diff --git a/contracts/nft-certificate/src/lib.rs b/contracts/nft-certificate/src/lib.rs
index 25448d85..01297dd4 100644
--- a/contracts/nft-certificate/src/lib.rs
+++ b/contracts/nft-certificate/src/lib.rs
@@ -21,6 +21,13 @@
//! PAUSED — bool (pause flag)
//! TOK_COUNT — u64 (total tokens minted, net of burns)
//!
+//!
+//! # Storage layout (Instance)
+//! ADMIN — Address (contract admin)
+//! ISSUERS — Vec
(authorized issuer set)
+//! PAUSED — bool (pause flag)
+//! TOK_COUNT — u64 (total tokens minted, net of burns)
+//!
//! # Storage layout (Persistent, keyed by token_id: u64)
//! Token(id) — Token (owner + metadata)
@@ -125,6 +132,34 @@ impl NftCertificate {
// ── Issuer management ─────────────────────────────────────────────────────
+ /// Add an address to the authorized issuer set.
+ ///
+ /// Admin only. The issuer is immediately permitted to call `mint`.
+ ///
+ /// # Errors
+ /// - `IssuerAlreadyExists` if the address is already an issuer.
+ pub fn add_issuer(env: Env, issuer: Address) {
+ Self::require_admin(&env);
+
+ let mut issuers: Vec = env
+ .storage().instance()
+ .get(&symbol_short!("ISSUERS"))
+ .unwrap_or_else(|| Vec::new(&env));
+
+ // Reject duplicates
+ for i in 0..issuers.len() {
+ if issuers.get(i).unwrap().issuer == issuer {
+ panic_with_error!(&env, NftCertError::IssuerAlreadyExists);
+ }
+ }
+
+ issuers.push_back(IssuerRecord {
+ issuer: issuer.clone(),
+ added_at: env.ledger().timestamp(),
+ });
+ env.storage().instance().set(&symbol_short!("ISSUERS"), &issuers);
+
+
/// Add an address to the authorized issuer set.
///
/// Admin only. The issuer is immediately permitted to call `mint`.
@@ -276,6 +311,10 @@ impl NftCertificate {
/// The caller must be the owner of all tokens being merged. This does NOT
/// require issuer authority — any token owner may merge their own tokens.
///
+ ///
+ /// The caller must be the owner of all tokens being merged. This does NOT
+ /// require issuer authority — any token owner may merge their own tokens.
+ ///
/// # Parameters
/// * `owner` — address that owns all input tokens (must sign)
/// * `token_ids` — list of token IDs to merge (must all belong to `owner`)
@@ -365,6 +404,10 @@ impl NftCertificate {
env.storage().persistent().remove(&key);
}
+
+ env.storage().persistent().remove(&key);
+ }
+
if total_trees != merged_metadata.tree_count || total_co2 != merged_metadata.co2_offset_kg {
panic_with_error!(&env, NftError::MetadataMismatch);
}
@@ -405,6 +448,15 @@ impl NftCertificate {
env.storage().instance().set(&symbol_short!("TOK_COUNT"), &new_count);
env.events().publish((symbol_short!("merged"), owner), (new_token_id, token_ids.len()));
+
+ let count: u64 = env.storage().instance()
+ .get(&symbol_short!("TOK_COUNT")).unwrap_or(0);
+ let new_count = count
+ .checked_sub(token_ids.len() as u64).expect("count underflow")
+ .checked_add(1).expect("count overflow");
+ env.storage().instance().set(&symbol_short!("TOK_COUNT"), &new_count);
+
+ env.events().publish((symbol_short!("merged"), owner), (new_token_id, token_ids.len()));
}
/// Split a single certificate into two new certificates with custom tree counts and CO2 offsets.
@@ -679,6 +731,34 @@ mod tests {
ctx.client.add_issuer(&issuer); // duplicate
}
+ #[test]
+ fn test_issuer_added_at_timestamp_stored() {
+ let ctx = setup();
+ let issuer = Address::generate(&ctx.env);
+ ctx.client.add_issuer(&issuer);
+ let rec = ctx.client.get_issuers().get(0).unwrap();
+ assert_eq!(rec.issuer, issuer);
+ assert_eq!(rec.added_at, ctx.env.ledger().timestamp());
+ }
+
+ // ── remove_issuer ─────────────────────────────────────────────────────────
+
+ #[test]
+ fn test_remove_issuer_revokes_permission() {
+ let ctx = setup();
+ let issuer = Address::generate(&ctx.env);
+ ctx.client.add_issuer(&issuer);
+ ctx.client.remove_issuer(&issuer);
+ assert!(!ctx.client.is_issuer(&issuer));
+ assert_eq!(ctx.client.get_issuers().len(), 0);
+ #[should_panic(expected = "Error(Contract, #301)")]
+ fn test_add_duplicate_issuer_rejected() {
+ let ctx = setup();
+ let issuer = Address::generate(&ctx.env);
+ ctx.client.add_issuer(&issuer);
+ ctx.client.add_issuer(&issuer); // duplicate
+ }
+
#[test]
fn test_issuer_added_at_timestamp_stored() {
let ctx = setup();
@@ -744,6 +824,180 @@ mod tests {
assert_eq!(ctx.client.total_supply(), 1);
}
+ #[test]
+ fn test_multiple_issuers_can_mint_independently() {
+ let ctx = setup();
+ let i1 = Address::generate(&ctx.env);
+ let i2 = Address::generate(&ctx.env);
+ let r1 = Address::generate(&ctx.env);
+ let r2 = Address::generate(&ctx.env);
+ ctx.client.add_issuer(&i1);
+ ctx.client.add_issuer(&i2);
+
+ ctx.client.mint(&i1, &r1, &1, &meta(&ctx.env, 10, 480));
+ ctx.client.mint(&i2, &r2, &2, &meta(&ctx.env, 20, 960));
+
+ assert_eq!(ctx.client.get_token(&1).unwrap().issuer, i1);
+ assert_eq!(ctx.client.get_token(&2).unwrap().issuer, i2);
+ assert_eq!(ctx.client.total_supply(), 2);
+ }
+
+ #[test]
+ #[should_panic(expected = "Error(Contract, #300)")]
+ fn test_unauthorized_address_cannot_mint() {
+ let ctx = setup();
+ let non_issuer = Address::generate(&ctx.env);
+ let recipient = Address::generate(&ctx.env);
+ ctx.client.mint(&non_issuer, &recipient, &1, &meta(&ctx.env, 10, 480));
+ }
+
+ #[test]
+ #[should_panic(expected = "Error(Contract, #300)")]
+ fn test_removed_issuer_cannot_mint() {
+ let ctx = setup();
+ let issuer = Address::generate(&ctx.env);
+ let recipient = Address::generate(&ctx.env);
+ ctx.client.add_issuer(&issuer);
+ ctx.client.remove_issuer(&issuer);
+ // Permission revoked — must fail
+ ctx.client.mint(&issuer, &recipient, &1, &meta(&ctx.env, 10, 480));
+ }
+
+ #[test]
+ #[should_panic(expected = "Error(Contract, #300)")]
+ fn test_admin_without_issuer_role_cannot_mint() {
+ let ctx = setup();
+ let recipient = Address::generate(&ctx.env);
+ // Admin has not added themselves to the issuer set
+ ctx.client.mint(&ctx.admin, &recipient, &1, &meta(&ctx.env, 10, 480));
+ }
+
+ #[test]
+ fn test_admin_added_as_issuer_can_mint() {
+ let ctx = setup();
+ let recipient = Address::generate(&ctx.env);
+ ctx.client.add_issuer(&ctx.admin);
+ ctx.client.mint(&ctx.admin, &recipient, &1, &meta(&ctx.env, 10, 480));
+ assert_eq!(ctx.client.owner_of(&1).unwrap(), recipient);
+ }
+
+ #[test]
+ #[should_panic(expected = "Error(Contract, #1)")] // NftError::TokenAlreadyMinted = 1
+ fn test_duplicate_token_id_rejected() {
+ let ctx = setup();
+ let issuer = Address::generate(&ctx.env);
+ let recipient = Address::generate(&ctx.env);
+ ctx.client.add_issuer(&issuer);
+ ctx.client.mint(&issuer, &recipient, &1, &meta(&ctx.env, 10, 480));
+ ctx.client.mint(&issuer, &recipient, &1, &meta(&ctx.env, 10, 480));
+ }
+
+ #[test]
+ #[should_panic(expected = "Error(Contract, #10)")]
+ fn test_mint_zero_tree_count_rejected() {
+ let ctx = setup();
+ let issuer = Address::generate(&ctx.env);
+ ctx.client.add_issuer(&issuer);
+ ctx.client.mint(&issuer, &Address::generate(&ctx.env), &1, &meta(&ctx.env, 0, 480));
+ }
+
+ #[test]
+ #[should_panic(expected = "Error(Contract, #62)")]
+ fn test_mint_zero_co2_rejected() {
+ let ctx = setup();
+ let issuer = Address::generate(&ctx.env);
+ ctx.client.add_issuer(&issuer);
+ ctx.client.mint(&issuer, &Address::generate(&ctx.env), &1, &meta(&ctx.env, 10, 0));
+ }
+
+ // ── merge ─────────────────────────────────────────────────────────────────
+
+ #[test]
+ fn test_merge_two_certificates() {
+ let ctx = setup();
+ let issuer = Address::generate(&ctx.env);
+ let owner = Address::generate(&ctx.env);
+ ctx.client.add_issuer(&issuer);
+
+ ctx.client.mint(&issuer, &owner, &1, &meta(&ctx.env, 50, 2400));
+ ctx.client.mint(&issuer, &owner, &2, &meta(&ctx.env, 75, 3600));
+
+ let ids = soroban_sdk::vec![&ctx.env, 1u64, 2u64];
+ ctx.client.merge(&owner, &ids, &3, &meta(&ctx.env, 125, 6000));
+
+ assert!(ctx.client.get_token(&1).is_none());
+ assert!(ctx.client.get_token(&2).is_none());
+ let merged = ctx.client.get_token(&3).unwrap();
+ assert_eq!(merged.owner, owner);
+ assert_eq!(merged.metadata.tree_count, 125);
+ assert_eq!(merged.metadata.co2_offset_kg, 6000);
+ assert_eq!(ctx.client.total_supply(), 1);
+ }
+
+ #[test]
+ #[should_panic(expected = "Error(Contract, #3)")]
+ fn test_merge_tokens_not_owned_by_caller_rejected() {
+ let ctx = setup();
+ let issuer = Address::generate(&ctx.env);
+ let owner = Address::generate(&ctx.env);
+ let other = Address::generate(&ctx.env);
+ ctx.client.add_issuer(&issuer);
+ ctx.client.mint(&issuer, &owner, &1, &meta(&ctx.env, 50, 2400));
+
+ let ids = soroban_sdk::vec![&ctx.env, 1u64];
+ ctx.client.merge(&other, &ids, &2, &meta(&ctx.env, 50, 2400));
+ }
+
+ #[test]
+ #[should_panic(expected = "Error(Contract, #3)")] // NftError::MetadataMismatch = 3
+ fn test_merge_metadata_mismatch_rejected() {
+ let ctx = setup();
+ let issuer = Address::generate(&ctx.env);
+ let owner = Address::generate(&ctx.env);
+ ctx.client.add_issuer(&issuer);
+ ctx.client.mint(&issuer, &owner, &1, &meta(&ctx.env, 50, 2400));
+
+ let ids = soroban_sdk::vec![&ctx.env, 1u64];
+ // Sums say 50/2400 but we claim 100/4800
+ ctx.client.merge(&owner, &ids, &2, &meta(&ctx.env, 100, 4800));
+ }
+
+ // ── pause / unpause ───────────────────────────────────────────────────────
+
+ #[test]
+ fn test_pause_blocks_mint() {
+ let ctx = setup();
+ let issuer = Address::generate(&ctx.env);
+ ctx.client.add_issuer(&issuer);
+ ctx.client.pause();
+ assert!(ctx.client.is_paused());
+ let result = ctx.client.try_mint(
+ &issuer, &Address::generate(&ctx.env), &1, &meta(&ctx.env, 10, 480),
+ );
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn test_unpause_restores_mint() {
+ let ctx = setup();
+ let issuer = Address::generate(&ctx.env);
+ ctx.client.add_issuer(&issuer);
+ ctx.client.pause();
+ ctx.client.unpause();
+ assert!(!ctx.client.is_paused());
+ ctx.client.mint(&issuer, &Address::generate(&ctx.env), &1, &meta(&ctx.env, 10, 480));
+ assert_eq!(ctx.client.total_supply(), 1);
+ }
+
+ // ── is_issuer / get_issuers ───────────────────────────────────────────────
+
+ #[test]
+ fn test_unknown_address_is_not_issuer() {
+ let ctx = setup();
+ let random = Address::generate(&ctx.env);
+ assert!(!ctx.client.is_issuer(&random));
+ }
+
// ── remove_issuer ─────────────────────────────────────────────────────────
#[test]
@@ -1264,6 +1518,42 @@ mod tests {
assert_eq!(ctx.client.total_supply(), 2);
}
+ #[test]
+ fn test_total_supply_decreases_by_net_on_merge() {
+ let ctx = setup();
+ let issuer = Address::generate(&ctx.env);
+ let owner = Address::generate(&ctx.env);
+ ctx.client.add_issuer(&issuer);
+ for id in 1u64..=4 {
+ ctx.client.mint(&issuer, &owner, &id, &meta(&ctx.env, 10, 480));
+ }
+ assert_eq!(ctx.client.total_supply(), 4);
+ let ids = soroban_sdk::vec![&ctx.env, 1u64, 2u64, 3u64, 4u64];
+ ctx.client.merge(&owner, &ids, &5, &meta(&ctx.env, 40, 1920));
+ // 4 burned + 1 minted = net -3
+ assert_eq!(ctx.client.total_supply(), 1);
+ }
+
+ #[test]
+ fn test_owner_of_returns_none_for_unknown_token() {
+ let ctx = setup();
+ assert!(ctx.client.owner_of(&999).is_none());
+ }
+
+ // ── total_supply ──────────────────────────────────────────────────────────
+
+ #[test]
+ fn test_total_supply_increments_on_mint() {
+ let ctx = setup();
+ let issuer = Address::generate(&ctx.env);
+ ctx.client.add_issuer(&issuer);
+ assert_eq!(ctx.client.total_supply(), 0);
+ ctx.client.mint(&issuer, &Address::generate(&ctx.env), &1, &meta(&ctx.env, 5, 240));
+ assert_eq!(ctx.client.total_supply(), 1);
+ ctx.client.mint(&issuer, &Address::generate(&ctx.env), &2, &meta(&ctx.env, 5, 240));
+ assert_eq!(ctx.client.total_supply(), 2);
+ }
+
#[test]
fn test_total_supply_decreases_by_net_on_merge() {
let ctx = setup();
diff --git a/contracts/platform-governance/src/lib.rs b/contracts/platform-governance/src/lib.rs
index f6d0be8e..b74ee3e3 100644
--- a/contracts/platform-governance/src/lib.rs
+++ b/contracts/platform-governance/src/lib.rs
@@ -43,10 +43,28 @@
//! VEST: — VestingSchedule (linear token lockup for planters)
use soroban_sdk::{
+ contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, Env,
+ String, Symbol, Vec,
contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, token,
Address, Env, IntoVal, String, Symbol, Val, Vec,
};
+// ── Error codes ───────────────────────────────────────────────────────────────
+
+#[contracterror]
+#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
+#[repr(u32)]
+pub enum GovernanceError {
+ /// No TREE tokens are locked for this voter
+ NoLockedTokens = 200,
+ /// Lock amount must be positive
+ LockAmountMustBePositive = 201,
+ /// Requested unlock amount exceeds locked balance
+ InsufficientLockedBalance = 202,
+ /// Tokens are still time-locked and cannot be withdrawn yet
+ LockNotYetExpired = 203,
+}
+
// ── Types ─────────────────────────────────────────────────────────────────────
/// Proposal type for different governance actions
@@ -146,6 +164,24 @@ pub struct DelegateRecord {
pub registered_at: u64,
}
+/// Record of a voter's locked TREE tokens used for quadratic voting power.
+///
+/// Locking is non-custodial from a governance perspective: tokens are held
+/// in this contract and can be unlocked by the owner at any time
+/// (subject to an optional minimum lock period stored in `locked_until`).
+#[contracttype]
+#[derive(Clone, Debug)]
+pub struct TokenLock {
+ /// Voter who owns these locked tokens
+ pub voter: Address,
+ /// TREE token contract address
+ pub token: Address,
+ /// Total amount currently locked (in token's native units / stroops)
+ pub amount: i128,
+ /// Computed quadratic voting power = isqrt(amount)
+ pub voting_power: i128,
+ /// Earliest unlock timestamp (0 = unlockable immediately)
+ pub locked_until: u64,
/// Linear vesting schedule for community tree planter rewards.
///
/// Tokens are released linearly between `start_at + cliff_seconds` and
@@ -247,6 +283,21 @@ fn vote_key(proposal_id: u64, voter: &Address) -> (Symbol, u64, Address) {
(symbol_short!("VOTE"), proposal_id, voter.clone())
}
+/// Key for a voter's token lock record.
+fn token_lock_key(voter: &Address) -> (Symbol, Address) {
+ (symbol_short!("TLOCK"), voter.clone())
+}
+
+/// Key for the TREE token contract address used for lock deposits.
+fn tree_token_key() -> Symbol {
+ symbol_short!("TREE_TOK")
+}
+
+/// Key for the minimum lock period in seconds (0 = no minimum).
+fn min_lock_seconds_key() -> Symbol {
+ symbol_short!("MIN_LOCK")
+}
+
/// Key for a registered delegate's DelegateRecord.
fn delegate_info_key(delegate: &Address) -> (Symbol, Address) {
(symbol_short!("DLGT"), delegate.clone())
@@ -309,10 +360,11 @@ impl PlatformGovernance {
/// One-time initialisation.
///
/// `admin` — admin address for contract management
- /// `staking_contract` — verifier-staking contract for voting power
+ /// `staking_contract` — verifier-staking contract (legacy; kept for compatibility)
/// `admin_controls` — admin-controls contract for parameter updates
/// `platform_fee` — initial platform fee percentage
/// `min_planting_bond` — initial minimum planting bond
+ /// `tree_token` — TREE SAC token address used for voting power locks
pub fn initialize(
env: Env,
admin: Address,
@@ -320,6 +372,7 @@ impl PlatformGovernance {
admin_controls: Address,
platform_fee: u64,
min_planting_bond: i128,
+ tree_token: Address,
) {
if env.storage().instance().has(&admin_key()) {
panic!("already initialized");
@@ -343,6 +396,15 @@ impl PlatformGovernance {
env.storage()
.instance()
.set(&min_planting_bond_key(), &min_planting_bond);
+ env.storage()
+ .instance()
+ .set(&proposal_count_key(), &0u64);
+ env.storage()
+ .instance()
+ .set(&tree_token_key(), &tree_token);
+ env.storage()
+ .instance()
+ .set(&min_lock_seconds_key(), &0u64);
env.storage().instance().set(&proposal_count_key(), &0u64);
// Initialize empty verifier whitelist
@@ -475,12 +537,15 @@ impl PlatformGovernance {
panic!("already voted on this proposal");
}
- // Get voting power from staking contract
+ // Get quadratic voting power from locked TREE tokens
let staking_contract: Address = env
.storage()
.instance()
.get(&staking_contract_key())
.expect("not initialized");
+ let own_power = Self::get_voting_power(&env, &staking_contract, &voter);
+ let delegated_power = Self::aggregate_delegated_power(&env, &staking_contract, &voter);
+ let power = own_power + delegated_power;
// Get raw voting power (staked token amount)
let own_power = Self::get_voting_power(&env, &staking_contract, &voter);
@@ -490,8 +555,8 @@ impl PlatformGovernance {
let raw_power = own_power + delegated_power;
- if raw_power <= 0 {
- panic!("must be a staked verifier or delegate to vote");
+ if power <= 0 {
+ panic!("must lock TREE tokens to vote");
}
// Track this voter's activity for the rolling 30-day window used to
@@ -540,8 +605,7 @@ impl PlatformGovernance {
proposal.total_votes += power;
// Check if proposal meets quorum
- let total_staked = Self::get_total_staked(&env, &staking_contract);
- let quorum_percentage: u64 = env
+ let total_staked = Self::get_total_staked(&env, &staking_contract); let quorum_percentage: u64 = env
.storage()
.instance()
.get(&quorum_percentage_key())
@@ -730,6 +794,166 @@ impl PlatformGovernance {
);
}
+ // ── Quadratic voting token lock (issue #761) ──────────────────────────────
+
+ /// Lock TREE tokens to establish quadratic voting power.
+ ///
+ /// Voting power is computed as `isqrt(total_locked_amount)`. Successive
+ /// calls add to the existing lock — tokens are accumulated, not replaced.
+ ///
+ /// # Authorization
+ /// `voter` must sign.
+ ///
+ /// # Parameters
+ /// * `voter` — address locking tokens (must sign)
+ /// * `amount` — number of TREE tokens to lock (in stroops)
+ ///
+ /// # Errors
+ /// Panics with `LockAmountMustBePositive` if `amount <= 0`.
+ pub fn lock_tokens(env: Env, voter: Address, amount: i128) {
+ use crate::GovernanceError;
+ voter.require_auth();
+
+ if amount <= 0 {
+ soroban_sdk::panic_with_error!(&env, GovernanceError::LockAmountMustBePositive);
+ }
+
+ let tree_token: Address = env
+ .storage()
+ .instance()
+ .get(&tree_token_key())
+ .expect("tree token not configured");
+
+ // Transfer tokens from voter into this contract
+ token::Client::new(&env, &tree_token).transfer(
+ &voter,
+ &env.current_contract_address(),
+ &amount,
+ );
+
+ let min_lock: u64 = env
+ .storage()
+ .instance()
+ .get(&min_lock_seconds_key())
+ .unwrap_or(0);
+
+ let locked_until = if min_lock > 0 {
+ env.ledger()
+ .timestamp()
+ .checked_add(min_lock)
+ .expect("lock expiry overflow")
+ } else {
+ 0
+ };
+
+ // Accumulate into existing lock
+ let existing: Option = env
+ .storage()
+ .persistent()
+ .get(&token_lock_key(&voter));
+
+ let new_amount = match existing {
+ Some(lock) => lock
+ .amount
+ .checked_add(amount)
+ .expect("locked balance overflow"),
+ None => amount,
+ };
+
+ let voting_power = Self::isqrt(new_amount);
+
+ env.storage().persistent().set(
+ &token_lock_key(&voter),
+ &TokenLock {
+ voter: voter.clone(),
+ token: tree_token,
+ amount: new_amount,
+ voting_power,
+ locked_until,
+ },
+ );
+
+ env.events().publish(
+ (symbol_short!("tok_lock"), voter),
+ (new_amount, voting_power),
+ );
+ }
+
+ /// Unlock previously locked TREE tokens.
+ ///
+ /// Reduces the lock by `amount`. Voting power is recomputed on the
+ /// remaining balance.
+ ///
+ /// # Authorization
+ /// `voter` must sign.
+ ///
+ /// # Errors
+ /// - `NoLockedTokens` — voter has no lock record
+ /// - `InsufficientLockedBalance` — requested amount exceeds lock
+ /// - `LockNotYetExpired` — minimum lock period not elapsed
+ pub fn unlock_tokens(env: Env, voter: Address, amount: i128) {
+ use crate::GovernanceError;
+ voter.require_auth();
+
+ if amount <= 0 {
+ soroban_sdk::panic_with_error!(&env, GovernanceError::LockAmountMustBePositive);
+ }
+
+ let mut lock: TokenLock = env
+ .storage()
+ .persistent()
+ .get(&token_lock_key(&voter))
+ .unwrap_or_else(|| {
+ soroban_sdk::panic_with_error!(&env, GovernanceError::NoLockedTokens)
+ });
+
+ if lock.locked_until > 0 && env.ledger().timestamp() < lock.locked_until {
+ soroban_sdk::panic_with_error!(&env, GovernanceError::LockNotYetExpired);
+ }
+
+ if amount > lock.amount {
+ soroban_sdk::panic_with_error!(&env, GovernanceError::InsufficientLockedBalance);
+ }
+
+ lock.amount = lock.amount.checked_sub(amount).expect("underflow");
+ lock.voting_power = Self::isqrt(lock.amount);
+
+ // Transfer back to voter
+ token::Client::new(&env, &lock.token).transfer(
+ &env.current_contract_address(),
+ &voter,
+ &amount,
+ );
+
+ env.storage()
+ .persistent()
+ .set(&token_lock_key(&voter), &lock);
+
+ env.events().publish(
+ (symbol_short!("tok_unlk"), voter),
+ (lock.amount, lock.voting_power),
+ );
+ }
+
+ /// Returns the `TokenLock` record for `voter`, or `None` if no tokens
+ /// are locked.
+ pub fn locked_balance(env: Env, voter: Address) -> Option {
+ env.storage()
+ .persistent()
+ .get(&token_lock_key(&voter))
+ }
+
+ /// Set the minimum lock period in seconds. Admin only.
+ /// Pass 0 to disable (tokens immediately unlockable).
+ pub fn set_min_lock_seconds(env: Env, seconds: u64) {
+ Self::require_admin(&env);
+ env.storage()
+ .instance()
+ .set(&min_lock_seconds_key(), &seconds);
+ env.events()
+ .publish((symbol_short!("min_lock"),), seconds);
+ }
+
// ── Liquid democracy ──────────────────────────────────────────────────────
/// Register the caller as a liquid-democracy delegate for a governance domain.
@@ -1287,6 +1511,16 @@ impl PlatformGovernance {
panic_with_error!(&env, GovernanceError::VestingCliffExceedsDuration);
}
+ fn get_voting_power(env: &Env, _staking_contract: &Address, voter: &Address) -> i128 {
+ // Quadratic voting power = isqrt(locked_token_amount).
+ // The isqrt is already pre-computed and stored in the TokenLock record
+ // so we just read it — O(1) with no arithmetic at vote time.
+ env.storage()
+ .persistent()
+ .get::<(Symbol, Address), TokenLock>(&token_lock_key(voter))
+ .map(|lock| lock.voting_power)
+ .unwrap_or(0)
+ }
let key = vesting_key(&planter);
if env.storage().persistent().has(&key) {
let existing: VestingSchedule = env
@@ -1826,6 +2060,10 @@ mod tests {
use super::*;
use soroban_sdk::{
testutils::{Address as _, Ledger},
+ token, Address, Env, String,
+ };
+
+ fn setup() -> (Env, Address, Address, Address, Address, PlatformGovernanceClient<'static>) {
token::TokenClient,
Address, Env, String,
};
@@ -1847,23 +2085,39 @@ mod tests {
let staking_contract = Address::generate(&env);
let admin_controls = Address::generate(&env);
+ // Register a TREE SAC token with this contract as admin
+ let tree_token = env
+ .register_stellar_asset_contract_v2(contract_id.clone())
+ .address();
+
client.initialize(
&admin,
&staking_contract,
&admin_controls,
&DEFAULT_PLATFORM_FEE,
&DEFAULT_MIN_PLANTING_BOND,
+ &tree_token,
);
- (env, admin, staking_contract, admin_controls, client)
+ (env, admin, staking_contract, admin_controls, tree_token, client)
}
- // ── Existing tests ────────────────────────────────────────────────────────
+ /// Helper: mint `amount` TREE tokens to `voter` and lock them so they
+ /// have quadratic voting power of `isqrt(amount)`.
+ fn lock_for_voter(
+ env: &Env,
+ tree_token: &Address,
+ client: &PlatformGovernanceClient,
+ voter: &Address,
+ amount: i128,
+ ) {
+ token::StellarAssetClient::new(env, tree_token).mint(voter, &amount);
+ client.lock_tokens(voter, &amount);
+ }
#[test]
fn test_initialize() {
- let (_, _admin, _, _, client) = setup();
-
+ let (_, _admin, _, _, _, client) = setup();
assert_eq!(client.platform_fee(), DEFAULT_PLATFORM_FEE);
assert_eq!(client.min_planting_bond(), DEFAULT_MIN_PLANTING_BOND);
assert_eq!(client.quorum_percentage(), DEFAULT_QUORUM_PERCENTAGE);
@@ -1872,25 +2126,15 @@ mod tests {
#[test]
fn test_create_proposal() {
- let (env, admin, _, _, client) = setup();
-
+ let (env, admin, _, _, tree_token, client) = setup();
+ lock_for_voter(&env, &tree_token, &client, &admin, 10_000);
let description_hash = String::from_str(&env, "hash123");
let proposal_type = ProposalType::PlatformFee;
-
let mut options = Vec::new(&env);
- options.push_back(VoteOption {
- option_id: 1,
- description: String::from_str(&env, "Set fee to 10%"),
- });
- options.push_back(VoteOption {
- option_id: 2,
- description: String::from_str(&env, "Set fee to 15%"),
- });
-
+ options.push_back(VoteOption { option_id: 1, description: String::from_str(&env, "Set fee to 10%") });
+ options.push_back(VoteOption { option_id: 2, description: String::from_str(&env, "Set fee to 15%") });
client.create_proposal(&description_hash, &proposal_type, &options, &604800, &admin);
-
assert_eq!(client.proposal_count(), 1);
-
let proposal = client.get_proposal(&0);
assert_eq!(proposal.description_hash, description_hash);
assert!(matches!(proposal.status, ProposalStatus::Active));
@@ -1898,60 +2142,38 @@ mod tests {
#[test]
fn test_vote_on_proposal() {
- let (env, admin, _, _, client) = setup();
-
- let description_hash = String::from_str(&env, "hash123");
- let proposal_type = ProposalType::PlatformFee;
-
+ let (env, admin, _, _, tree_token, client) = setup();
+ // Lock 10000 tokens → sqrt(10000) = 100 voting power
+ lock_for_voter(&env, &tree_token, &client, &admin, 10_000);
let mut options = Vec::new(&env);
- options.push_back(VoteOption {
- option_id: 1,
- description: String::from_str(&env, "Set fee to 10%"),
- });
-
- client.create_proposal(&description_hash, &proposal_type, &options, &604800, &admin);
+ options.push_back(VoteOption { option_id: 1, description: String::from_str(&env, "Set fee to 10%") });
+ client.create_proposal(&String::from_str(&env, "hash123"), &ProposalType::PlatformFee, &options, &604800, &admin);
client.vote(&0, &1, &admin);
-
let proposal = client.get_proposal(&0);
- assert_eq!(proposal.total_votes, 1000);
+ assert_eq!(proposal.total_votes, 100); // sqrt(10000) = 100
}
#[test]
#[should_panic(expected = "already voted on this proposal")]
fn test_double_vote_rejected() {
- let (env, admin, _, _, client) = setup();
-
- let description_hash = String::from_str(&env, "hash123");
- let proposal_type = ProposalType::PlatformFee;
-
+ let (env, admin, _, _, tree_token, client) = setup();
+ lock_for_voter(&env, &tree_token, &client, &admin, 10_000);
let mut options = Vec::new(&env);
- options.push_back(VoteOption {
- option_id: 1,
- description: String::from_str(&env, "Set fee to 10%"),
- });
-
- client.create_proposal(&description_hash, &proposal_type, &options, &604800, &admin);
+ options.push_back(VoteOption { option_id: 1, description: String::from_str(&env, "Set fee to 10%") });
+ client.create_proposal(&String::from_str(&env, "hash123"), &ProposalType::PlatformFee, &options, &604800, &admin);
client.vote(&0, &1, &admin);
client.vote(&0, &1, &admin);
}
#[test]
fn test_execute_passed_proposal() {
- let (env, admin, _, _, client) = setup();
-
- let description_hash = String::from_str(&env, "hash123");
- let proposal_type = ProposalType::PlatformFee;
-
+ let (env, admin, _, _, tree_token, client) = setup();
+ lock_for_voter(&env, &tree_token, &client, &admin, 10_000);
let mut options = Vec::new(&env);
- options.push_back(VoteOption {
- option_id: 1,
- description: String::from_str(&env, "Set fee to 10%"),
- });
-
- client.create_proposal(&description_hash, &proposal_type, &options, &1, &admin);
-
- // Vote with admin (single vote for simplicity)
+ options.push_back(VoteOption { option_id: 1, description: String::from_str(&env, "Set fee to 10%") });
+ client.create_proposal(&String::from_str(&env, "hash123"), &ProposalType::PlatformFee, &options, &1, &admin);
client.vote(&0, &1, &admin);
+ env.ledger().set_timestamp(env.ledger().timestamp() + 200000);
// Advance past voting period and timelock
env.ledger()
@@ -1962,6 +2184,12 @@ mod tests {
#[test]
#[should_panic(expected = "proposal has not passed")]
+ fn test_execute_failed_proposal_rejected() {
+ let (env, admin, _, _, _tree_token, client) = setup();
+ let mut options = Vec::new(&env);
+ options.push_back(VoteOption { option_id: 1, description: String::from_str(&env, "Set fee to 10%") });
+ client.create_proposal(&String::from_str(&env, "hash123"), &ProposalType::PlatformFee, &options, &1, &admin);
+ client.execute(&0);
fn test_queue_failed_proposal_rejected() {
let (env, admin, _, _, client) = setup();
@@ -1982,26 +2210,21 @@ mod tests {
#[test]
fn test_admin_set_platform_fee() {
- let (_, _admin, _, _, client) = setup();
-
+ let (_, _admin, _, _, _, client) = setup();
client.set_platform_fee(&15);
assert_eq!(client.platform_fee(), 15);
}
#[test]
fn test_verifier_whitelist() {
- let (env, _admin, _, _, client) = setup();
-
+ let (env, _admin, _, _, _, client) = setup();
let verifier = Address::generate(&env);
client.add_verifier_to_whitelist(&verifier);
-
let whitelist = client.verifier_whitelist();
assert_eq!(whitelist.len(), 1);
assert_eq!(whitelist.get(0).unwrap(), verifier);
-
client.remove_verifier_from_whitelist(&verifier);
- let whitelist = client.verifier_whitelist();
- assert_eq!(whitelist.len(), 0);
+ assert_eq!(client.verifier_whitelist().len(), 0);
}
#[test]
@@ -2014,7 +2237,6 @@ mod tests {
assert_eq!(PlatformGovernance::isqrt(25), 5);
assert_eq!(PlatformGovernance::isqrt(100), 10);
assert_eq!(PlatformGovernance::isqrt(10000), 100);
- // Test non-perfect squares
assert_eq!(PlatformGovernance::isqrt(2), 1);
assert_eq!(PlatformGovernance::isqrt(8), 2);
assert_eq!(PlatformGovernance::isqrt(15), 3);
@@ -2023,70 +2245,59 @@ mod tests {
#[test]
fn test_quadratic_voting_species_selection() {
+ let (env, admin, _, _, tree_token, client) = setup();
+ // Lock 1000 tokens → sqrt(1000) ≈ 31
+ lock_for_voter(&env, &tree_token, &client, &admin, 1_000);
let (env, admin, _, _, client) = setup();
let description_hash = String::from_str(&env, "species_hash");
let proposal_type = ProposalType::SpeciesSelection;
let mut options = Vec::new(&env);
- options.push_back(VoteOption {
- option_id: 1,
- description: String::from_str(&env, "Oak Tree"),
- });
- options.push_back(VoteOption {
- option_id: 2,
- description: String::from_str(&env, "Pine Tree"),
- });
-
- client.create_proposal(&description_hash, &proposal_type, &options, &604800, &admin);
+ options.push_back(VoteOption { option_id: 1, description: String::from_str(&env, "Oak Tree") });
+ options.push_back(VoteOption { option_id: 2, description: String::from_str(&env, "Pine Tree") });
+ client.create_proposal(&String::from_str(&env, "species_hash"), &ProposalType::SpeciesSelection, &options, &604800, &admin);
client.vote(&0, &1, &admin);
-
let proposal = client.get_proposal(&0);
- // With raw power of 1000, sqrt(1000) ≈ 31
- assert_eq!(proposal.total_votes, 31);
+ assert_eq!(proposal.total_votes, 31); // isqrt(1000) = 31
}
#[test]
fn test_normal_voting_platform_fee() {
+ let (env, admin, _, _, tree_token, client) = setup();
+ // Lock 10000 tokens → quadratic power = sqrt(10000) = 100
+ lock_for_voter(&env, &tree_token, &client, &admin, 10_000);
let (env, admin, _, _, client) = setup();
let description_hash = String::from_str(&env, "fee_hash");
let proposal_type = ProposalType::PlatformFee;
let mut options = Vec::new(&env);
- options.push_back(VoteOption {
- option_id: 1,
- description: String::from_str(&env, "Set fee to 10%"),
- });
-
- client.create_proposal(&description_hash, &proposal_type, &options, &604800, &admin);
+ options.push_back(VoteOption { option_id: 1, description: String::from_str(&env, "Set fee to 10%") });
+ client.create_proposal(&String::from_str(&env, "fee_hash"), &ProposalType::PlatformFee, &options, &604800, &admin);
client.vote(&0, &1, &admin);
-
let proposal = client.get_proposal(&0);
- // Normal voting uses raw power (1000)
- assert_eq!(proposal.total_votes, 1000);
+ assert_eq!(proposal.total_votes, 100); // isqrt(10000) = 100
}
#[test]
fn test_species_selection_execution() {
+ let (env, admin, _, _, tree_token, client) = setup();
+ lock_for_voter(&env, &tree_token, &client, &admin, 10_000);
let (env, admin, _, _, client) = setup();
let description_hash = String::from_str(&env, "species_hash");
let proposal_type = ProposalType::SpeciesSelection;
let mut options = Vec::new(&env);
- options.push_back(VoteOption {
- option_id: 1,
- description: String::from_str(&env, "Oak Tree"),
- });
-
- client.create_proposal(&description_hash, &proposal_type, &options, &1, &admin);
+ options.push_back(VoteOption { option_id: 1, description: String::from_str(&env, "Oak Tree") });
+ client.create_proposal(&String::from_str(&env, "species_hash"), &ProposalType::SpeciesSelection, &options, &1, &admin);
client.vote(&0, &1, &admin);
-
- // Manually set proposal to Passed for testing
let mut proposal = client.get_proposal(&0);
proposal.status = ProposalStatus::Passed;
env.storage().persistent().set(&proposal_key(0), &proposal);
+ client.queue(&0);
+ env.ledger().set_timestamp(env.ledger().timestamp() + 200000);
// Queue it — starts the 48h timelock
client.queue(&0);
@@ -2096,20 +2307,19 @@ mod tests {
.set_timestamp(env.ledger().timestamp() + DEFAULT_TIMELOCK_SECONDS + 1);
client.execute(&0);
-
- let proposal = client.get_proposal(&0);
- assert!(matches!(proposal.status, ProposalStatus::Executed));
+ assert!(matches!(client.get_proposal(&0).status, ProposalStatus::Executed));
}
// ── Timelock controller tests (#752) ──────────────────────────────────────
- /// Helper: create a proposal, manually mark it Passed, and return its ID.
fn create_passed_proposal(
env: &Env,
client: &PlatformGovernanceClient,
admin: &Address,
+ tree_token: &Address,
voting_period: u64,
) -> u64 {
+ lock_for_voter(env, tree_token, client, admin, 10_000);
let mut options = Vec::new(env);
options.push_back(VoteOption {
option_id: 1,
@@ -2131,26 +2341,21 @@ mod tests {
#[test]
fn test_queue_transitions_passed_to_queued() {
- let (env, admin, _, _, client) = setup();
- let id = create_passed_proposal(&env, &client, &admin, 1);
-
+ let (env, admin, _, _, tree_token, client) = setup();
+ let id = create_passed_proposal(&env, &client, &admin, &tree_token, 1);
client.queue(&id);
-
let proposal = client.get_proposal(&id);
assert!(matches!(proposal.status, ProposalStatus::Queued));
assert!(proposal.queued_at > 0);
- // executable_at must be queued_at + DEFAULT_TIMELOCK_SECONDS
assert_eq!(proposal.executable_at, proposal.queued_at + DEFAULT_TIMELOCK_SECONDS);
}
#[test]
fn test_queue_sets_executable_at_48h_from_now() {
- let (env, admin, _, _, client) = setup();
- let id = create_passed_proposal(&env, &client, &admin, 1);
-
+ let (env, admin, _, _, tree_token, client) = setup();
+ let id = create_passed_proposal(&env, &client, &admin, &tree_token, 1);
let before = env.ledger().timestamp();
client.queue(&id);
-
let proposal = client.get_proposal(&id);
assert_eq!(proposal.queued_at, before);
assert_eq!(proposal.executable_at, before + DEFAULT_TIMELOCK_SECONDS);
@@ -2158,38 +2363,19 @@ mod tests {
#[test]
fn test_full_lifecycle_create_vote_queue_execute() {
- let (env, admin, _, _, client) = setup();
-
- // 1. Create
+ let (env, admin, _, _, tree_token, client) = setup();
+ lock_for_voter(&env, &tree_token, &client, &admin, 10_000);
let mut options = Vec::new(&env);
- options.push_back(VoteOption {
- option_id: 1,
- description: String::from_str(&env, "Set fee to 10%"),
- });
- client.create_proposal(
- &String::from_str(&env, "hash"),
- &ProposalType::PlatformFee,
- &options,
- &1,
- &admin,
- );
+ options.push_back(VoteOption { option_id: 1, description: String::from_str(&env, "Set fee to 10%") });
+ client.create_proposal(&String::from_str(&env, "hash"), &ProposalType::PlatformFee, &options, &1, &admin);
let id = 0u64;
assert!(matches!(client.get_proposal(&id).status, ProposalStatus::Active));
-
- // 2. Vote — manually set Passed (simplified: skips quorum threshold)
let mut proposal = client.get_proposal(&id);
proposal.status = ProposalStatus::Passed;
env.storage().persistent().set(&proposal_key(id), &proposal);
- assert!(matches!(client.get_proposal(&id).status, ProposalStatus::Passed));
-
- // 3. Queue
client.queue(&id);
assert!(matches!(client.get_proposal(&id).status, ProposalStatus::Queued));
-
- // 4. Advance past timelock
env.ledger().set_timestamp(env.ledger().timestamp() + DEFAULT_TIMELOCK_SECONDS + 1);
-
- // 5. Execute
client.execute(&id);
assert!(matches!(client.get_proposal(&id).status, ProposalStatus::Executed));
}
@@ -2197,105 +2383,72 @@ mod tests {
#[test]
#[should_panic(expected = "proposal has not passed")]
fn test_queue_active_proposal_rejected() {
- let (env, admin, _, _, client) = setup();
+ let (env, admin, _, _, _tree_token, client) = setup();
let mut options = Vec::new(&env);
- options.push_back(VoteOption {
- option_id: 1,
- description: String::from_str(&env, "Yes"),
- });
- client.create_proposal(
- &String::from_str(&env, "hash"),
- &ProposalType::PlatformFee,
- &options,
- &604800,
- &admin,
- );
- // Proposal is Active, not Passed — must fail
+ options.push_back(VoteOption { option_id: 1, description: String::from_str(&env, "Yes") });
+ client.create_proposal(&String::from_str(&env, "hash"), &ProposalType::PlatformFee, &options, &604800, &admin);
client.queue(&0);
}
#[test]
#[should_panic(expected = "proposal has not passed")]
fn test_queue_already_queued_proposal_rejected() {
- let (env, admin, _, _, client) = setup();
- let id = create_passed_proposal(&env, &client, &admin, 1);
+ let (env, admin, _, _, tree_token, client) = setup();
+ let id = create_passed_proposal(&env, &client, &admin, &tree_token, 1);
client.queue(&id);
- // Second queue call — now status is Queued, not Passed → must fail
client.queue(&id);
}
#[test]
#[should_panic(expected = "proposal not queued for execution")]
fn test_execute_passed_but_not_queued_rejected() {
- let (env, admin, _, _, client) = setup();
- let id = create_passed_proposal(&env, &client, &admin, 1);
- // Advance time past any timelock
+ let (env, admin, _, _, tree_token, client) = setup();
+ let id = create_passed_proposal(&env, &client, &admin, &tree_token, 1);
env.ledger().set_timestamp(env.ledger().timestamp() + 300_000);
- // Must fail — proposal is Passed but never queued
client.execute(&id);
}
#[test]
#[should_panic(expected = "proposal not queued for execution")]
fn test_execute_active_proposal_rejected() {
- let (env, admin, _, _, client) = setup();
+ let (env, admin, _, _, _tree_token, client) = setup();
let mut options = Vec::new(&env);
- options.push_back(VoteOption {
- option_id: 1,
- description: String::from_str(&env, "Yes"),
- });
- client.create_proposal(
- &String::from_str(&env, "hash"),
- &ProposalType::PlatformFee,
- &options,
- &604800,
- &admin,
- );
+ options.push_back(VoteOption { option_id: 1, description: String::from_str(&env, "Yes") });
+ client.create_proposal(&String::from_str(&env, "hash"), &ProposalType::PlatformFee, &options, &604800, &admin);
client.execute(&0);
}
#[test]
#[should_panic(expected = "timelock period has not elapsed")]
fn test_execute_before_timelock_elapses_rejected() {
- let (env, admin, _, _, client) = setup();
- let id = create_passed_proposal(&env, &client, &admin, 1);
+ let (env, admin, _, _, tree_token, client) = setup();
+ let id = create_passed_proposal(&env, &client, &admin, &tree_token, 1);
client.queue(&id);
- // Do NOT advance time — timelock has not elapsed
client.execute(&id);
}
#[test]
fn test_execute_exactly_at_timelock_boundary_succeeds() {
- let (env, admin, _, _, client) = setup();
- let id = create_passed_proposal(&env, &client, &admin, 1);
-
+ let (env, admin, _, _, tree_token, client) = setup();
+ let id = create_passed_proposal(&env, &client, &admin, &tree_token, 1);
let queue_time = env.ledger().timestamp();
client.queue(&id);
-
- // Advance to exactly executable_at
env.ledger().set_timestamp(queue_time + DEFAULT_TIMELOCK_SECONDS);
-
client.execute(&id);
assert!(matches!(client.get_proposal(&id).status, ProposalStatus::Executed));
}
#[test]
fn test_timelock_duration_is_configurable() {
- let (env, admin, _, _, client) = setup();
-
- // Admin sets a custom 1-hour timelock
+ let (env, admin, _, _, tree_token, client) = setup();
let one_hour = 3600u64;
client.update_timelock(&one_hour);
assert_eq!(client.timelock_seconds(), one_hour);
-
- let id = create_passed_proposal(&env, &client, &admin, 1);
+ let id = create_passed_proposal(&env, &client, &admin, &tree_token, 1);
let queue_time = env.ledger().timestamp();
client.queue(&id);
-
let proposal = client.get_proposal(&id);
assert_eq!(proposal.executable_at, queue_time + one_hour);
-
- // Execute after 1 hour
env.ledger().set_timestamp(queue_time + one_hour);
client.execute(&id);
assert!(matches!(client.get_proposal(&id).status, ProposalStatus::Executed));
@@ -2304,25 +2457,23 @@ mod tests {
#[test]
#[should_panic(expected = "timelock must be > 0")]
fn test_set_zero_timelock_rejected() {
- let (_, _, _, _, client) = setup();
+ let (_, _, _, _, _, client) = setup();
client.update_timelock(&0);
}
#[test]
fn test_default_timelock_is_48_hours() {
- let (_, _, _, _, client) = setup();
+ let (_, _, _, _, _, client) = setup();
assert_eq!(client.timelock_seconds(), DEFAULT_TIMELOCK_SECONDS);
- assert_eq!(DEFAULT_TIMELOCK_SECONDS, 172800); // 48 × 3600
+ assert_eq!(DEFAULT_TIMELOCK_SECONDS, 172800);
}
#[test]
fn test_queued_at_and_executable_at_stored_correctly() {
- let (env, admin, _, _, client) = setup();
- let id = create_passed_proposal(&env, &client, &admin, 1);
-
+ let (env, admin, _, _, tree_token, client) = setup();
+ let id = create_passed_proposal(&env, &client, &admin, &tree_token, 1);
let t0 = env.ledger().timestamp();
client.queue(&id);
-
let p = client.get_proposal(&id);
assert_eq!(p.queued_at, t0);
assert_eq!(p.executable_at, t0 + DEFAULT_TIMELOCK_SECONDS);
@@ -2332,11 +2483,15 @@ mod tests {
#[test]
#[should_panic(expected = "proposal not queued for execution")]
fn test_execute_double_call_rejected() {
- let (env, admin, _, _, client) = setup();
- let id = create_passed_proposal(&env, &client, &admin, 1);
+ let (env, admin, _, _, tree_token, client) = setup();
+ let id = create_passed_proposal(&env, &client, &admin, &tree_token, 1);
client.queue(&id);
env.ledger().set_timestamp(env.ledger().timestamp() + DEFAULT_TIMELOCK_SECONDS + 1);
client.execute(&id);
+ let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ client.execute(&id);
+ }));
+ assert!(result.is_err());
client.execute(&id);
}
@@ -2344,13 +2499,10 @@ mod tests {
#[test]
fn test_register_delegate() {
- let (env, _, _, _, client) = setup();
-
+ let (env, _, _, _, _, client) = setup();
let delegate = Address::generate(&env);
let domain = String::from_str(&env, "climate");
-
client.register_delegate(&delegate, &domain);
-
let record = client.get_delegate(&delegate).expect("delegate not found");
assert_eq!(record.delegate, delegate);
assert_eq!(record.domain, domain);
@@ -2358,70 +2510,53 @@ mod tests {
#[test]
fn test_unregister_delegate_no_delegators() {
- let (env, _, _, _, client) = setup();
-
+ let (env, _, _, _, _, client) = setup();
let delegate = Address::generate(&env);
client.register_delegate(&delegate, &String::from_str(&env, "verifier"));
client.unregister_delegate(&delegate);
-
assert!(client.get_delegate(&delegate).is_none());
}
#[test]
#[should_panic(expected = "not a registered delegate")]
fn test_unregister_non_existent_delegate_fails() {
- let (env, _, _, _, client) = setup();
- let random = Address::generate(&env);
- client.unregister_delegate(&random);
+ let (env, _, _, _, _, client) = setup();
+ client.unregister_delegate(&Address::generate(&env));
}
#[test]
#[should_panic(expected = "cannot unregister: active delegations exist")]
fn test_unregister_with_active_delegations_fails() {
- let (env, _, _, _, client) = setup();
-
+ let (env, _, _, _, _, client) = setup();
let delegate = Address::generate(&env);
let delegator = Address::generate(&env);
-
client.register_delegate(&delegate, &String::from_str(&env, "climate"));
client.delegate_to(&delegator, &delegate);
-
- // Must fail — there is still an active delegation.
client.unregister_delegate(&delegate);
}
#[test]
fn test_delegate_to_registered_delegate() {
- let (env, _, _, _, client) = setup();
-
+ let (env, _, _, _, _, client) = setup();
let delegate = Address::generate(&env);
let delegator = Address::generate(&env);
-
client.register_delegate(&delegate, &String::from_str(&env, "climate"));
client.delegate_to(&delegator, &delegate);
-
- let stored = client
- .get_delegation(&delegator)
- .expect("delegation not found");
+ let stored = client.get_delegation(&delegator).expect("delegation not found");
assert_eq!(stored, delegate);
}
#[test]
#[should_panic(expected = "target is not a registered delegate")]
fn test_delegate_to_non_registered_fails() {
- let (env, _, _, _, client) = setup();
-
- let delegator = Address::generate(&env);
- let random = Address::generate(&env);
-
- client.delegate_to(&delegator, &random);
+ let (env, _, _, _, _, client) = setup();
+ client.delegate_to(&Address::generate(&env), &Address::generate(&env));
}
#[test]
#[should_panic(expected = "cannot delegate to yourself")]
fn test_delegate_to_self_fails() {
- let (env, _, _, _, client) = setup();
-
+ let (env, _, _, _, _, client) = setup();
let user = Address::generate(&env);
client.register_delegate(&user, &String::from_str(&env, "climate"));
client.delegate_to(&user, &user);
@@ -2429,68 +2564,55 @@ mod tests {
#[test]
fn test_retract_delegation() {
- let (env, _, _, _, client) = setup();
-
+ let (env, _, _, _, _, client) = setup();
let delegate = Address::generate(&env);
let delegator = Address::generate(&env);
-
client.register_delegate(&delegate, &String::from_str(&env, "climate"));
client.delegate_to(&delegator, &delegate);
client.retract_delegation(&delegator);
-
assert!(client.get_delegation(&delegator).is_none());
}
#[test]
#[should_panic(expected = "no active delegation")]
fn test_retract_with_no_delegation_fails() {
- let (env, _, _, _, client) = setup();
- let user = Address::generate(&env);
- client.retract_delegation(&user);
+ let (env, _, _, _, _, client) = setup();
+ client.retract_delegation(&Address::generate(&env));
}
#[test]
fn test_delegate_to_replaces_existing_delegation() {
- let (env, _, _, _, client) = setup();
-
+ let (env, _, _, _, _, client) = setup();
let delegate_a = Address::generate(&env);
let delegate_b = Address::generate(&env);
let delegator = Address::generate(&env);
-
client.register_delegate(&delegate_a, &String::from_str(&env, "climate"));
client.register_delegate(&delegate_b, &String::from_str(&env, "verifier"));
-
client.delegate_to(&delegator, &delegate_a);
- // Switch to delegate_b atomically.
client.delegate_to(&delegator, &delegate_b);
-
- let stored = client.get_delegation(&delegator).unwrap();
- assert_eq!(stored, delegate_b);
-
- // delegate_a should have no delegators left.
+ assert_eq!(client.get_delegation(&delegator).unwrap(), delegate_b);
assert_eq!(client.get_delegated_power(&delegate_a), 0);
- // delegate_b should have the delegator's power.
- assert_eq!(client.get_delegated_power(&delegate_b), 1000);
+ assert_eq!(client.get_delegated_power(&delegate_b), 0); // delegator has no lock
}
#[test]
fn test_vote_aggregates_delegated_power() {
- let (env, _, _, _, client) = setup();
-
+ let (env, _, _, _, tree_token, client) = setup();
let delegate = Address::generate(&env);
let delegator_1 = Address::generate(&env);
let delegator_2 = Address::generate(&env);
+ // Lock tokens for all three: sqrt(10000) = 100 each
+ lock_for_voter(&env, &tree_token, &client, &delegate, 10_000);
+ lock_for_voter(&env, &tree_token, &client, &delegator_1, 10_000);
+ lock_for_voter(&env, &tree_token, &client, &delegator_2, 10_000);
+
client.register_delegate(&delegate, &String::from_str(&env, "climate"));
client.delegate_to(&delegator_1, &delegate);
client.delegate_to(&delegator_2, &delegate);
- // Create a proposal and vote as the delegate.
let mut options = Vec::new(&env);
- options.push_back(VoteOption {
- option_id: 1,
- description: String::from_str(&env, "Yes"),
- });
+ options.push_back(VoteOption { option_id: 1, description: String::from_str(&env, "Yes") });
client.create_proposal(
&String::from_str(&env, "hash_dlgt"),
&ProposalType::PlatformFee,
@@ -2498,100 +2620,199 @@ mod tests {
&604800,
&delegate,
);
-
client.vote(&0, &1, &delegate);
let proposal = client.get_proposal(&0);
- // own (1000) + delegator_1 (1000) + delegator_2 (1000) = 3000
- assert_eq!(proposal.total_votes, 3000);
-
- let vote_rec = client.get_vote(&0, &delegate).unwrap();
- assert_eq!(vote_rec.power, 3000);
+ // own (100) + delegator_1 (100) + delegator_2 (100) = 300
+ assert_eq!(proposal.total_votes, 300);
+ assert_eq!(client.get_vote(&0, &delegate).unwrap().power, 300);
}
#[test]
#[should_panic(expected = "voting power delegated; retract delegation before voting")]
fn test_delegated_user_cannot_vote_directly() {
- let (env, _, _, _, client) = setup();
-
+ let (env, _, _, _, tree_token, client) = setup();
let delegate = Address::generate(&env);
let delegator = Address::generate(&env);
-
+ lock_for_voter(&env, &tree_token, &client, &delegate, 10_000);
+ lock_for_voter(&env, &tree_token, &client, &delegator, 10_000);
client.register_delegate(&delegate, &String::from_str(&env, "climate"));
client.delegate_to(&delegator, &delegate);
-
let mut options = Vec::new(&env);
- options.push_back(VoteOption {
- option_id: 1,
- description: String::from_str(&env, "Yes"),
- });
- client.create_proposal(
- &String::from_str(&env, "hash"),
- &ProposalType::PlatformFee,
- &options,
- &604800,
- &delegate,
- );
-
- // delegator still has an active delegation → must panic.
+ options.push_back(VoteOption { option_id: 1, description: String::from_str(&env, "Yes") });
+ client.create_proposal(&String::from_str(&env, "hash"), &ProposalType::PlatformFee, &options, &604800, &delegate);
client.vote(&0, &1, &delegator);
}
#[test]
fn test_retract_then_vote_directly() {
- let (env, _, _, _, client) = setup();
-
+ let (env, _, _, _, tree_token, client) = setup();
let delegate = Address::generate(&env);
let delegator = Address::generate(&env);
-
+ lock_for_voter(&env, &tree_token, &client, &delegate, 10_000);
+ lock_for_voter(&env, &tree_token, &client, &delegator, 10_000);
client.register_delegate(&delegate, &String::from_str(&env, "climate"));
client.delegate_to(&delegator, &delegate);
client.retract_delegation(&delegator);
-
let mut options = Vec::new(&env);
- options.push_back(VoteOption {
- option_id: 1,
- description: String::from_str(&env, "Yes"),
- });
- client.create_proposal(
- &String::from_str(&env, "hash"),
- &ProposalType::PlatformFee,
- &options,
- &604800,
- &delegate,
- );
-
- // After retraction the delegator should be able to vote directly.
+ options.push_back(VoteOption { option_id: 1, description: String::from_str(&env, "Yes") });
+ client.create_proposal(&String::from_str(&env, "hash"), &ProposalType::PlatformFee, &options, &604800, &delegate);
client.vote(&0, &1, &delegator);
-
- let proposal = client.get_proposal(&0);
- assert_eq!(proposal.total_votes, 1000); // only own power, no delegated
+ assert_eq!(client.get_proposal(&0).total_votes, 100); // sqrt(10000) = 100
}
#[test]
fn test_get_delegated_power_zero_when_no_delegators() {
- let (env, _, _, _, client) = setup();
-
+ let (env, _, _, _, _, client) = setup();
let delegate = Address::generate(&env);
client.register_delegate(&delegate, &String::from_str(&env, "verifier"));
-
assert_eq!(client.get_delegated_power(&delegate), 0);
}
#[test]
fn test_get_delegated_power_accumulates_multiple_delegators() {
- let (env, _, _, _, client) = setup();
-
+ let (env, _, _, _, tree_token, client) = setup();
let delegate = Address::generate(&env);
client.register_delegate(&delegate, &String::from_str(&env, "climate"));
-
+ // 5 delegators, each locking 100 tokens → sqrt(100) = 10 each
for _ in 0..5u32 {
let delegator = Address::generate(&env);
+ lock_for_voter(&env, &tree_token, &client, &delegator, 100);
client.delegate_to(&delegator, &delegate);
}
+ assert_eq!(client.get_delegated_power(&delegate), 50); // 5 × 10
+ }
+
+ // ── Quadratic voting lock tests (issue #761) ──────────────────────────────
+
+ #[test]
+ fn test_lock_tokens_stores_correct_voting_power() {
+ let (env, admin, _, _, tree_token, client) = setup();
+ token::StellarAssetClient::new(&env, &tree_token).mint(&admin, &10_000);
+ client.lock_tokens(&admin, &10_000);
+ let lock = client.locked_balance(&admin).unwrap();
+ assert_eq!(lock.amount, 10_000);
+ assert_eq!(lock.voting_power, 100); // sqrt(10000) = 100
+ }
+
+ #[test]
+ fn test_lock_tokens_accumulates_on_successive_calls() {
+ let (env, admin, _, _, tree_token, client) = setup();
+ token::StellarAssetClient::new(&env, &tree_token).mint(&admin, &20_000);
+ client.lock_tokens(&admin, &9_000);
+ client.lock_tokens(&admin, &7_000);
+ let lock = client.locked_balance(&admin).unwrap();
+ assert_eq!(lock.amount, 16_000);
+ assert_eq!(lock.voting_power, PlatformGovernance::isqrt(16_000));
+ }
+
+ #[test]
+ fn test_unlock_tokens_reduces_balance_and_recomputes_power() {
+ let (env, admin, _, _, tree_token, client) = setup();
+ token::StellarAssetClient::new(&env, &tree_token).mint(&admin, &10_000);
+ client.lock_tokens(&admin, &10_000);
+ client.unlock_tokens(&admin, &6_000);
+ let lock = client.locked_balance(&admin).unwrap();
+ assert_eq!(lock.amount, 4_000);
+ assert_eq!(lock.voting_power, PlatformGovernance::isqrt(4_000));
+ }
+
+ #[test]
+ #[should_panic(expected = "Error(Contract, #202)")]
+ fn test_unlock_more_than_locked_rejected() {
+ let (env, admin, _, _, tree_token, client) = setup();
+ token::StellarAssetClient::new(&env, &tree_token).mint(&admin, &5_000);
+ client.lock_tokens(&admin, &5_000);
+ client.unlock_tokens(&admin, &6_000);
+ }
+
+ #[test]
+ #[should_panic(expected = "Error(Contract, #200)")]
+ fn test_unlock_with_no_lock_rejected() {
+ let (env, admin, _, _, _tree_token, client) = setup();
+ client.unlock_tokens(&admin, &100);
+ }
- // 5 delegators × 1000 each = 5000
- assert_eq!(client.get_delegated_power(&delegate), 5000);
+ #[test]
+ #[should_panic(expected = "Error(Contract, #201)")]
+ fn test_lock_zero_amount_rejected() {
+ let (env, admin, _, _, _tree_token, client) = setup();
+ client.lock_tokens(&admin, &0);
+ }
+
+ #[test]
+ fn test_different_lock_amounts_produce_different_voting_powers() {
+ let (env, _, _, _, tree_token, client) = setup();
+ let voter_a = Address::generate(&env);
+ let voter_b = Address::generate(&env);
+ token::StellarAssetClient::new(&env, &tree_token).mint(&voter_a, &100);
+ token::StellarAssetClient::new(&env, &tree_token).mint(&voter_b, &10_000);
+ client.lock_tokens(&voter_a, &100);
+ client.lock_tokens(&voter_b, &10_000);
+ let power_a = client.locked_balance(&voter_a).unwrap().voting_power;
+ let power_b = client.locked_balance(&voter_b).unwrap().voting_power;
+ // A: sqrt(100) = 10; B: sqrt(10000) = 100 → 10x lock but same multiplier
+ assert_eq!(power_a, 10);
+ assert_eq!(power_b, 100);
+ // Power ratio should be sqrt(100) not 100x
+ assert_eq!(power_b / power_a, 10);
+ }
+
+ #[test]
+ fn test_voting_power_used_in_vote() {
+ let (env, _, _, _, tree_token, client) = setup();
+ let voter_a = Address::generate(&env);
+ let voter_b = Address::generate(&env);
+ // A locks 100 → power 10; B locks 10000 → power 100
+ token::StellarAssetClient::new(&env, &tree_token).mint(&voter_a, &100);
+ token::StellarAssetClient::new(&env, &tree_token).mint(&voter_b, &10_000);
+ client.lock_tokens(&voter_a, &100);
+ client.lock_tokens(&voter_b, &10_000);
+
+ let proposer = Address::generate(&env);
+ token::StellarAssetClient::new(&env, &tree_token).mint(&proposer, &1);
+ client.lock_tokens(&proposer, &1);
+
+ let mut options = Vec::new(&env);
+ options.push_back(VoteOption { option_id: 1, description: String::from_str(&env, "A") });
+ options.push_back(VoteOption { option_id: 2, description: String::from_str(&env, "B") });
+ client.create_proposal(&String::from_str(&env, "h"), &ProposalType::PlatformFee, &options, &604800, &proposer);
+
+ client.vote(&0, &1, &voter_a);
+ client.vote(&0, &2, &voter_b);
+
+ let proposal = client.get_proposal(&0);
+ assert_eq!(proposal.total_votes, 10 + 100); // 110
+ let tally_1 = proposal.tally.iter().find(|t| t.option_id == 1).unwrap();
+ let tally_2 = proposal.tally.iter().find(|t| t.option_id == 2).unwrap();
+ assert_eq!(tally_1.votes, 10);
+ assert_eq!(tally_2.votes, 100);
+ }
+
+ #[test]
+ #[should_panic(expected = "must lock TREE tokens to vote")]
+ fn test_vote_without_locked_tokens_rejected() {
+ let (env, admin, _, _, _tree_token, client) = setup();
+ let mut options = Vec::new(&env);
+ options.push_back(VoteOption { option_id: 1, description: String::from_str(&env, "Yes") });
+ client.create_proposal(&String::from_str(&env, "h"), &ProposalType::PlatformFee, &options, &604800, &admin);
+ client.vote(&0, &1, &admin); // admin has no locked tokens
+ }
+
+ #[test]
+ fn test_locked_balance_returns_none_for_unlocked_voter() {
+ let (env, admin, _, _, _tree_token, client) = setup();
+ assert!(client.locked_balance(&admin).is_none());
+ }
+
+ #[test]
+ fn test_quadratic_dampening_vs_linear() {
+ // Verify that doubling locked tokens does NOT double voting power
+ // (quadratic property: sqrt(4x) = 2*sqrt(x))
+ assert_eq!(PlatformGovernance::isqrt(400), 20);
+ assert_eq!(PlatformGovernance::isqrt(100), 10);
+ // 4x tokens → only 2x power
+ assert_eq!(PlatformGovernance::isqrt(400) / PlatformGovernance::isqrt(100), 2);
}
#[test]
diff --git a/contracts/tree-escrow/src/lib.rs b/contracts/tree-escrow/src/lib.rs
index f9ffb288..c7df05e9 100644
--- a/contracts/tree-escrow/src/lib.rs
+++ b/contracts/tree-escrow/src/lib.rs
@@ -97,6 +97,9 @@ enum DataKey {
Escrow(Address),
}
+ Escrow(Address),
+}
+
AdminTree,
Oracle,
SurvivalThreshold,
@@ -163,6 +166,10 @@ impl TreeEscrow {
/// token contract could call back into `deposit` before this invocation
/// completes. The guard prevents that scenario.
///
+ /// REENTRANCY GUARD: The token transfer is a cross-contract call. A malicious
+ /// token contract could call back into `deposit` before this invocation
+ /// completes. The guard prevents that scenario.
+ ///
/// # Authorization
/// `donor` must sign the transaction.
pub fn deposit(
@@ -182,6 +189,19 @@ impl TreeEscrow {
if amount <= 0 { panic_with_error!(&env, HarvestaError::AmountMustBePositive); }
if tree_count <= 0 { panic_with_error!(&env, HarvestaError::TreeCountMustBePositive); }
+ let key = DataKey::Escrow(farmer.clone());
+ if env.storage().persistent().has(&key) {
+ panic_with_error!(&env, HarvestaError::EscrowAlreadyExists);
+ }
+
+ // Cross-contract call — guard prevents reentrant deposit
+ token::Client::new(&env, &token).transfer(
+ &donor,
+ &env.current_contract_address(),
+ &amount,
+ );
+
+
let key = DataKey::Escrow(farmer.clone());
if env.storage().persistent().has(&key) {
panic_with_error!(&env, HarvestaError::EscrowAlreadyExists);
@@ -412,6 +432,10 @@ impl TreeEscrow {
/// REENTRANCY GUARD: Two cross-contract calls (token transfer + mint).
/// A malicious token could re-enter `verify_planting` between them.
///
+ ///
+ /// REENTRANCY GUARD: Two cross-contract calls (token transfer + mint).
+ /// A malicious token could re-enter `verify_planting` between them.
+ ///
/// # Authorization
/// Admin must sign.
pub fn verify_planting(