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(