diff --git a/soroban/contracts/factory/src/lib.rs b/soroban/contracts/factory/src/lib.rs index e01d709..a748c62 100644 --- a/soroban/contracts/factory/src/lib.rs +++ b/soroban/contracts/factory/src/lib.rs @@ -247,6 +247,18 @@ fn validate_asset(env: &Env, asset: &Address) -> Result<(), FactoryError> { asset, &Symbol::new(env, "symbol"), vec![env], + ) { + Ok(Ok(_)) => {} + _ => return Err(FactoryError::InvalidAsset), + } + // Validation 49: Also require the SEP-41 `balance` entry point. A contract + // that answers `symbol` but not `balance` cannot support staking, because + // `stake`/`lock_assets` read the caller's token balance before transferring + // (#542). + match env.try_invoke_contract::( + asset, + &Symbol::new(env, "balance"), + (env.current_contract_address(),).into_val(env), ) { Ok(Ok(_)) => Ok(()), _ => Err(FactoryError::InvalidAsset), @@ -351,6 +363,14 @@ fn create_pool_inner( return Err(FactoryError::InvalidAsset); } + // Validation 48: Ensure the staking asset is not also the pool admin. A + // pool administered by its own staking token cannot authorize admin + // actions (pause, upgrade, multiplier changes), leaving the pool + // unmanageable (#541). + if asset == admin { + return Err(FactoryError::InvalidAsset); + } + // Validation 24: Ensure global_multiplier is strictly within bounds // Must be at least 1 and at most MAX_GLOBAL_MULTIPLIER (1000) if global_multiplier < 1 { @@ -395,6 +415,13 @@ fn create_pool_inner( if min_stake_amount > MAX_STAKE_AMOUNT { return Err(FactoryError::InvalidMinStakeAmount); } + // Validation 47: Reject an undersized raw min_stake_amount (magnitude + // beyond MAX_STAKE_AMOUNT on the negative side) before the non-positive + // defaulting logic. A value like `i128::MIN` is corrupt input, not a + // request to use the dust default (#540). + if min_stake_amount < -MAX_STAKE_AMOUNT { + return Err(FactoryError::InvalidMinStakeAmount); + } let effective_min_stake = if min_stake_amount <= 0 { MIN_STAKE_AMOUNT } else { @@ -1572,6 +1599,11 @@ impl Factory { validate_factory_admin(&env, &admin)?; admin.require_auth(); + // Validation 50: Reject an empty batch before any work is done. A + // zero-length request consumes admin authorization and bumps instance + // TTL while creating nothing (#543). + if pools.is_empty() { + return Err(FactoryError::EmptyBatch); // Validation 35: Cap the batch size before any pool is deployed. Each // entry triggers a WASM deploy inside `create_pool_inner`, so an // unbounded batch can exhaust the transaction's resource budget and diff --git a/soroban/contracts/factory/src/test.rs b/soroban/contracts/factory/src/test.rs index e438418..fa81980 100644 --- a/soroban/contracts/factory/src/test.rs +++ b/soroban/contracts/factory/src/test.rs @@ -13,6 +13,20 @@ use soroban_sdk::{ use farming_pool::FarmingPoolClient; +/// A test-only contract that answers SEP-41 `symbol` but has no `balance` +/// entry point. Used to prove `validate_asset` rejects half-implemented token +/// interfaces rather than treating any `symbol` responder as a stake asset +/// (#542). +#[contract] +pub struct SymbolOnlyToken; + +#[contractimpl] +impl SymbolOnlyToken { + pub fn symbol(env: Env) -> soroban_sdk::String { + soroban_sdk::String::from_str(&env, "FAKE") + } +} + // The contract crate is `#![no_std]`; these tests assert on `std` collection // types, so the shim is declared here (same as farming-pool's tests). extern crate std; @@ -1467,6 +1481,73 @@ fn test_create_pool_rejects_factory_address_as_asset() { assert_eq!(t.client.pool_count(), 0); } +#[test] +fn test_create_pool_rejects_min_stake_amount_below_negative_max() { + let t = setup(); + let asset = test_asset(&t.env); + + let result = t.client.try_create_pool( + &asset, + &1_728_000u128, + &2u32, + &25u64, + &(0i128 - MAX_STAKE_AMOUNT - 1), + ); + assert_eq!(result, Err(Ok(FactoryError::InvalidMinStakeAmount))); + assert_eq!(t.client.pool_count(), 0); +} + +#[test] +fn test_create_pool_rejects_i128_min_min_stake_amount() { + let t = setup(); + let asset = test_asset(&t.env); + + let result = t + .client + .try_create_pool(&asset, &1_728_000u128, &2u32, &25u64, &i128::MIN); + assert_eq!(result, Err(Ok(FactoryError::InvalidMinStakeAmount))); + assert_eq!(t.client.pool_count(), 0); +} + +#[test] +fn test_create_pool_rejects_asset_equal_to_admin() { + let t = setup(); + let asset = test_asset(&t.env); + + // Point the factory admin at the token contract so `asset == admin`, then + // confirm the pool is refused rather than deployed with a token-controlled + // admin (#541). + t.client.transfer_admin(&asset); + t.env.mock_all_auths(); + + let result = t + .client + .try_create_pool(&asset, &1_728_000u128, &2u32, &25u64, &0i128); + assert_eq!(result, Err(Ok(FactoryError::InvalidAsset))); + assert_eq!(t.client.pool_count(), 0); +} + +#[test] +fn test_create_pool_rejects_asset_without_balance_entry_point() { + let t = setup(); + let asset = t.env.register(SymbolOnlyToken, ()); + + let result = t + .client + .try_create_pool(&asset, &1_728_000u128, &2u32, &25u64, &0i128); + assert_eq!(result, Err(Ok(FactoryError::InvalidAsset))); + assert_eq!(t.client.pool_count(), 0); +} + +#[test] +fn test_create_pools_batch_rejects_empty_batch() { + let t = setup(); + + let result = t.client.try_create_pools_batch(&vec![&t.env]); + assert_eq!(result, Err(Ok(FactoryError::EmptyBatch))); + assert_eq!(t.client.pool_count(), 0); +} + #[test] fn test_create_pool_rejects_non_token_asset() { let t = setup(); diff --git a/soroban/contracts/factory/src/types.rs b/soroban/contracts/factory/src/types.rs index b303675..aa9b384 100644 --- a/soroban/contracts/factory/src/types.rs +++ b/soroban/contracts/factory/src/types.rs @@ -188,6 +188,10 @@ pub enum FactoryError { /// threshold, which would trap staked funds for an effectively permanent /// window. MinLockPeriodAboveCeiling = 19, + /// `create_pools_batch` was called with an empty `pools` vector, which + /// would consume admin authorization and bump instance TTL while creating + /// nothing. + EmptyBatch = 20, /// `create_pools_batch` was called with more entries than `MAX_BATCH_SIZE`. BatchTooLarge = 20, } diff --git a/soroban/contracts/farming-pool/src/lib.rs b/soroban/contracts/farming-pool/src/lib.rs deleted file mode 100644 index e2f4c7f..0000000 --- a/soroban/contracts/farming-pool/src/lib.rs +++ /dev/null @@ -1,3594 +0,0 @@ -#![no_std] -#![allow(deprecated)] - -#[cfg(test)] -mod mock_fee_token; -#[cfg(test)] -mod mock_reentrant_token; -mod types; - -use soroban_sdk::{ - contract, contractimpl, symbol_short, token, Address, BytesN, Env, Executable, Vec, -}; -pub use types::{PoolError, PoolInfo, UserSummary}; -use types::{ - AdminActionEvent, AdminActionHistoryPage, BankedCreditTotals, BoostConfig, BoostEvent, - BoostHistoryPage, CreditRateEvent, CreditRateHistoryPage, DataKey, GlobalMultiplierEvent, - GlobalMultiplierHistoryPage, ListWhitelistedResponse, Position, StakeEvent, - StakeHistoryPage, - UserStake, WhitelistEvent, WhitelistHistoryPage, UserActivity, TierInfo, DistributionEvent, DistributionHistoryPage, PoolAnalytics, -}; - -// Expose compiled WASM bytes so sibling crates (e.g. `factory`) can upload the -// real farming-pool contract in their integration tests via: -// `env.deployer().upload_contract_wasm(farming_pool::WASM)` -// Gated behind `testutils` feature (enabled by factory's dev-dependency) so it -// is never included in on-chain release builds. -#[cfg(any(test, feature = "testutils"))] -mod wasm_import { - // Scoped in its own module (per contractimport!'s documented pattern) so - // its generated `WASM`/`Client` items don't collide with this crate's - // own `#[contract]`-generated names. No sha256 pinning here — unlike - // contractfile!, contractimport! doesn't require it, which matters - // because this WASM is our own sibling crate's freshly-built output on - // every CI run, not a fixed external artifact: Rust/LLVM codegen isn't - // bit-for-bit reproducible across separate `cargo build` invocations of - // the same source, so a hardcoded hash here would break intermittently - // for reasons unrelated to any real content change. - soroban_sdk::contractimport!(file = "../../target/wasm32v1-none/release/farming_pool.wasm"); -} -#[cfg(any(test, feature = "testutils"))] -pub use wasm_import::WASM; - -// Persistent-storage TTL: extend to ~60 days if below ~30 days (at ~5s/ledger). -const USER_TTL_THRESHOLD: u32 = 518_400; -const USER_TTL_EXTEND_TO: u32 = 1_036_800; - -/// Current contract schema version, written by `initialize`/`migrate`. A pool -/// deployed before `SchemaVersion` was tracked at all reads back `SCHEMA_VERSION` -/// via `read_schema_version`'s `unwrap_or`, so it's treated as already current -/// rather than needing a migration. -const SCHEMA_VERSION: u32 = 1; - -/// Sanity ceilings on `global_multiplier` and `credit_rate` (see #89). -/// -/// `compute_credits` computes -/// `compute_total_stake(amount, allocation_pct, multiplier) * credit_rate * ledgers_elapsed`, -/// and `compute_total_stake` reduces to exactly `amount * multiplier` at -/// `allocation_pct = 100` (its worst case: `boosted = amount`, `principal = 0`). -/// The `/100` division in `compute_total_stake` therefore does *not* loosen -/// this bound at the boundary — the naive product below is tight, not a -/// conservative over-estimate. -/// -/// Worst-case overflow chain: -/// -/// ```text -/// amount_max * multiplier_max * credit_rate_max * elapsed_max <= i128::MAX / 16 -/// ``` -/// -/// Inputs, chosen and justified independently of the multiplier/credit-rate -/// ceilings themselves: -/// - `amount_max = 10^18` — 100 billion whole tokens at Stellar's standard -/// 7-decimal ("stroop") convention. Far above any realistic pool TVL, but -/// many orders of magnitude below `i128::MAX` (~1.7 x 10^38). -/// - `elapsed_max = 63_072_000` ledgers — ~10 years at 5s/ledger -/// (`10 * 365 * 24 * 3600 / 5`), a multi-year operational horizon between -/// checkpoints. -/// - Headroom factor of 16x, i.e. the worst-case product must not exceed -/// `i128::MAX / 16`, leaving ample margin beyond the bare non-overflow -/// requirement. -/// -/// Solving for `multiplier_max * credit_rate_max`: -/// `(i128::MAX / 16) / (amount_max * elapsed_max) ≈ 1.686 x 10^11`. -/// -/// Chosen ceilings (round, human-readable, at or below the derived bound): -/// - `MAX_GLOBAL_MULTIPLIER = 1_000` -/// - `MAX_CREDIT_RATE = 100_000_000` (10^8) -/// - product = 10^11, comfortably under the 1.686 x 10^11 budget. -/// -/// Verification: `amount_max * multiplier_max * credit_rate_max * elapsed_max` -/// = `10^18 * 1_000 * 10^8 * 63_072_000` ≈ `6.307 x 10^36`, versus -/// `i128::MAX ≈ 1.701 x 10^38` — a headroom ratio of ~27x, comfortably -/// exceeding the required 16x. (For reference, the earlier sketch pair of -/// 1_000 / 1_000_000_000 gives a worst case of ≈ 6.307 x 10^37, which fits -/// under raw `i128::MAX` but only with ~2.7x headroom — it does not survive -/// this derivation's 16x margin, hence `MAX_CREDIT_RATE` here is 10x smaller.) -const MAX_GLOBAL_MULTIPLIER: u32 = 1_000; -const MAX_CREDIT_RATE: i128 = 100_000_000; -const MAX_STAKE_AMOUNT: i128 = 10i128.pow(18); -const MAX_LOCK_PERIOD: u32 = 63_072_000; // 10 years at 5s/ledger - -fn bump_instance(env: &Env) { - env.storage() - .instance() - .extend_ttl(USER_TTL_THRESHOLD, USER_TTL_EXTEND_TO); -} - -fn bump_user(env: &Env, key: &DataKey) { - env.storage() - .persistent() - .extend_ttl(key, USER_TTL_THRESHOLD, USER_TTL_EXTEND_TO); -} - -fn require_initialized(env: &Env) -> Result<(), PoolError> { - if !env.storage().instance().has(&DataKey::Admin) { - return Err(PoolError::NotInitialized); - } - Ok(()) -} - -fn require_staking_not_paused(env: &Env) -> Result<(), PoolError> { - if pool_is_paused(env) || pool_is_staking_paused(env) { - return Err(PoolError::Paused); - } - Ok(()) -} - -fn require_withdrawals_not_paused(env: &Env) -> Result<(), PoolError> { - if pool_is_paused(env) || pool_is_withdrawals_paused(env) { - return Err(PoolError::Paused); - } - Ok(()) -} - -fn get_admin(env: &Env) -> Result { - env.storage() - .instance() - .get(&DataKey::Admin) - .ok_or(PoolError::NotInitialized) -} - -fn read_global_multiplier(env: &Env) -> u32 { - env.storage() - .instance() - .get(&DataKey::GlobalMultiplier) - .unwrap_or(1) -} - -fn read_credit_rate(env: &Env) -> i128 { - env.storage() - .instance() - .get(&DataKey::CreditRate) - .unwrap_or(1) -} - -fn read_total_credits(env: &Env) -> i128 { - env.storage() - .instance() - .get(&DataKey::TotalCredits) - .unwrap_or(0) -} - -fn get_stake_token(env: &Env) -> Result { - env.storage() - .instance() - .get(&DataKey::StakeToken) - .ok_or(PoolError::NotInitialized) -} - -fn read_min_lock_period(env: &Env) -> u32 { - env.storage() - .instance() - .get(&DataKey::MinLockPeriod) - .unwrap_or(0) -} - -fn pool_is_paused(env: &Env) -> bool { - env.storage() - .instance() - .get(&DataKey::Paused) - .unwrap_or(false) -} - -fn pool_is_staking_paused(env: &Env) -> bool { - env.storage() - .instance() - .get(&DataKey::PausedStaking) - .unwrap_or(false) -} - -fn pool_is_withdrawals_paused(env: &Env) -> bool { - env.storage() - .instance() - .get(&DataKey::PausedWithdrawals) - .unwrap_or(false) -} - -fn read_global_multiplier_change_ledger(env: &Env) -> u32 { - env.storage() - .instance() - .get(&DataKey::GlobalMultiplierChangeLedger) - .unwrap_or(0) -} - -fn read_schema_version(env: &Env) -> u32 { - env.storage() - .instance() - .get(&DataKey::SchemaVersion) - .unwrap_or(SCHEMA_VERSION) -} - -fn get_user_boost(env: &Env, user: &Address) -> Option { - let key = DataKey::UserBoost(user.clone()); - let value: Option = env.storage().persistent().get(&key); - if value.is_some() { - bump_user(env, &key); - } - value -} - -fn get_user_stake(env: &Env, user: &Address) -> Option { - let key = DataKey::UserStake(user.clone()); - let value: Option = env.storage().persistent().get(&key); - if value.is_some() { - bump_user(env, &key); - } - value -} - -fn set_user_stake(env: &Env, user: &Address, stake: &UserStake) { - let key = DataKey::UserStake(user.clone()); - let is_new = !env.storage().persistent().has(&key); - env.storage().persistent().set(&key, stake); - bump_user(env, &key); - let mut users = get_staked_users_list(env); - if !users.contains(user) { - users.push_back(user.clone()); - set_staked_users_list(env, &users); - if is_new { - increment_unique_user_count(env); - } - } -} - -fn remove_user_stake(env: &Env, user: &Address) { - env.storage() - .persistent() - .remove(&DataKey::UserStake(user.clone())); - let users = get_staked_users_list(env); - let mut new_users: Vec
= Vec::new(env); - for u in users.iter() { - if u != *user { - new_users.push_back(u); - } - } - set_staked_users_list(env, &new_users); -} - -fn read_total_staked(env: &Env) -> i128 { - env.storage() - .instance() - .get(&DataKey::TotalStaked) - .unwrap_or(0) -} - -fn add_total_staked(env: &Env, amount: i128) { - let total = env - .storage() - .instance() - .get::(&DataKey::TotalStaked) - .unwrap_or(0); - let new_total = total.checked_add(amount).expect("total stake overflow"); - env.storage().instance().set(&DataKey::TotalStaked, &new_total); - update_peak_total_staked(env, new_total); -} - -fn subtract_total_staked(env: &Env, amount: i128) { - let total = env - .storage() - .instance() - .get::(&DataKey::TotalStaked) - .unwrap_or(0); - env.storage().instance().set( - &DataKey::TotalStaked, - &total.checked_sub(amount).expect("total stake underflow"), - ); -} - -fn read_total_locked(env: &Env) -> i128 { - env.storage() - .instance() - .get(&DataKey::TotalLocked) - .unwrap_or(0) -} - -fn add_total_locked(env: &Env, amount: i128) { - let total = env - .storage() - .instance() - .get::(&DataKey::TotalLocked) - .unwrap_or(0); - env.storage().instance().set( - &DataKey::TotalLocked, - &total.checked_add(amount).expect("total locked overflow"), - ); -} - -fn subtract_total_locked(env: &Env, amount: i128) { - let total = env - .storage() - .instance() - .get::(&DataKey::TotalLocked) - .unwrap_or(0); - env.storage().instance().set( - &DataKey::TotalLocked, - &total.checked_sub(amount).expect("total locked underflow"), - ); -} - -fn read_boost_count(env: &Env) -> u32 { - env.storage() - .instance() - .get(&DataKey::BoostCount) - .unwrap_or(0) -} - -fn increment_boost_count(env: &Env) { - let count = read_boost_count(env); - env.storage() - .instance() - .set(&DataKey::BoostCount, &(count + 1)); -} - -fn is_user_staked(env: &Env, user: &Address) -> bool { - get_position(env, user).is_some() || get_user_stake(env, user).is_some() -} - -fn increment_staked_user_count(env: &Env) { - let count: u32 = env - .storage() - .instance() - .get(&DataKey::StakedUserCount) - .unwrap_or(0); - env.storage() - .instance() - .set(&DataKey::StakedUserCount, &(count + 1)); -} - -fn decrement_staked_user_count(env: &Env) { - let count: u32 = env - .storage() - .instance() - .get(&DataKey::StakedUserCount) - .unwrap_or(0); - if count > 0 { - env.storage() - .instance() - .set(&DataKey::StakedUserCount, &(count - 1)); - } -} - -fn read_lock_count(env: &Env) -> u32 { - env.storage() - .instance() - .get(&DataKey::LockCount) - .unwrap_or(0) -} - -fn increment_lock_count(env: &Env) { - let count = read_lock_count(env); - env.storage() - .instance() - .set(&DataKey::LockCount, &(count + 1)); -} - -fn read_unstake_count(env: &Env) -> u32 { - env.storage() - .instance() - .get(&DataKey::UnstakeCount) - .unwrap_or(0) -} - -fn increment_unstake_count(env: &Env) { - let count = read_unstake_count(env); - env.storage() - .instance() - .set(&DataKey::UnstakeCount, &(count + 1)); -} - -fn read_active_stake_count(env: &Env) -> u32 { - env.storage() - .instance() - .get(&DataKey::ActiveStakeCount) - .unwrap_or(0) -} - -// ── Feature 1: User Activity Tracking ──────────────────────────────────────── - -fn record_user_activity(env: &Env, user: &Address) { - let key = DataKey::UserLastActivity(user.clone()); - let current_ledger = env.ledger().sequence(); - env.storage().persistent().set(&key, ¤t_ledger); - bump_user(env, &key); -} - -fn get_user_last_activity(env: &Env, user: &Address) -> Option { - let key = DataKey::UserLastActivity(user.clone()); - let value: Option = env.storage().persistent().get(&key); - if value.is_some() { - bump_user(env, &key); - } - value -} - -fn increment_user_activity_count(env: &Env, user: &Address) { - let key = DataKey::UserActivityCount(user.clone()); - let count: u32 = env.storage().persistent().get(&key).unwrap_or(0); - env.storage().persistent().set(&key, &(count + 1)); - bump_user(env, &key); -} - -fn read_user_activity_count(env: &Env, user: &Address) -> u32 { - let key = DataKey::UserActivityCount(user.clone()); - let count: u32 = env.storage().persistent().get(&key).unwrap_or(0); - if count > 0 { - bump_user(env, &key); - } - count -} - -// ── Feature 2: Performance Tier System ─────────────────────────────────────── - -fn get_user_tier(env: &Env, user: &Address) -> u32 { - let key = DataKey::UserTier(user.clone()); - let tier: u32 = env.storage().persistent().get(&key).unwrap_or(0); - if tier > 0 { - bump_user(env, &key); - } - tier -} - -fn set_user_tier(env: &Env, user: &Address, tier: u32) { - let key = DataKey::UserTier(user.clone()); - env.storage().persistent().set(&key, &tier); - bump_user(env, &key); -} - -fn get_user_tier_points(env: &Env, user: &Address) -> i128 { - let key = DataKey::UserTierPoints(user.clone()); - let points: i128 = env.storage().persistent().get(&key).unwrap_or(0); - if points > 0 { - bump_user(env, &key); - } - points -} - -fn add_user_tier_points(env: &Env, user: &Address, points: i128) { - let key = DataKey::UserTierPoints(user.clone()); - let current = env.storage().persistent().get::(&key).unwrap_or(0); - env.storage().persistent().set(&key, &(current + points)); - bump_user(env, &key); -} - -// Calculate tier based on points and lock duration -fn calculate_user_tier(points: i128, lock_ledgers: u32) -> u32 { - match (points, lock_ledgers) { - (p, l) if p >= 10000 && l >= 2000000 => 3, // platinum: 10k+ points, 2M+ ledgers - (p, l) if p >= 5000 && l >= 1000000 => 2, // gold: 5k+ points, 1M+ ledgers - (p, l) if p >= 1000 && l >= 500000 => 1, // silver: 1k+ points, 500k+ ledgers - _ => 0, // bronze: default - } -} - -// ── Feature 3: Bulk Reward Distribution ────────────────────────────────────── - -fn record_distribution_event(env: &Env, user_count: u32, total_amount: i128, admin: &Address) { - let count: u32 = env - .storage() - .instance() - .get(&DataKey::DistributionEventCount) - .unwrap_or(0); - let event = DistributionEvent { - user_count, - total_amount, - admin: admin.clone(), - ledger: env.ledger().sequence(), - }; - env.storage() - .persistent() - .set(&DataKey::DistributionHistory(count), &event); - env.storage() - .instance() - .set(&DataKey::DistributionEventCount, &(count + 1)); -} - -fn add_total_distributed_rewards(env: &Env, amount: i128) { - let total: i128 = env - .storage() - .instance() - .get(&DataKey::TotalDistributedRewards) - .unwrap_or(0); - env.storage().instance().set( - &DataKey::TotalDistributedRewards, - &total.checked_add(amount).expect("reward distribution overflow"), - ); -} - -// ── Feature 4: Extended Analytics ──────────────────────────────────────────── - -fn update_peak_total_staked(env: &Env, current_total: i128) { - let peak: i128 = env - .storage() - .instance() - .get(&DataKey::PeakTotalStaked) - .unwrap_or(0); - if current_total > peak { - env.storage() - .instance() - .set(&DataKey::PeakTotalStaked, ¤t_total); - } -} - -fn increment_unique_user_count(env: &Env) { - let count: u32 = env - .storage() - .instance() - .get(&DataKey::UniqueUserCount) - .unwrap_or(0); - env.storage() - .instance() - .set(&DataKey::UniqueUserCount, &(count + 1)); -} - -fn decrement_unique_user_count(env: &Env) { - let count: u32 = env - .storage() - .instance() - .get(&DataKey::UniqueUserCount) - .unwrap_or(0); - if count > 0 { - env.storage() - .instance() - .set(&DataKey::UniqueUserCount, &(count - 1)); - } -} - -fn read_unique_user_count(env: &Env) -> u32 { - env.storage() - .instance() - .get(&DataKey::UniqueUserCount) - .unwrap_or(0) -} - -fn increment_active_stake_count(env: &Env) { - let count = read_active_stake_count(env); - env.storage() - .instance() - .set(&DataKey::ActiveStakeCount, &(count + 1)); -} - -fn decrement_active_stake_count(env: &Env) { - let count = read_active_stake_count(env); - if count > 0 { - env.storage() - .instance() - .set(&DataKey::ActiveStakeCount, &(count - 1)); - } -} - -fn read_credit_rate_change_count(env: &Env) -> u32 { - env.storage() - .instance() - .get(&DataKey::CreditRateChangeCount) - .unwrap_or(0) -} - -fn increment_credit_rate_change_count(env: &Env) { - let count = read_credit_rate_change_count(env); - env.storage() - .instance() - .set(&DataKey::CreditRateChangeCount, &(count + 1)); -} - -fn get_emergency_withdrawal_count(env: &Env) -> u32 { - env.storage() - .instance() - .get(&DataKey::EmergencyWithdrawalCount) - .unwrap_or(0) -} - -fn increment_emergency_withdrawal_count(env: &Env) { - let count = get_emergency_withdrawal_count(env); - env.storage().instance().set( - &DataKey::EmergencyWithdrawalCount, - &(count.saturating_add(1)), - ); -} - -fn set_banked_credits(env: &Env, user: &Address, totals: BankedCreditTotals) { - let key = DataKey::BankedCredits(user.clone()); - env.storage().persistent().set(&key, &totals); - bump_user(env, &key); -} - -fn add_total_distributed_credits(env: &Env, amount: i128) { - let total = env - .storage() - .instance() - .get::(&DataKey::TotalDistributedCredits) - .unwrap_or(0); - env.storage().instance().set( - &DataKey::TotalDistributedCredits, - &total.checked_add(amount).expect("total credits overflow"), - ); -} - -fn add_total_credits(env: &Env, amount: i128) { - let total = read_total_credits(env); - env.storage().instance().set( - &DataKey::TotalCredits, - &total.checked_add(amount).expect("total credits overflow"), - ); -} - -fn read_total_banked_credits(env: &Env) -> i128 { - env.storage() - .instance() - .get(&DataKey::TotalBankedCredits) - .unwrap_or(0) -} - -fn add_total_banked_credits(env: &Env, amount: i128) { - let total = read_total_banked_credits(env); - env.storage().instance().set( - &DataKey::TotalBankedCredits, - &total - .checked_add(amount) - .expect("total banked credits overflow"), - ); -} - -fn subtract_total_banked_credits(env: &Env, amount: i128) { - let total = read_total_banked_credits(env); - env.storage().instance().set( - &DataKey::TotalBankedCredits, - &total - .checked_sub(amount) - .expect("total banked credits underflow"), - ); -} - -fn read_total_credits_earned(env: &Env, user: &Address) -> i128 { - let key = DataKey::TotalCreditsEarned(user.clone()); - env.storage().persistent().get(&key).unwrap_or(0) -} - -fn add_total_credits_earned(env: &Env, user: &Address, amount: i128) { - let key = DataKey::TotalCreditsEarned(user.clone()); - let total = env - .storage() - .persistent() - .get::(&key) - .unwrap_or(0); - env.storage().persistent().set( - &key, - &total - .checked_add(amount) - .expect("user lifetime credits overflow"), - ); - bump_user(env, &key); -} - -fn read_total_deposits(env: &Env) -> i128 { - env.storage() - .instance() - .get(&DataKey::TotalDeposits) - .unwrap_or(0) -} - -fn add_total_deposits(env: &Env, amount: i128) { - let total = env - .storage() - .instance() - .get::(&DataKey::TotalDeposits) - .unwrap_or(0); - env.storage().instance().set( - &DataKey::TotalDeposits, - &total.checked_add(amount).expect("total deposits overflow"), - ); -} - -fn read_total_withdrawals(env: &Env) -> i128 { - env.storage() - .instance() - .get(&DataKey::TotalWithdrawals) - .unwrap_or(0) -} - -fn add_total_withdrawals(env: &Env, amount: i128) { - let total = env - .storage() - .instance() - .get::(&DataKey::TotalWithdrawals) - .unwrap_or(0); - env.storage().instance().set( - &DataKey::TotalWithdrawals, - &total - .checked_add(amount) - .expect("total withdrawals overflow"), - ); -} - -fn read_total_boost_allocations(env: &Env) -> u64 { - env.storage() - .instance() - .get(&DataKey::TotalBoostAlloc) - .unwrap_or(0) -} - -fn add_total_boost_allocation(env: &Env, delta: i64) { - let total = read_total_boost_allocations(env); - if delta >= 0 { - env.storage().instance().set( - &DataKey::TotalBoostAlloc, - &total - .checked_add(delta as u64) - .expect("total boost alloc overflow"), - ); - } else { - let sub = (-delta) as u64; - env.storage().instance().set( - &DataKey::TotalBoostAlloc, - &total.checked_sub(sub).expect("total boost alloc underflow"), - ); - } -} - -fn read_boost_user_count(env: &Env) -> u32 { - env.storage() - .instance() - .get(&DataKey::BoostUserCount) - .unwrap_or(0) -} - -fn increment_boost_user_count(env: &Env) { - let count = read_boost_user_count(env); - env.storage() - .instance() - .set(&DataKey::BoostUserCount, &(count + 1)); -} - -fn decrement_boost_user_count(env: &Env) { - let count = read_boost_user_count(env); - if count > 0 { - env.storage() - .instance() - .set(&DataKey::BoostUserCount, &(count - 1)); - } -} - -fn get_position(env: &Env, user: &Address) -> Option { - let key = DataKey::UserPosition(user.clone()); - let value: Option = env.storage().persistent().get(&key); - if value.is_some() { - bump_user(env, &key); - } - value -} - -fn set_position(env: &Env, user: &Address, position: &Position) { - let key = DataKey::UserPosition(user.clone()); - let is_new = !env.storage().persistent().has(&key); - env.storage().persistent().set(&key, position); - bump_user(env, &key); - let mut users = get_locked_users_list(env); - if !users.contains(user) { - users.push_back(user.clone()); - set_locked_users_list(env, &users); - if is_new { - increment_unique_user_count(env); - } - } -} - -fn remove_position(env: &Env, user: &Address) { - env.storage() - .persistent() - .remove(&DataKey::UserPosition(user.clone())); - let users = get_locked_users_list(env); - let mut new_users: Vec
= Vec::new(env); - for u in users.iter() { - if u != *user { - new_users.push_back(u); - } - } - set_locked_users_list(env, &new_users); -} - -fn whitelist_enabled(env: &Env) -> bool { - env.storage() - .instance() - .get(&DataKey::WhitelistEnabled) - .unwrap_or(false) -} - -fn is_user_whitelisted(env: &Env, user: &Address) -> bool { - let key = DataKey::Whitelisted(user.clone()); - let ok = env.storage().persistent().get(&key).unwrap_or(false); - if ok { - bump_user(env, &key); - } - ok -} - -fn get_whitelisted_users_list(env: &Env) -> Vec
{ - env.storage() - .instance() - .get(&DataKey::WhitelistedUsers) - .unwrap_or(Vec::new(env)) -} - -fn set_whitelisted_users_list(env: &Env, users: &Vec
) { - env.storage() - .instance() - .set(&DataKey::WhitelistedUsers, users); -} - -fn get_staked_users_list(env: &Env) -> Vec
{ - env.storage() - .instance() - .get(&DataKey::StakedUsers) - .unwrap_or(Vec::new(env)) -} - -fn set_staked_users_list(env: &Env, users: &Vec
) { - env.storage() - .instance() - .set(&DataKey::StakedUsers, users); -} - -fn get_locked_users_list(env: &Env) -> Vec
{ - env.storage() - .instance() - .get(&DataKey::LockedUsers) - .unwrap_or(Vec::new(env)) -} - -fn set_locked_users_list(env: &Env, users: &Vec
) { - env.storage() - .instance() - .set(&DataKey::LockedUsers, users); -} - -// ── History recording helpers ──────────────────────────────────────────────── - -fn record_whitelist_event(env: &Env, user: &Address, added: bool) { - let count: u32 = env - .storage() - .instance() - .get(&DataKey::WhitelistEventCount) - .unwrap_or(0); - let event = WhitelistEvent { - user: user.clone(), - added, - ledger: env.ledger().sequence(), - }; - env.storage() - .persistent() - .set(&DataKey::WhitelistHistory(count), &event); - env.storage() - .instance() - .set(&DataKey::WhitelistEventCount, &(count + 1)); -} - -fn record_boost_event(env: &Env, user: &Address, old_allocation: u32, new_allocation: u32) { - let count: u32 = env - .storage() - .instance() - .get(&DataKey::BoostEventCount) - .unwrap_or(0); - let event = BoostEvent { - user: user.clone(), - old_allocation, - new_allocation, - ledger: env.ledger().sequence(), - }; - env.storage() - .persistent() - .set(&DataKey::BoostHistory(count), &event); - env.storage() - .instance() - .set(&DataKey::BoostEventCount, &(count + 1)); -} - -fn record_stake_event(env: &Env, user: &Address, action: soroban_sdk::Symbol, amount: i128) { - let count: u32 = env - .storage() - .instance() - .get(&DataKey::StakeEventCount) - .unwrap_or(0); - let event = StakeEvent { - user: user.clone(), - action, - amount, - ledger: env.ledger().sequence(), - }; - env.storage() - .persistent() - .set(&DataKey::StakeHistory(count), &event); - env.storage() - .instance() - .set(&DataKey::StakeEventCount, &(count + 1)); -} - -fn record_admin_action(env: &Env, action: soroban_sdk::Symbol, admin: &Address) { - let count: u32 = env - .storage() - .instance() - .get(&DataKey::AdminActionCount) - .unwrap_or(0); - let event = AdminActionEvent { - action, - admin: admin.clone(), - ledger: env.ledger().sequence(), - }; - env.storage() - .persistent() - .set(&DataKey::AdminActionHistory(count), &event); - env.storage() - .instance() - .set(&DataKey::AdminActionCount, &(count + 1)); -} - -// ── #310 — Credit rate history ──────────────────────────────────────────────── - -fn record_credit_rate_change(env: &Env, old_rate: i128, new_rate: i128) { - let count: u32 = env - .storage() - .instance() - .get(&DataKey::CreditRateEventCount) - .unwrap_or(0); - let event = CreditRateEvent { - old_rate, - new_rate, - ledger: env.ledger().sequence(), - }; - env.storage() - .persistent() - .set(&DataKey::CreditRateHistory(count), &event); - env.storage() - .instance() - .set(&DataKey::CreditRateEventCount, &(count + 1)); -} - -// ── #311 — Global multiplier history ───────────────────────────────────────── - -fn record_global_multiplier_change(env: &Env, old_multiplier: u32, new_multiplier: u32) { - let count: u32 = env - .storage() - .instance() - .get(&DataKey::GlobalMultiplierEventCount) - .unwrap_or(0); - let event = GlobalMultiplierEvent { - old_multiplier, - new_multiplier, - ledger: env.ledger().sequence(), - }; - env.storage() - .persistent() - .set(&DataKey::GlobalMultiplierHistory(count), &event); - env.storage() - .instance() - .set(&DataKey::GlobalMultiplierEventCount, &(count + 1)); -} - -// ── Boost calculation ───────────────────────────────────────────────────────── - -/// Compute the effective total stake for credit accrual. -/// -/// Splits `amount` into a principal portion and a boosted virtual portion. -/// The computation uses a single trailing division so small stakes do not -/// lose their boost to intermediate truncation (#284): -/// total_stake = amount * (100 - allocation_pct + allocation_pct * multiplier) / 100 -/// -/// This is algebraically identical to the split form -/// (`boosted = amount * allocation_pct / 100`, `principal = amount - boosted`, -/// `virtual = boosted * multiplier`) but defers the `/ 100` until after all -/// multiplication, so e.g. `amount = 1, allocation_pct = 1, multiplier = 1000` -/// yields `10` instead of truncating `boosted` to `0` and returning `1`. -/// -/// With no boost (allocation_pct = 0) total_stake == amount. -fn compute_total_stake(amount: i128, allocation_pct: u32, multiplier: u32) -> i128 { - let alloc = allocation_pct as i128; - let mult = multiplier as i128; - amount * (100 - alloc + alloc * mult) / 100 -} - -fn compute_credits( - amount: i128, - allocation_pct: u32, - multiplier: u32, - credit_rate: i128, - ledgers_elapsed: u32, -) -> i128 { - compute_total_stake(amount, allocation_pct, multiplier) - .checked_mul(credit_rate) - .and_then(|t| t.checked_mul(ledgers_elapsed as i128)) - .expect("credits arithmetic overflow") -} - -fn compute_stake_accrual(env: &Env, user: &Address, stake: &UserStake, current: u32) -> i128 { - let allocation_pct = get_user_boost(env, user).unwrap_or(0); - let current_multiplier = read_global_multiplier(env); - let change_ledger = read_global_multiplier_change_ledger(env); - let elapsed_since_start = current.saturating_sub(stake.start_ledger); - - if stake.multiplier == current_multiplier || change_ledger <= stake.start_ledger { - return compute_credits( - stake.amount, - allocation_pct, - stake.multiplier, - stake.credit_rate, - elapsed_since_start, - ); - } - - let pre_change_elapsed = change_ledger - .saturating_sub(stake.start_ledger) - .min(elapsed_since_start); - let post_change_elapsed = current - .saturating_sub(change_ledger) - .min(elapsed_since_start.saturating_sub(pre_change_elapsed)); - - compute_credits( - stake.amount, - allocation_pct, - stake.multiplier, - stake.credit_rate, - pre_change_elapsed, - ) + compute_credits( - stake.amount, - allocation_pct, - current_multiplier, - stake.credit_rate, - post_change_elapsed, - ) -} - -/// Snapshot the user's current credit accrual and adopt the latest global -/// multiplier and credit rate. -/// -/// This is called internally by `stake`, `unstake`, and `set_boost` to -/// freeze the user's accrued credits under the *old* rate/multiplier before -/// switching them to the *current* values for future accrual. -/// -/// # Design trade-off: rate changes between checkpoints -/// -/// `credit_rate` and `global_multiplier` are global parameters that can be -/// changed by the admin at any time (via `set_credit_rate` / -/// `set_global_multiplier`). Because each user's snapshot is only updated -/// when *they* trigger a checkpoint (stake, unstake, or set_boost), users -/// who checkpoint less frequently may earn credits at a different effective -/// rate than those who checkpoint more often during a rate change window. -/// -/// This is an intentional design choice: it keeps credit accrual fully -/// local to each user's storage entry (no shared counter to synchronise), -/// avoids front-running concerns around rate changes, and ensures that the -/// cost of a rate change is O(1) rather than O(n) in the number of users. -/// Integrators should be aware that a user's on-chain credit balance may -/// temporarily reflect an outdated rate until their next checkpoint. -/// -/// Helper function to perform a checkpoint on a user's `UserStake`. -/// -/// Computes and banks accrued credits based on the active boost configuration, -/// updates `start_ledger`, and snapshots current global multiplier / credit rate. -/// Emits `(symbol_short!("pool"), symbol_short!("chkpt"))` when accrued > 0. -fn checkpoint(env: &Env, user: &Address, stake: &mut UserStake) { - let current = env.ledger().sequence(); - let accrued = compute_stake_accrual(env, user, stake, current); - stake.credits_banked += accrued; - add_total_credits(env, accrued); - add_total_distributed_credits(env, accrued); - if accrued > 0 { - add_total_banked_credits(env, accrued); - add_total_credits_earned(env, user, accrued); - } - stake.start_ledger = current; - stake.credit_rate = read_credit_rate(env); - stake.multiplier = read_global_multiplier(env); - - if accrued > 0 { - #[allow(deprecated)] - env.events().publish( - (symbol_short!("pool"), symbol_short!("chkpt")), - (user.clone(), accrued, stake.credits_banked), - ); - } -} - -fn checkpoint_position(env: &Env, user: &Address, position: &mut Position) { - let current = env.ledger().sequence(); - let elapsed = current.saturating_sub(position.checkpoint_ledger); - let delta = compute_credits(position.amount, 0, 1, position.credit_rate, elapsed); - position.total_credits += delta; - add_total_credits(env, delta); - add_total_distributed_credits(env, delta); - if delta > 0 { - add_total_banked_credits(env, delta); - add_total_credits_earned(env, user, delta); - } - position.checkpoint_ledger = current; - position.credit_rate = read_credit_rate(env); - - if delta > 0 { - #[allow(deprecated)] - env.events().publish( - (symbol_short!("pool"), symbol_short!("chkpt")), - (user.clone(), delta, position.total_credits), - ); - } -} - -#[contract] -pub struct FarmingPool; - -#[contractimpl] -impl FarmingPool { - /// Initialize the pool. `global_multiplier` and `credit_rate` are bounded - /// by `MAX_GLOBAL_MULTIPLIER`/`MAX_CREDIT_RATE` — see #89 for the - /// overflow-safety derivation shared with `set_global_multiplier` and - /// `set_credit_rate`. - pub fn initialize( - env: Env, - admin: Address, - stake_token: Address, - global_multiplier: u32, - credit_rate: i128, - min_lock_period: u32, - min_stake_amount: i128, - ) -> Result<(), PoolError> { - if env.storage().instance().has(&DataKey::Admin) { - return Err(PoolError::AlreadyInitialized); - } - // Ceilings mirror `set_global_multiplier`/`set_credit_rate` — see #89. - if !(1..=MAX_GLOBAL_MULTIPLIER).contains(&global_multiplier) { - return Err(PoolError::InvalidGlobalMultiplier); - } - if credit_rate <= 0 || credit_rate > MAX_CREDIT_RATE { - return Err(PoolError::InvalidCreditRate); - } - // Same ceiling `set_min_lock_period` enforces; without it a pool could be - // deployed with a lock period the admin setter would refuse. - if min_lock_period > MAX_LOCK_PERIOD { - return Err(PoolError::InvalidLockPeriod); - } - let min_stake = if min_stake_amount <= 0 { - 1i128 - } else { - min_stake_amount - }; - if min_stake > MAX_STAKE_AMOUNT { - return Err(PoolError::InvalidMinStakeAmount); - } - - env.storage().instance().set(&DataKey::Admin, &admin); - env.storage() - .instance() - .set(&DataKey::StakeToken, &stake_token); - env.storage() - .instance() - .set(&DataKey::GlobalMultiplier, &global_multiplier); - env.storage() - .instance() - .set(&DataKey::CreditRate, &credit_rate); - env.storage() - .instance() - .set(&DataKey::MinLockPeriod, &min_lock_period); - env.storage() - .instance() - .set(&DataKey::MinStakeAmount, &min_stake); - env.storage().instance().set(&DataKey::TotalStaked, &0i128); - env.storage().instance().set(&DataKey::TotalLocked, &0i128); - env.storage().instance().set(&DataKey::TotalCredits, &0i128); - env.storage() - .instance() - .set(&DataKey::TotalBankedCredits, &0i128); - env.storage() - .instance() - .set(&DataKey::TotalDeposits, &0i128); - env.storage() - .instance() - .set(&DataKey::TotalWithdrawals, &0i128); - env.storage() - .instance() - .set(&DataKey::SchemaVersion, &SCHEMA_VERSION); - bump_instance(&env); - Ok(()) - } - - pub fn admin(env: Env) -> Result { - bump_instance(&env); - get_admin(&env) - } - - /// Return the stake token address configured for the pool (#558). - pub fn stake_token(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - get_stake_token(&env) - } - - /// Alias for `stake_token` for consistency with getter conventions (#558). - pub fn get_stake_token(env: Env) -> Result { - Self::stake_token(env) - } - - /// Propose an admin handoff. The proposed admin must separately call - /// `accept_admin` and authorise the call before the handoff completes. - /// - /// Proposing the current admin cancels any pending handoff. A new proposal - /// also overwrites an existing pending handoff. - /// Emits `("pool", "adm_prop")` with `(current_admin, proposed_admin)`. - pub fn propose_admin(env: Env, new_admin: Address) -> Result<(), PoolError> { - let current = get_admin(&env)?; - current.require_auth(); - bump_instance(&env); - - if new_admin == current { - env.storage().instance().remove(&DataKey::PendingAdmin); - } else { - env.storage() - .instance() - .set(&DataKey::PendingAdmin, &new_admin); - } - env.events().publish( - (symbol_short!("pool"), symbol_short!("adm_prop")), - (current, new_admin), - ); - Ok(()) - } - - /// Accept the pending admin handoff. Only the proposed admin can authorise. - pub fn accept_admin(env: Env) -> Result<(), PoolError> { - require_initialized(&env)?; - let pending: Address = env - .storage() - .instance() - .get(&DataKey::PendingAdmin) - .ok_or(PoolError::NoPendingAdmin)?; - pending.require_auth(); - let current = get_admin(&env)?; - bump_instance(&env); - - env.storage().instance().set(&DataKey::Admin, &pending); - env.storage().instance().remove(&DataKey::PendingAdmin); - env.events().publish( - (symbol_short!("pool"), symbol_short!("adm_xfr")), - (current, pending), - ); - Ok(()) - } - - /// Admin: transfer admin rights to `new_admin` in one step. - /// - /// Deprecated: use `propose_admin` followed by `accept_admin` so the new - /// admin must prove control of its address before the handoff completes. - /// Emits a `("pool", "adm_xfr")` event with `(old_admin, new_admin)` tuple. - #[deprecated(note = "use propose_admin followed by accept_admin")] - pub fn transfer_admin(env: Env, new_admin: Address) -> Result<(), PoolError> { - let current = get_admin(&env)?; - current.require_auth(); - bump_instance(&env); - - env.storage().instance().set(&DataKey::Admin, &new_admin); - env.storage().instance().remove(&DataKey::PendingAdmin); - #[allow(deprecated)] - env.events().publish( - (symbol_short!("pool"), symbol_short!("adm_xfr")), - (current, new_admin), - ); - Ok(()) - } - - pub fn schema_version(env: Env) -> u32 { - bump_instance(&env); - read_schema_version(&env) - } - - /// Schema migration entry-point (currently a no-op placeholder). - /// - /// This function exists so that future schema version bumps can perform - /// data migrations inside the same entry-point without changing the ABI. - /// At present `SCHEMA_VERSION == 1` and no stored data needs - /// transformation, so the call simply stamps the current version and - /// returns the previous one. - /// - /// Admin-only. Returns the schema version *before* this call. - /// - /// # Behaviour once real migrations are needed - /// - /// When `SCHEMA_VERSION` is bumped, add a `match` over the old version - /// that performs the necessary storage reads/writes (e.g. re-encoding a - /// stored struct, adding a new field with a default value, etc.) **before** - /// writing the new `SCHEMA_VERSION`. Each migration step must be idempotent - /// and should be tested in isolation. - pub fn migrate(env: Env) -> Result { - require_initialized(&env)?; - let admin = get_admin(&env)?; - admin.require_auth(); - bump_instance(&env); - - let current = read_schema_version(&env); - let target = SCHEMA_VERSION; - - if current < target { - let mut version = current; - while version < target { - match version { - 0 => { - // Initial schema tracking migration (v0 -> v1) - version = 1; - } - 1 => { - // Schema migration v1 -> v2 (placeholder for v1 -> v2 transformations) - version = 2; - } - _ => break, - } - } - env.storage() - .instance() - .set(&DataKey::SchemaVersion, &target); - } - - #[allow(deprecated)] - env.events().publish( - (symbol_short!("pool"), symbol_short!("migrated")), - (current, target), - ); - record_admin_action(&env, symbol_short!("migrate"), &admin); - Ok(current) - } - - pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), PoolError> { - require_initialized(&env)?; - let admin = get_admin(&env)?; - admin.require_auth(); - bump_instance(&env); - - // Capture the old WASM hash before replacing it so the upgrade event - // provides a complete audit trail of which version was replaced (#291). - let old_wasm_hash = match env.current_contract_address().executable() { - Some(Executable::Wasm(hash)) => Some(hash), - _ => None, - }; - - #[allow(deprecated)] - env.events().publish( - (symbol_short!("pool"), symbol_short!("upgraded")), - (old_wasm_hash, new_wasm_hash.clone()), - ); - - record_admin_action(&env, symbol_short!("upgrade"), &admin); - - env.deployer().update_current_contract_wasm(new_wasm_hash); - Ok(()) - } - - /// Admin: re-initialize a user's banked credits after TTL expiry (#290). - /// - /// If a user's `BankedCredits` storage entry expired due to TTL, this - /// function allows the admin to restore it from verified off-chain records. - /// The admin must provide the correct position and stake credit totals as - /// known from the last checkpoint before expiry. - /// - /// This does NOT restore the user's stake or position (those require a new - /// `stake` or `lock_assets` call) — only their banked credit history so - /// they do not lose previously-earned credits. - pub fn recover_banked_credits( - env: Env, - user: Address, - position_credits: i128, - stake_credits: i128, - ) -> Result<(), PoolError> { - require_initialized(&env)?; - get_admin(&env)?.require_auth(); - if position_credits < 0 || stake_credits < 0 { - return Err(PoolError::InvalidAmount); - } - bump_instance(&env); - - set_banked_credits( - &env, - &user, - BankedCreditTotals { - position_credits, - stake_credits, - }, - ); - - #[allow(deprecated)] - env.events().publish( - (symbol_short!("pool"), symbol_short!("recov")), - (user, position_credits, stake_credits), - ); - Ok(()) - } - - /// Lock assets for the minimum lock period. A top-up checkpoints the - /// existing position and extends its whole-position unlock ledger to the - /// later of the existing unlock ledger and a fresh period from this call. - /// - /// # Reentrancy posture (#398) - /// - /// `stake_token` is admin-supplied and therefore not necessarily a trusted - /// Stellar Asset Contract — a non-standard `transfer` could call back into - /// this contract while it is still executing. Two independent defenses - /// apply, in order: - /// - /// 1. **Checks-effects-interactions.** Every validation runs first, the - /// position (including the extended unlock ledger) is written to - /// storage, and only then is `token::transfer` called. A reentrant call - /// therefore observes the already-updated position rather than a - /// half-applied one, so it cannot withdraw or re-credit more than the - /// post-deposit position allows. `token::Client::transfer` returns - /// `()` on success and traps on failure, so a failed transfer reverts the - /// whole invocation and leaves no partial deposit (see #363). - /// 2. **Host-level reentry prohibition.** Soroban's `ContractReentryMode` - /// defaults to `Prohibited`, so a token that tries to reenter this - /// contract during the transfer traps with "Contract re-entry is not - /// allowed" before any of our code runs. - /// - /// Both properties are covered by the reentrancy tests in `test.rs` - /// (`test_lock_assets_reentrant_transfer_is_rejected_and_final_state_is_correct` - /// and `test_lock_assets_reverts_entirely_if_stake_token_naively_reenters`), - /// which use `MockReentrantToken` / `MockNaiveReentrantToken` to attempt - /// the reentry mid-transfer. No code change is required for the reported - /// concern; this comment records the verification. - pub fn lock_assets(env: Env, user: Address, amount: i128) -> Result<(), PoolError> { - user.require_auth(); - require_initialized(&env)?; - require_staking_not_paused(&env)?; - - if amount <= 0 { - return Err(PoolError::InvalidAmount); - } - if amount > MAX_STAKE_AMOUNT { - return Err(PoolError::ExceedsMaxStake); - } - - if whitelist_enabled(&env) && !is_user_whitelisted(&env, &user) { - return Err(PoolError::NotWhitelisted); - } - let existing_amount = get_position(&env, &user).map_or(0i128, |p| p.amount); - let total_amount = existing_amount + amount; - let min_stake = Self::get_min_stake_amount(env.clone())?; - if total_amount < min_stake { - return Err(PoolError::BelowMinimumStake); - } - if total_amount > MAX_STAKE_AMOUNT { - return Err(PoolError::ExceedsMaxStake); - } - - // Same pre-write guards as `stake`: pool-wide totals must not overflow - // (`add_total_staked` / `add_total_locked` would trap via `expect`), and - // the caller must be funded. Both are decided before any write or - // cross-contract call. - read_total_staked(&env) - .checked_add(amount) - .ok_or(PoolError::ExceedsMaxStake)?; - read_total_locked(&env) - .checked_add(amount) - .ok_or(PoolError::ExceedsMaxStake)?; - - let stake_token = get_stake_token(&env)?; - let token_client = token::TokenClient::new(&env, &stake_token); - let caller_balance = match token_client.try_balance(&user) { - Ok(Ok(balance)) => balance, - _ => return Err(PoolError::InvalidStakeToken), - }; - if caller_balance < amount { - return Err(PoolError::InsufficientBalance); - } - - bump_instance(&env); - - let was_staked = is_user_staked(&env, &user); - let current = env.ledger().sequence(); - let mut position = if let Some(mut existing) = get_position(&env, &user) { - checkpoint_position(&env, &user, &mut existing); - existing.amount += amount; - - // Issue #376: Update lock_ledger to current ledger for additional deposits - // This ensures the lock period is based on the most recent deposit, not the original - existing.lock_ledger = current; - let fresh_unlock = current.saturating_add(read_min_lock_period(&env)); - existing.unlock_ledger = existing.unlock_ledger.max(fresh_unlock); - existing - } else { - Position { - amount, - lock_ledger: current, - unlock_ledger: current.saturating_add(read_min_lock_period(&env)), - checkpoint_ledger: current, - total_credits: 0, - credit_rate: read_credit_rate(&env), - } - }; - - position.credit_rate = read_credit_rate(&env); - - // Checks-effects-interactions: persist state *before* the external - // token transfer below. `stake_token` is an admin-supplied address, - // not necessarily a trusted Stellar Asset Contract, and its - // `transfer` is a synchronous cross-contract call that could - // otherwise observe (or, on a future host that permits it, mutate) - // this position while it's still only a local variable. If the - // transfer fails, the whole invocation reverts and this write is - // rolled back with it — Soroban's per-invocation atomicity, not - // manual sequencing, is what keeps this safe on failure. See #69. - set_position(&env, &user, &position); - if !was_staked && is_user_staked(&env, &user) { - increment_staked_user_count(&env); - } - increment_lock_count(&env); - add_total_staked(&env, amount); - add_total_locked(&env, amount); - add_total_deposits(&env, amount); - - // Verify the pool actually received the full amount, as `stake` does. A - // fee-on-transfer or under-delivering token would otherwise leave the - // locked position backed by less than its recorded amount. Returning an - // error reverts the whole invocation, including the position write above. - let pool_before = token_client.balance(&env.current_contract_address()); - token_client.transfer(&user, env.current_contract_address(), &amount); - let pool_after = token_client.balance(&env.current_contract_address()); - if pool_after.checked_sub(pool_before).unwrap_or(0) < amount { - return Err(PoolError::TransferAmountMismatch); - } - - env.events().publish( - (symbol_short!("pool"), symbol_short!("locked")), - (user.clone(), amount, position.amount), - ); - - record_stake_event(&env, &user, symbol_short!("lock"), amount); - record_user_activity(&env, &user); - increment_user_activity_count(&env, &user); - add_user_tier_points(&env, &user, (amount / 1000000) + (read_min_lock_period(&env) as i128 / 100000)); - - Ok(()) - } - - pub fn unlock_assets(env: Env, user: Address, amount: i128) -> Result<(), PoolError> { - user.require_auth(); - require_initialized(&env)?; - require_withdrawals_not_paused(&env)?; - - if amount <= 0 { - return Err(PoolError::InvalidAmount); - } - if amount > MAX_STAKE_AMOUNT { - return Err(PoolError::ExceedsMaxStake); - } - bump_instance(&env); - - let was_staked = is_user_staked(&env, &user); - let mut position = get_position(&env, &user).ok_or(PoolError::NoActivePosition)?; - if amount > position.amount { - return Err(PoolError::InsufficientBalance); - } - - let current = env.ledger().sequence(); - if current < position.unlock_ledger { - return Err(PoolError::LockPeriodNotElapsed); - } - - let credits_before = position.total_credits; - checkpoint_position(&env, &user, &mut position); - let credits_earned = position.total_credits - credits_before; - let total_credits = position.total_credits; - position.amount -= amount; - - // Checks-Effects-Interactions: persist state before token transfer (#70). - if position.amount == 0 { - let banked = Self::get_banked_credits_split(env.clone(), user.clone())?; - set_banked_credits( - &env, - &user, - BankedCreditTotals { - position_credits: banked.position_credits + total_credits, - stake_credits: banked.stake_credits, - }, - ); - remove_position(&env, &user); - } else { - set_position(&env, &user, &position); - } - if was_staked && !is_user_staked(&env, &user) { - decrement_staked_user_count(&env); - } - subtract_total_staked(&env, amount); - subtract_total_locked(&env, amount); - - let stake_token = get_stake_token(&env)?; - token::TokenClient::new(&env, &stake_token).transfer( - &env.current_contract_address(), - &user, - &amount, - ); - - env.events().publish( - (symbol_short!("pool"), symbol_short!("unlocked")), - (user.clone(), amount, credits_earned, total_credits), - ); - - record_stake_event(&env, &user, symbol_short!("unlock"), amount); - record_user_activity(&env, &user); - increment_user_activity_count(&env, &user); - - Ok(()) - } - - /// Calculate current accrued credits specifically for the time-locked `Position` staking system. - /// - /// See also `get_position_credits` (an explicit alias for this function) and `get_credits` - /// (which calculates combined credits across both `Position` and `UserStake` systems). - pub fn calculate_credits(env: Env, user: Address) -> Result { - require_initialized(&env)?; - bump_instance(&env); - let Some(position) = get_position(&env, &user) else { - return Ok(0); - }; - - let elapsed = env - .ledger() - .sequence() - .saturating_sub(position.checkpoint_ledger); - let accrued = compute_credits(position.amount, 0, 1, position.credit_rate, elapsed); - Ok(position.total_credits + accrued) - } - - /// Return current accrued credits for a user's time-locked `Position`. - /// - /// Alias for `calculate_credits` providing explicit system-specific naming. - pub fn get_position_credits(env: Env, user: Address) -> Result { - Self::calculate_credits(env, user) - } - - pub fn get_user_position(env: Env, user: Address) -> Result, PoolError> { - require_initialized(&env)?; - bump_instance(&env); - let Some(mut position) = get_position(&env, &user) else { - return Ok(None); - }; - let current = env.ledger().sequence(); - let elapsed = current.saturating_sub(position.checkpoint_ledger); - let accrued = compute_credits(position.amount, 0, 1, position.credit_rate, elapsed); - position.total_credits += accrued; - position.checkpoint_ledger = current; - position.credit_rate = read_credit_rate(&env); - Ok(Some(position)) - } - - /// Lightweight check for whether `user` has an active locked position. - /// - /// Returns `true` if the user has a non-zero locked position, `false` - /// otherwise. This is cheaper than `get_user_position` as it avoids - /// computing uncommitted credit accrual. - pub fn has_position(env: Env, user: Address) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(get_position(&env, &user).is_some()) - } - - /// Lightweight check for whether `user` has an active flexible stake (#560). - /// - /// Returns `true` if the user has an active flexible stake, `false` - /// otherwise. This is cheaper than `get_stake` as it avoids computing - /// uncommitted credit accrual and allocating the `UserStake` struct. - pub fn has_stake(env: Env, user: Address) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(get_user_stake(&env, &user).is_some()) - } - - pub fn pause(env: Env) -> Result<(), PoolError> { - require_initialized(&env)?; - let admin = get_admin(&env)?; - admin.require_auth(); - bump_instance(&env); - env.storage().instance().set(&DataKey::Paused, &true); - env.storage().instance().set(&DataKey::PausedStaking, &true); - env.storage() - .instance() - .set(&DataKey::PausedWithdrawals, &true); - env.events() - .publish((symbol_short!("pool"), symbol_short!("paused")), ()); - - record_admin_action(&env, symbol_short!("paused"), &admin); - - Ok(()) - } - - pub fn pause_staking(env: Env) -> Result<(), PoolError> { - require_initialized(&env)?; - get_admin(&env)?.require_auth(); - bump_instance(&env); - env.storage().instance().set(&DataKey::PausedStaking, &true); - env.events() - .publish((symbol_short!("pool"), symbol_short!("stg_pause")), ()); - Ok(()) - } - - pub fn pause_withdrawals(env: Env) -> Result<(), PoolError> { - require_initialized(&env)?; - get_admin(&env)?.require_auth(); - bump_instance(&env); - env.storage() - .instance() - .set(&DataKey::PausedWithdrawals, &true); - env.events() - .publish((symbol_short!("pool"), symbol_short!("wd_pause")), ()); - Ok(()) - } - - pub fn unpause(env: Env) -> Result<(), PoolError> { - require_initialized(&env)?; - let admin = get_admin(&env)?; - admin.require_auth(); - bump_instance(&env); - env.storage().instance().set(&DataKey::Paused, &false); - env.storage() - .instance() - .set(&DataKey::PausedStaking, &false); - env.storage() - .instance() - .set(&DataKey::PausedWithdrawals, &false); - env.events() - .publish((symbol_short!("pool"), symbol_short!("unpaused")), ()); - - record_admin_action(&env, symbol_short!("unpaus"), &admin); - - Ok(()) - } - - pub fn unpause_staking(env: Env) -> Result<(), PoolError> { - require_initialized(&env)?; - get_admin(&env)?.require_auth(); - bump_instance(&env); - env.storage() - .instance() - .set(&DataKey::PausedStaking, &false); - env.events() - .publish((symbol_short!("pool"), symbol_short!("stg_unps")), ()); - Ok(()) - } - - pub fn unpause_withdrawals(env: Env) -> Result<(), PoolError> { - require_initialized(&env)?; - get_admin(&env)?.require_auth(); - bump_instance(&env); - env.storage() - .instance() - .set(&DataKey::PausedWithdrawals, &false); - env.events() - .publish((symbol_short!("pool"), symbol_short!("wd_unps")), ()); - Ok(()) - } - - pub fn is_paused(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(pool_is_paused(&env)) - } - - /// Return whether staking operations are currently paused (#559). - pub fn is_staking_paused(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(pool_is_staking_paused(&env)) - } - - /// Return whether withdrawal operations are currently paused (#559). - pub fn is_withdrawals_paused(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(pool_is_withdrawals_paused(&env)) - } - - /// Withdraw staked/locked tokens during emergency when pool is paused. - /// - /// Allows users to withdraw their staked/locked assets during an emergency pause. - /// Requires authorization from `user`. Checkpoints and preserves accrued credit - /// totals in `BankedCredits`. - /// - /// # Usage & Operational Guidelines - /// - **When to Use**: Called during emergency situations or protocol maintenance when - /// the contract has been explicitly paused via `pause()`. - /// - **Credit Preservation**: Staked/locked assets are returned in full while accrued - /// credits are safely preserved in `BankedCredits` split between `position_credits` - /// and `stake_credits` (see `get_banked_credits_split`). Users do not forfeit earned credits. - /// - **User Notification & Off-Chain Tracking**: Every emergency withdrawal emits an - /// on-chain `("pool", "emrg_exit")` event with payload `(admin, user, total_returned)`. - /// Indexers and frontends notify users of emergency exit transactions by monitoring this topic. - /// - **Audit Requirements & Privilege Governance**: Because emergency mechanisms handle pool - /// assets during security pauses, all admin actions initiating pauses and emergency exits must - /// be logged to immutable audit trails and governed by multi-sig or timelock controls. - pub fn emergency_withdraw(env: Env, user: Address) -> Result { - user.require_auth(); - require_initialized(&env)?; - let admin = get_admin(&env)?; - if !pool_is_paused(&env) { - return Err(PoolError::NotPaused); - } - bump_instance(&env); - - let was_staked = is_user_staked(&env, &user); - let mut position_opt = get_position(&env, &user); - let mut stake_opt = get_user_stake(&env, &user); - if position_opt.is_none() && stake_opt.is_none() { - return Err(PoolError::NoActiveStake); - } - // Bank credits accrued since the last checkpoint so they survive the exit (#295). - if let Some(p) = position_opt.as_mut() { - checkpoint_position(&env, &user, p); - } - if let Some(s) = stake_opt.as_mut() { - checkpoint(&env, &user, s); - } - let position_amount = position_opt.as_ref().map_or(0i128, |p| p.amount); - let stake_amount = stake_opt.as_ref().map_or(0i128, |s| s.amount); - let total_returned = position_amount + stake_amount; - if total_returned == 0 { - return Err(PoolError::NoActiveStake); - } - let position_credits = position_opt.as_ref().map_or(0i128, |p| p.total_credits); - let stake_credits = stake_opt.as_ref().map_or(0i128, |s| s.credits_banked); - - // Checks-effects-interactions: clear all state *before* the external - // token transfers below so a malicious token contract cannot - // re-enter and double-withdraw. On transfer failure the whole - // invocation reverts, rolling these writes back. See #279. - if position_opt.is_some() { - subtract_total_staked(&env, position_amount); - subtract_total_locked(&env, position_amount); - remove_position(&env, &user); - } - - if stake_opt.is_some() { - subtract_total_staked(&env, stake_amount); - remove_user_stake(&env, &user); - decrement_active_stake_count(&env); - } - - if was_staked && !is_user_staked(&env, &user) { - decrement_staked_user_count(&env); - } - - add_total_withdrawals(&env, total_returned); - - // Bank the position and stake credits as separate totals so each staking - // system's accrual history survives even when a user held both (#145). - if position_credits > 0 || stake_credits > 0 { - set_banked_credits( - &env, - &user, - BankedCreditTotals { - position_credits, - stake_credits, - }, - ); - } - - increment_emergency_withdrawal_count(&env); - - let stake_token = get_stake_token(&env)?; - let token = token::TokenClient::new(&env, &stake_token); - if position_amount > 0 { - token.transfer(&env.current_contract_address(), &user, &position_amount); - } - if stake_amount > 0 { - token.transfer(&env.current_contract_address(), &user, &stake_amount); - } - - env.events().publish( - (symbol_short!("pool"), symbol_short!("emrg_exit")), - (admin, user, total_returned), - ); - Ok(total_returned) - } - - /// Total number of successful `emergency_withdraw` calls since pool - /// initialization, for protocol risk monitoring (#257). - pub fn emergency_withdrawal_count(env: Env) -> u32 { - bump_instance(&env); - get_emergency_withdrawal_count(&env) - } - - pub fn get_banked_credits(env: Env, user: Address) -> i128 { - bump_instance(&env); - let totals = Self::get_banked_credits_split(env, user).unwrap_or(BankedCreditTotals { - position_credits: 0, - stake_credits: 0, - }); - totals.position_credits + totals.stake_credits - } - - /// Return the banked credits for `user`, split by staking system. The - /// lock/unlock `position` and boost `stake` histories are kept separate so - /// that a user who held both does not lose which credits came from where - /// (#145). Returns zeros when `user` has no banked credits. - pub fn get_banked_credits_split( - env: Env, - user: Address, - ) -> Result { - bump_instance(&env); - let key = DataKey::BankedCredits(user.clone()); - let value: Option = env.storage().persistent().get(&key); - if value.is_some() { - bump_user(&env, &key); - } - Ok(value.unwrap_or(BankedCreditTotals { - position_credits: 0, - stake_credits: 0, - })) - } - - // ── Whitelist system ────────────────────────────────────────────────────── - - /// Admin: enable whitelist mode. Admin must authorise. - /// Emits `("pool", "wl_en")` event. - pub fn enable_whitelist(env: Env) -> Result<(), PoolError> { - require_initialized(&env)?; - get_admin(&env)?.require_auth(); - bump_instance(&env); - env.storage() - .instance() - .set(&DataKey::WhitelistEnabled, &true); - env.events().publish( - (symbol_short!("pool"), symbol_short!("wl_en")), - (), - ); - Ok(()) - } - - /// Admin: disable whitelist mode. Admin must authorise. - /// Emits `("pool", "wl_dis")` event. - pub fn disable_whitelist(env: Env) -> Result<(), PoolError> { - require_initialized(&env)?; - get_admin(&env)?.require_auth(); - bump_instance(&env); - env.storage() - .instance() - .set(&DataKey::WhitelistEnabled, &false); - env.events().publish( - (symbol_short!("pool"), symbol_short!("wl_dis")), - (), - ); - Ok(()) - } - - /// Admin: add `user` to the whitelist. Admin must authorise. - pub fn add_to_whitelist(env: Env, user: Address) -> Result<(), PoolError> { - require_initialized(&env)?; - get_admin(&env)?.require_auth(); - bump_instance(&env); - - let key = DataKey::Whitelisted(user.clone()); - env.storage().persistent().set(&key, &true); - bump_user(&env, &key); - - let mut users = get_whitelisted_users_list(&env); - if !users.contains(&user) { - users.push_back(user.clone()); - set_whitelisted_users_list(&env, &users); - } - - record_whitelist_event(&env, &user, true); - - Ok(()) - } - - /// Admin: remove `user` from the whitelist. Admin must authorise. - pub fn remove_from_whitelist(env: Env, user: Address) -> Result<(), PoolError> { - require_initialized(&env)?; - get_admin(&env)?.require_auth(); - bump_instance(&env); - - let key = DataKey::Whitelisted(user.clone()); - env.storage().persistent().remove(&key); - - let users = get_whitelisted_users_list(&env); - let mut new_users: Vec
= Vec::new(&env); - for u in users.iter() { - if u != user { - new_users.push_back(u); - } - } - set_whitelisted_users_list(&env, &new_users); - - record_whitelist_event(&env, &user, false); - - Ok(()) - } - - /// Public: check if `user` is whitelisted. Bumps TTL of the entry if whitelisted. - pub fn is_whitelisted(env: Env, user: Address) -> bool { - bump_instance(&env); - is_user_whitelisted(&env, &user) - } - - /// Return a paginated list of all whitelisted addresses. - /// - /// `offset`: zero-based index of the first address to return. - /// `limit`: maximum number of addresses to return per call. - /// - /// Returns a `ListWhitelistedResponse` containing the requested page and - /// the total number of whitelisted addresses. Call repeatedly with - /// increasing `offset` until `offset >= total` to retrieve the full list. - pub fn get_whitelisted_users( - env: Env, - offset: u32, - limit: u32, - ) -> Result { - require_initialized(&env)?; - bump_instance(&env); - - let all = get_whitelisted_users_list(&env); - let total = all.len(); - let mut page: Vec
= Vec::new(&env); - let mut i = offset; - let mut count = 0u32; - while i < total && count < limit { - page.push_back(all.get(i).unwrap()); - i += 1; - count += 1; - } - - Ok(ListWhitelistedResponse { users: page, total }) - } - - /// Admin: batch add multiple `users` to the whitelist. Capped at 50 addresses per call. Admin must authorise. - pub fn batch_add_to_whitelist(env: Env, users: Vec
) -> Result<(), PoolError> { - require_initialized(&env)?; - get_admin(&env)?.require_auth(); - if users.len() > 50 { - return Err(PoolError::BatchTooLarge); - } - bump_instance(&env); - - let mut list = get_whitelisted_users_list(&env); - for user in users.iter() { - let key = DataKey::Whitelisted(user.clone()); - env.storage().persistent().set(&key, &true); - bump_user(&env, &key); - - if !list.contains(&user) { - list.push_back(user); - } - } - set_whitelisted_users_list(&env, &list); - Ok(()) - } - - /// Admin: batch remove multiple `users` from the whitelist. Capped at 50 addresses per call. Admin must authorise. - /// - /// Mirrors `batch_add_to_whitelist` so admins who need to revoke many - /// users do not have to issue one `remove_from_whitelist` call per user, - /// which is gas-inefficient. See #167. - pub fn batch_remove_from_whitelist(env: Env, users: Vec
) -> Result<(), PoolError> { - require_initialized(&env)?; - get_admin(&env)?.require_auth(); - if users.len() > 50 { - return Err(PoolError::BatchTooLarge); - } - bump_instance(&env); - - let mut list = get_whitelisted_users_list(&env); - for user in users.iter() { - let key = DataKey::Whitelisted(user.clone()); - env.storage().persistent().remove(&key); - - let mut new_list: Vec
= Vec::new(&env); - for u in list.iter() { - if u != user { - new_list.push_back(u); - } - } - list = new_list; - } - set_whitelisted_users_list(&env, &list); - Ok(()) - } - - // ── Boost / Stake system ───────────────────────────────────────────────── - - /// Stake `amount` tokens. If a prior stake exists, earned credits are checkpointed first. - /// - /// # No minimum lock period — deliberately - /// Unlike `lock_assets`, this flexible stake system has **no** enforced lock - /// period and `unstake` can be called immediately. This is an intentional - /// design property of the boost/stake system (see `docs/staking_systems.md`), - /// not an oversight: it is a separate continuous-staking model built for - /// flexible deposits where a lock would defeat its purpose. - /// - /// The "flash-staking" concern raised in #169 (stake and immediately - /// unstake) exposes no privilege, because: - /// - A stake is **not** a loan of leverage: the pool never owes more than - /// the exact staked amount, and `unstake` returns only `stake.amount`. - /// - Credits accrue linearly over *elapsed ledgers* (see `compute_stake_accrual`), - /// so an immediate round-trip banks ~0 credits; there is no fixed - /// up-front reward to harvest. - /// - On stake, the position is checkpointed to the current ledger and - /// credit rate; on unstake it is checkpointed again, so any intermediate - /// ledger gap is the only thing ever rewarded. - /// - /// Pools that require a commitment lock should use the lock/unlock - /// `Position` system (`lock_assets`/`unlock_assets`) instead. - pub fn stake(env: Env, from: Address, amount: i128) -> Result<(), PoolError> { - from.require_auth(); - require_initialized(&env)?; - require_staking_not_paused(&env)?; - if amount <= 0 { - return Err(PoolError::InvalidAmount); - } - if amount > MAX_STAKE_AMOUNT { - return Err(PoolError::ExceedsMaxStake); - } - - if whitelist_enabled(&env) && !is_user_whitelisted(&env, &from) { - return Err(PoolError::NotWhitelisted); - } - - // Fetch the caller's stake record once, and validate the position this - // call will write — not just the delta. Matches `lock_assets`: the - // minimum and cap apply to the caller's *resulting* total stake, and - // overflow returns `ExceedsMaxStake` instead of trapping. That single - // read is reused for the write below, so the value that passed - // validation is exactly the value that gets persisted: no second - // storage read (and no re-derived total) sits between the checks and - // the effects. See #448, #449, #457. - let mut existing_stake = get_user_stake(&env, &from); - let total_amount = existing_stake - .as_ref() - .map_or(0i128, |stake| stake.amount) - .checked_add(amount) - .ok_or(PoolError::ExceedsMaxStake)?; - let min_stake = Self::get_min_stake_amount(env.clone())?; - if total_amount < min_stake { - return Err(PoolError::BelowMinimumStake); - } - if total_amount > MAX_STAKE_AMOUNT { - return Err(PoolError::ExceedsMaxStake); - } - - // Reject before any write if the pool-wide deposit total would overflow; - // `add_total_deposits` would otherwise trap via `expect`. See #452. - read_total_deposits(&env) - .checked_add(amount) - .ok_or(PoolError::ExceedsMaxStake)?; - - // Reject before any write if the pool-wide total staked would overflow; - // `add_total_staked` would otherwise trap via `expect`. See #484, #485. - read_total_staked(&env) - .checked_add(amount) - .ok_or(PoolError::ExceedsMaxStake)?; - - // Resolve the stake token before checkpointing or writing any state, so - // a missing token config fails without touching the position. See #450, #451. - let stake_token = get_stake_token(&env)?; - let token_client = token::TokenClient::new(&env, &stake_token); - // Validate funding before mutating the position. Token transfers trap - // when the caller is underfunded, but checking explicitly keeps the - // contract API deterministic and avoids relying on token-specific trap - // messages for a normal validation failure. A configured `stake_token` - // that cannot answer `balance` at all — a misconfigured address, or a - // contract without a token interface — is likewise reported as a typed - // error instead of a host failure. See #453, #460, #459. - // - // Every local check above now runs before this first cross-contract - // call: `balance` executes the stake token's own code, so it must not - // run while a pool invariant could still reject the stake. See #456. - let caller_balance = match token_client.try_balance(&from) { - Ok(Ok(balance)) => balance, - _ => return Err(PoolError::InvalidStakeToken), - }; - if caller_balance < amount { - return Err(PoolError::InsufficientBalance); - } - - bump_instance(&env); - - let is_first_stake = existing_stake.is_none(); - let was_staked = is_user_staked(&env, &from); - let current = env.ledger().sequence(); - let mut new_stake = if let Some(mut existing) = existing_stake.take() { - checkpoint(&env, &from, &mut existing); - existing.amount = total_amount; - existing - } else { - UserStake { - amount, - start_ledger: current, - credits_banked: 0, - credit_rate: read_credit_rate(&env), - multiplier: read_global_multiplier(&env), - } - }; - - new_stake.credit_rate = read_credit_rate(&env); - - // Checks-effects-interactions: persist state *before* the external - // token transfer below, consistent with `lock_assets`. See #69, #217. - set_user_stake(&env, &from, &new_stake); - if is_first_stake { - increment_active_stake_count(&env); - } - if !was_staked && is_user_staked(&env, &from) { - increment_staked_user_count(&env); - } - add_total_staked(&env, amount); - add_total_deposits(&env, amount); - - // Pull tokens from caller into the contract, then verify the pool - // actually received the full amount before the stake is left standing. - // The position, `total_staked` and `total_deposits` all credit `amount`, - // so a stake token that skims a fee (fee-on-transfer) or otherwise - // under-delivers would leave the pool short of its own liabilities and - // strand later withdrawers. Rejecting here reverts the whole - // invocation, the transfer included. See #458. - let pool_before = token_client.balance(&env.current_contract_address()); - token_client.transfer(&from, env.current_contract_address(), &amount); - let pool_after = token_client.balance(&env.current_contract_address()); - if pool_after.checked_sub(pool_before).unwrap_or(0) < amount { - return Err(PoolError::TransferAmountMismatch); - } - - env.events().publish( - (symbol_short!("pool"), symbol_short!("staked")), - (from.clone(), amount), - ); - - record_stake_event(&env, &from, symbol_short!("stake"), amount); - record_user_activity(&env, &from); - increment_user_activity_count(&env, &from); - add_user_tier_points(&env, &from, amount / 1000000); // Award tier points based on staked amount - - Ok(()) - } - - /// Withdraw the caller's entire flexible stake and bank the accrued credits. - /// - /// There is no minimum lock period (see `stake`); the caller may withdraw at - /// any time. Because credits accrue only over elapsed ledgers (#169), an - /// immediate stake→unstake round-trip earns no credits, so the lack of a - /// lock does not create a flash-staking reward. - pub fn unstake(env: Env, from: Address, amount: i128) -> Result { - from.require_auth(); - require_initialized(&env)?; - require_withdrawals_not_paused(&env)?; - bump_instance(&env); - - if amount <= 0 { - return Err(PoolError::InvalidAmount); - } - - let was_staked = is_user_staked(&env, &from); - let mut stake = get_user_stake(&env, &from).ok_or(PoolError::NoActiveStake)?; - if amount > stake.amount { - return Err(PoolError::InsufficientBalance); - } - - checkpoint(&env, &from, &mut stake); - let total_credits = stake.credits_banked; - stake.amount -= amount; - - // Checks-effects-interactions: clear state *before* the external - // token transfer below so a reentrant token cannot re-enter and - // double-withdraw. On transfer failure the whole invocation - // reverts, rolling these writes back. See #279. - if stake.amount == 0 { - if total_credits > 0 { - subtract_total_banked_credits(&env, total_credits); - } - remove_user_stake(&env, &from); - decrement_active_stake_count(&env); - if was_staked && !is_user_staked(&env, &from) { - decrement_staked_user_count(&env); - } - } else { - set_user_stake(&env, &from, &stake); - } - - increment_unstake_count(&env); - subtract_total_staked(&env, amount); - add_total_withdrawals(&env, amount); - - // Return staked tokens to caller. - let stake_token = get_stake_token(&env)?; - token::TokenClient::new(&env, &stake_token).transfer( - &env.current_contract_address(), - &from, - &amount, - ); - - env.events().publish( - (symbol_short!("pool"), symbol_short!("unstaked")), - (from.clone(), amount, total_credits), - ); - - record_stake_event(&env, &from, symbol_short!("unstake"), amount); - record_user_activity(&env, &from); - increment_user_activity_count(&env, &from); - - Ok(total_credits) - } - - /// Withdraw accrued credits from the caller's flexible stake without unstaking. - /// - /// Claims and resets `credits_banked` to 0 while keeping the staked amount intact. - /// Emits a `crd_wdr` event with `(user, credits)`. - pub fn withdraw_credits(env: Env, user: Address) -> Result { - user.require_auth(); - require_initialized(&env)?; - require_withdrawals_not_paused(&env)?; - bump_instance(&env); - - let mut stake = get_user_stake(&env, &user).ok_or(PoolError::NoActiveStake)?; - checkpoint(&env, &user, &mut stake); - let credits = stake.credits_banked; - if credits > 0 { - subtract_total_banked_credits(&env, credits); - } - stake.credits_banked = 0; - set_user_stake(&env, &user, &stake); - - #[allow(deprecated)] - env.events().publish( - (symbol_short!("pool"), symbol_short!("crd_wdr")), - (user.clone(), credits), - ); - - Ok(credits) - } - - /// Admin sets a user's boost allocation (0-100%). Requires an active - /// flexible `UserStake` for `user` (#285): boosting an address with no - /// stake is rejected with `NoActiveStake` instead of storing an inert - /// allocation. A time-locked `Position` alone does not satisfy this — - /// boost only accrues via the `UserStake` path. Set to 0 to clear boost. - pub fn set_boost(env: Env, user: Address, allocation_pct: u32) -> Result<(), PoolError> { - require_initialized(&env)?; - require_staking_not_paused(&env)?; - get_admin(&env)?.require_auth(); - if allocation_pct == 0 || allocation_pct > 100 { - return Err(PoolError::InvalidAllocation); - } - bump_instance(&env); - - let mut stake = get_user_stake(&env, &user).ok_or(PoolError::NoActiveStake)?; - checkpoint(&env, &user, &mut stake); - set_user_stake(&env, &user, &stake); - - let old_alloc: u32 = get_user_boost(&env, &user).unwrap_or(0); - if allocation_pct == 0 { - if old_alloc > 0 { - decrement_boost_user_count(&env); - add_total_boost_allocation(&env, -(old_alloc as i64)); - let key = DataKey::UserBoost(user.clone()); - env.storage().persistent().remove(&key); - } - } else if old_alloc == 0 { - increment_boost_user_count(&env); - add_total_boost_allocation(&env, allocation_pct as i64); - let key = DataKey::UserBoost(user.clone()); - if !env.storage().persistent().has(&key) { - increment_boost_count(&env); - } - env.storage().persistent().set(&key, &allocation_pct); - bump_user(&env, &key); - } else { - let delta = allocation_pct as i64 - old_alloc as i64; - if delta != 0 { - add_total_boost_allocation(&env, delta); - } - let key = DataKey::UserBoost(user.clone()); - env.storage().persistent().set(&key, &allocation_pct); - bump_user(&env, &key); - } - - let multiplier = read_global_multiplier(&env); - env.events().publish( - (symbol_short!("boost"), symbol_short!("applied")), - (user.clone(), old_alloc, allocation_pct, multiplier), - ); - - record_boost_event(&env, &user, old_alloc, allocation_pct); - - Ok(()) - } - - pub fn get_boost_config(env: Env, user: Address) -> Result, PoolError> { - require_initialized(&env)?; - bump_instance(&env); - let multiplier = match get_user_stake(&env, &user) { - Some(stake) => stake.multiplier, - None => read_global_multiplier(&env), - }; - Ok(Some(BoostConfig { - multiplier, - allocation_pct: get_user_boost(&env, &user).unwrap_or(0), - })) - } - - pub fn total_credits(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(read_total_credits(&env)) - } - - /// Set the global credit multiplier. Rejects 0 and anything above - /// `MAX_GLOBAL_MULTIPLIER` — see #89 for the overflow-safety derivation. - pub fn set_global_multiplier(env: Env, multiplier: u32) -> Result<(), PoolError> { - require_initialized(&env)?; - let admin = get_admin(&env)?; - admin.require_auth(); - if !(1..=MAX_GLOBAL_MULTIPLIER).contains(&multiplier) { - return Err(PoolError::InvalidGlobalMultiplier); - } - bump_instance(&env); - - // Capture the previous value before overwriting it so the event can - // carry both terms — off-chain indexers need the old multiplier for - // audit trails and rollback scenarios (#250). - let old_multiplier = read_global_multiplier(&env); - - env.storage() - .instance() - .set(&DataKey::GlobalMultiplier, &multiplier); - env.storage().instance().set( - &DataKey::GlobalMultiplierChangeLedger, - &env.ledger().sequence(), - ); - record_global_multiplier_change(&env, old_multiplier, multiplier); - env.events().publish( - (symbol_short!("boost"), symbol_short!("mult_set")), - (old_multiplier, multiplier), - ); - - record_admin_action(&env, symbol_short!("mult"), &admin); - - Ok(()) - } - - /// Set the credit accrual rate. Rejects non-positive values and anything - /// above `MAX_CREDIT_RATE` — see #89 for the overflow-safety derivation. - /// - /// The new rate takes effect immediately for *new* checkpoints. Existing - /// staked or locked users retain their previous rate snapshot until they - /// interact (e.g. `stake`/`unstake` or `lock_assets`/`unlock_assets`), - /// at which point `checkpoint` records the new rate. This is by design: - /// iterating all on-chain user entries would be prohibitively expensive. - /// Off-chain indexers should apply the rate from the `rate_set` event - /// when computing credits for users who have not yet checkpointed. - pub fn set_credit_rate(env: Env, new_rate: i128) -> Result<(), PoolError> { - require_initialized(&env)?; - let admin = get_admin(&env)?; - admin.require_auth(); - if new_rate <= 0 || new_rate > MAX_CREDIT_RATE { - return Err(PoolError::InvalidCreditRate); - } - bump_instance(&env); - - let old_rate = read_credit_rate(&env); - env.storage() - .instance() - .set(&DataKey::CreditRate, &new_rate); - increment_credit_rate_change_count(&env); - record_credit_rate_change(&env, old_rate, new_rate); - env.events().publish( - (symbol_short!("pool"), symbol_short!("rate_set")), - (old_rate, new_rate, env.ledger().sequence()), - ); - - record_admin_action(&env, symbol_short!("cr_rate"), &admin); - - Ok(()) - } - - pub fn set_min_lock_period(env: Env, new_period: u32) -> Result<(), PoolError> { - require_initialized(&env)?; - let admin = get_admin(&env)?; - admin.require_auth(); - if new_period > MAX_LOCK_PERIOD { - return Err(PoolError::InvalidLockPeriod); - } - bump_instance(&env); - - let old_period = read_min_lock_period(&env); - let current_ledger = env.ledger().sequence(); - - if new_period > old_period { - let locked_users = get_locked_users_list(&env); - for user in locked_users.iter() { - if let Some(position) = get_position(&env, &user) { - let required_unlock = position.lock_ledger.saturating_add(new_period); - if position.unlock_ledger < required_unlock && current_ledger < required_unlock { - return Err(PoolError::NoActivePosition); - } - } - } - } - - env.storage() - .instance() - .set(&DataKey::MinLockPeriod, &new_period); - env.events().publish( - (symbol_short!("pool"), symbol_short!("lock_set")), - (old_period, new_period), - ); - - record_admin_action(&env, symbol_short!("lk_per"), &admin); - - Ok(()) - } - - pub fn credit_rate(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(read_credit_rate(&env)) - } - - pub fn get_credit_rate(env: Env) -> Result { - Self::credit_rate(env) - } - - pub fn min_lock_period(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(read_min_lock_period(&env)) - } - - pub fn get_min_lock_period(env: Env) -> Result { - Self::min_lock_period(env) - } - - /// Return the minimum lock period in seconds (assuming ~5s/ledger). - /// - /// The raw ledger count in `min_lock_period` is an implementation detail - /// that frontends and users cannot meaningfully display. This helper - /// converts it to a human-readable duration so UIs can show days/hours - /// directly. See #166. - pub fn min_lock_period_seconds(env: Env) -> Result { - let ledgers = Self::min_lock_period(env)?; - Ok((ledgers as u64) * 5) - } - - pub fn get_min_lock_period_seconds(env: Env) -> Result { - Self::min_lock_period_seconds(env) - } - - /// Return current accrued credits specifically for a user's `UserStake` (boost/continuous staking system). - /// - /// Includes both live accrual for the active stake and banked stake credits from previous withdrawals. - pub fn get_stake_credits(env: Env, user: Address) -> Result { - require_initialized(&env)?; - bump_instance(&env); - let banked = Self::get_banked_credits_split(env.clone(), user.clone())?; - - let stake_credits = get_user_stake(&env, &user) - .map(|stake| { - stake.credits_banked - + compute_stake_accrual(&env, &user, &stake, env.ledger().sequence()) - }) - .unwrap_or(0); - - Ok(banked.stake_credits + stake_credits) - } - - /// Return total combined accrued credits for `user` across all staking systems. - /// - /// Merges credits from time-locked `Position` staking, flexible `UserStake` boost staking, - /// and prior `BankedCredits`. To query individual systems, see `get_position_credits` - /// (`calculate_credits`) and `get_stake_credits`. - pub fn get_credits(env: Env, user: Address) -> Result { - require_initialized(&env)?; - bump_instance(&env); - let banked = Self::get_banked_credits_split(env.clone(), user.clone())?; - - let position_credits = get_position(&env, &user) - .map(|position| { - let elapsed = env - .ledger() - .sequence() - .saturating_sub(position.checkpoint_ledger); - let accrued = compute_credits(position.amount, 0, 1, position.credit_rate, elapsed); - position.total_credits + accrued - }) - .unwrap_or(0); - - let stake_credits = get_user_stake(&env, &user) - .map(|stake| { - stake.credits_banked - + compute_stake_accrual(&env, &user, &stake, env.ledger().sequence()) - }) - .unwrap_or(0); - - Ok(banked.position_credits + banked.stake_credits + position_credits + stake_credits) - } - - pub fn set_min_stake_amount(env: Env, amount: i128) -> Result<(), PoolError> { - require_initialized(&env)?; - let admin = get_admin(&env)?; - admin.require_auth(); - bump_instance(&env); - - if amount <= 0 || amount > MAX_STAKE_AMOUNT { - return Err(PoolError::InvalidMinStakeAmount); - } - - let old_amount = Self::get_min_stake_amount(env.clone())?; - env.storage() - .instance() - .set(&DataKey::MinStakeAmount, &amount); - env.events().publish( - (symbol_short!("pool"), symbol_short!("minst_set")), - (old_amount, amount), - ); - - record_admin_action(&env, symbol_short!("min_stk"), &admin); - - Ok(()) - } - /// Return the current min stake amount , or `None` if not staked. - pub fn get_min_stake_amount(env: Env) -> Result { - require_initialized(&env)?; - let min_stake = env - .storage() - .instance() - .get::(&DataKey::MinStakeAmount) - .unwrap_or(1); - - Ok(min_stake) - } - - /// Return the current stake record for `user`, or `None` if not staked (#234). - /// - /// `credits_banked` in the returned record is not a stale checkpoint: it is - /// computed on the fly by adding accrual since `start_ledger` up to the - /// current ledger, the same way `get_credits`/`get_stake_credits` do for - /// this staking system. The returned `start_ledger`, `credit_rate`, and - /// `multiplier` reflect this fresh checkpoint too, but — unlike `stake`, - /// `unstake`, and `set_boost` — none of this is persisted; the on-chain - /// record is left untouched by this read-only call. - /// - /// This total covers only the flexible/boost staking system (`UserStake`). - /// A user who also holds a locked `Position`, or who has credits carried - /// over from a prior `emergency_withdraw`, has additional balances not - /// reflected here — use `get_credits` for the fully merged total across - /// all systems. - pub fn get_stake(env: Env, user: Address) -> Result, PoolError> { - require_initialized(&env)?; - bump_instance(&env); - let Some(mut stake) = get_user_stake(&env, &user) else { - return Ok(None); - }; - let current = env.ledger().sequence(); - stake.credits_banked += compute_stake_accrual(&env, &user, &stake, current); - stake.start_ledger = current; - stake.credit_rate = read_credit_rate(&env); - stake.multiplier = read_global_multiplier(&env); - Ok(Some(stake)) - } - - /// Ledger at which `user`'s continuous-stake credits were last - /// checkpointed, or `None` if the user has no active stake (#255). - /// - /// `checkpoint` resets `UserStake.start_ledger` to the current ledger on - /// every `stake` / `unstake` / `set_boost`, so this is the origin the - /// user's next accrual is measured from. - pub fn last_checkpoint_ledger(env: Env, user: Address) -> Option { - bump_instance(&env); - get_user_stake(&env, &user).map(|stake| stake.start_ledger) - } - - pub fn total_staked(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(read_total_staked(&env)) - } - - /// Return the cumulative number of credits distributed to all users since - /// the pool was initialized. - /// - /// The counter grows as credits are committed (banked) for users — at each - /// `checkpoint`/`checkpoint_position` that occurs on stake, lock, boost, - /// unlock, and unstake operations — rather than on every read-only - /// accrual view. It therefore always reflects the sum a protocol-wide - /// `get_credits` aggregation converges to as users interact, and is the - /// companion of `total_staked` for reward-rate and inflation analytics. - pub fn total_distributed_credits(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(env - .storage() - .instance() - .get(&DataKey::TotalDistributedCredits) - .unwrap_or(0)) - } - - /// Alias for `total_distributed_credits` for consistency with other getter functions. - pub fn get_total_distributed_credits(env: Env) -> Result { - Self::total_distributed_credits(env) - } - - /// Return the total credits earned across users and staking systems. - /// - /// Accrual is included when a user's stake or position is checkpointed; - /// uncheckpointed accrual remains available through the per-user queries. - pub fn get_total_earned(env: Env) -> Result { - Self::total_distributed_credits(env) - } - - /// Aggregate pool overview in a single invocation (Issue #395). - /// - /// A pool dashboard previously needed up to six separate reads - /// (`total_staked`, `credit_rate`, the global multiplier, the min lock - /// period, the min stake amount and the paused flag), each paying its own - /// invocation cost and TTL bump. This returns the same values together, - /// read straight from instance storage. - /// - /// Note on the issue's field list: it mentions "total credits" and - /// "number of stakers". Credits are covered by the two maintained - /// counters below. Staker count is not, because the pool keeps no - /// staker-count entry — it is derived by paging `get_positions`, so - /// inventing a field here would report a number nothing maintains. - /// - /// Returns `NotInitialized` if the pool has not been initialized. - pub fn get_pool_info(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - - Ok(PoolInfo { - total_staked: env - .storage() - .instance() - .get(&DataKey::TotalStaked) - .unwrap_or(0), - total_banked_credits: read_total_banked_credits(&env), - total_distributed_credits: env - .storage() - .instance() - .get(&DataKey::TotalDistributedCredits) - .unwrap_or(0), - credit_rate: read_credit_rate(&env), - global_multiplier: read_global_multiplier(&env), - min_lock_period: read_min_lock_period(&env), - min_stake_amount: env - .storage() - .instance() - .get(&DataKey::MinStakeAmount) - .unwrap_or(1), - is_paused: pool_is_paused(&env), - }) - } - - /// Return a consolidated overview of `user`'s account state across all pool features (#561). - /// - /// Aggregates flexible stake balance, time-locked position amount, cumulative accrued credits, - /// boost allocation percentage, whitelist authorization, and unlock ledger in a single call. - pub fn get_user_summary(env: Env, user: Address) -> Result { - require_initialized(&env)?; - bump_instance(&env); - - let staked_amount = get_user_stake(&env, &user) - .map(|s| s.amount) - .unwrap_or(0); - - let (locked_amount, unlock_ledger) = get_position(&env, &user) - .map(|p| (p.amount, p.unlock_ledger)) - .unwrap_or((0, 0)); - - let total_credits = Self::get_credits(env.clone(), user.clone())?; - let boost_allocation = get_user_boost(&env, &user).unwrap_or(0); - let is_whitelisted = is_user_whitelisted(&env, &user); - - Ok(UserSummary { - staked_amount, - locked_amount, - total_credits, - boost_allocation, - is_whitelisted, - unlock_ledger, - }) - } - - /// Return the total credits currently banked across all users. - pub fn total_banked_credits(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(read_total_banked_credits(&env)) - } - - /// Return the cumulative credits earned by `user` across their lifetime, - /// including amounts already withdrawn. - pub fn total_credits_earned(env: Env, user: Address) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(read_total_credits_earned(&env, &user)) - } - - /// Return the running total of all tokens deposited into the pool. - /// - /// Incremented by `stake` and `lock_assets` with the amount transferred in. - /// Tracks cumulative inflow for protocol flow analytics; compare with - /// `total_withdrawals` to derive net flow and with `total_staked` to - /// reconcile current TVL against historical turnover. - pub fn total_deposits(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(read_total_deposits(&env)) - } - - /// Return the running total of all tokens withdrawn from the pool. - /// - /// Incremented by `unstake`, `unlock_assets`, and `emergency_withdraw` - /// with the amount transferred out. Tracks cumulative outflow for - /// protocol flow analytics; compare with `total_deposits` to derive - /// net flow. - pub fn total_withdrawals(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(read_total_withdrawals(&env)) - } - - /// Return the count of currently staked unique users in the pool. - pub fn staked_user_count(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(env - .storage() - .instance() - .get(&DataKey::StakedUserCount) - .unwrap_or(0)) - } - - pub fn get_staked_user_count(env: Env) -> Result { - Self::staked_user_count(env) - } - - /// Return the total number of lock operations performed on the pool. - pub fn lock_count(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(read_lock_count(&env)) - } - - pub fn get_lock_count(env: Env) -> Result { - Self::lock_count(env) - } - - /// Return the total number of unstake operations performed on the pool. - pub fn unstake_count(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(read_unstake_count(&env)) - } - - pub fn get_unstake_count(env: Env) -> Result { - Self::unstake_count(env) - } - - /// Return the total number of boost configurations configured across users (#230). - pub fn boost_count(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(read_boost_count(&env)) - } - - pub fn get_boost_count(env: Env) -> Result { - Self::boost_count(env) - } - - /// Return the total tokens locked across all position locking positions (#232). - pub fn total_locked(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(env - .storage() - .instance() - .get(&DataKey::TotalLocked) - .unwrap_or(0)) - } - - pub fn get_total_locked(env: Env) -> Result { - Self::total_locked(env) - } - - /// Return the count of currently active stakes in the pool. - pub fn active_stake_count(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(read_active_stake_count(&env)) - } - - pub fn get_active_stake_count(env: Env) -> Result { - Self::active_stake_count(env) - } - - /// Return the total number of credit rate changes performed on the pool. - pub fn credit_rate_change_count(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(read_credit_rate_change_count(&env)) - } - - pub fn get_credit_rate_change_count(env: Env) -> Result { - Self::credit_rate_change_count(env) - } - - /// Return the number of addresses currently on the whitelist (#248). - /// - /// Admins use this for capacity planning without paging the full list via - /// `get_whitelisted_users`. The value is derived from the canonical - /// `WhitelistedUsers` list that every add / remove / batch path already - /// maintains (and dedupes), rather than a parallel counter that could - /// silently drift out of step with that list. - pub fn whitelist_count(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(get_whitelisted_users_list(&env).len()) - } - - pub fn get_whitelist_count(env: Env) -> Result { - Self::whitelist_count(env) - } - - /// Return a zero-based page of distinct users with an active stake or locked position. - pub fn get_stakers(env: Env, start: u32, limit: u32) -> Result, PoolError> { - require_initialized(&env)?; - bump_instance(&env); - - let mut all = get_staked_users_list(&env); - for user in get_locked_users_list(&env).iter() { - if !all.contains(&user) { - all.push_back(user); - } - } - - let mut page = Vec::new(&env); - let mut index = start; - let mut count = 0u32; - while index < all.len() && count < limit { - page.push_back(all.get(index).unwrap()); - index += 1; - count += 1; - } - - Ok(page) - } - - /// Return a paginated list of all users with active stakes. - /// - /// `offset`: zero-based index of the first user to return. - /// `limit`: maximum number of users to return per call. - /// - /// Returns addresses and total count. Call repeatedly with increasing `offset` - /// until `offset >= total` to retrieve the full list. - pub fn get_staked_users( - env: Env, - offset: u32, - limit: u32, - ) -> Result { - require_initialized(&env)?; - bump_instance(&env); - - let all = get_staked_users_list(&env); - let total = all.len(); - let mut page: Vec
= Vec::new(&env); - let mut i = offset; - let mut count = 0u32; - while i < total && count < limit { - page.push_back(all.get(i).unwrap()); - i += 1; - count += 1; - } - - Ok(ListWhitelistedResponse { users: page, total }) - } - - /// Return a paginated list of all users with active locked positions. - /// - /// `offset`: zero-based index of the first user to return. - /// `limit`: maximum number of users to return per call. - /// - /// Returns addresses and total count. Call repeatedly with increasing `offset` - /// until `offset >= total` to retrieve the full list. - pub fn get_locked_users( - env: Env, - offset: u32, - limit: u32, - ) -> Result { - require_initialized(&env)?; - bump_instance(&env); - - let all = get_locked_users_list(&env); - let total = all.len(); - let mut page: Vec
= Vec::new(&env); - let mut i = offset; - let mut count = 0u32; - while i < total && count < limit { - page.push_back(all.get(i).unwrap()); - i += 1; - count += 1; - } - - Ok(ListWhitelistedResponse { users: page, total }) - } - - /// Return a paginated list of all time-locked positions. - /// - /// `start`: zero-based index of the first position to return. - /// `limit`: maximum number of positions to return per call. - /// - /// Returns a vector of `(Address, Position)` tuples. Call repeatedly with - /// increasing `start` until the returned vector is empty or has fewer than - /// `limit` elements to retrieve all positions. - /// - /// This is useful for TVL calculations, analytics, and protocol health - /// monitoring where off-chain systems need to iterate all stakers. - pub fn get_all_positions( - env: Env, - start: u32, - limit: u32, - ) -> Result, PoolError> { - require_initialized(&env)?; - bump_instance(&env); - - let locked_users = get_locked_users_list(&env); - let total = locked_users.len(); - let mut positions: Vec<(Address, Position)> = Vec::new(&env); - let mut i = start; - let mut count = 0u32; - while i < total && count < limit { - let user = locked_users.get(i).unwrap(); - if let Some(position) = get_position(&env, &user) { - positions.push_back((user, position)); - } - i += 1; - count += 1; - } - - Ok(positions) - } - - // ── History / audit trail query functions ──────────────────────────────── - - /// Return a paginated list of whitelist history events. - /// - /// `offset`: zero-based index of the first event to return. - /// `limit`: maximum number of events to return per call. - pub fn get_whitelist_history( - env: Env, - offset: u32, - limit: u32, - ) -> Result { - require_initialized(&env)?; - bump_instance(&env); - - let total: u32 = env - .storage() - .instance() - .get(&DataKey::WhitelistEventCount) - .unwrap_or(0); - let mut events: Vec = Vec::new(&env); - let mut i = offset; - let mut count = 0u32; - while i < total && count < limit { - if let Some(event) = env - .storage() - .persistent() - .get::<_, WhitelistEvent>(&DataKey::WhitelistHistory(i)) - { - events.push_back(event); - } - i += 1; - count += 1; - } - - Ok(WhitelistHistoryPage { events, total }) - } - - /// Return a paginated list of boost allocation history events. - /// - /// `offset`: zero-based index of the first event to return. - /// `limit`: maximum number of events to return per call. - pub fn get_boost_history( - env: Env, - offset: u32, - limit: u32, - ) -> Result { - require_initialized(&env)?; - bump_instance(&env); - - let total: u32 = env - .storage() - .instance() - .get(&DataKey::BoostEventCount) - .unwrap_or(0); - let mut events: Vec = Vec::new(&env); - let mut i = offset; - let mut count = 0u32; - while i < total && count < limit { - if let Some(event) = env - .storage() - .persistent() - .get::<_, BoostEvent>(&DataKey::BoostHistory(i)) - { - events.push_back(event); - } - i += 1; - count += 1; - } - - Ok(BoostHistoryPage { events, total }) - } - - /// Return a paginated list of stake/unstake/lock/unlock history events. - /// - /// `offset`: zero-based index of the first event to return. - /// `limit`: maximum number of events to return per call. - pub fn get_stake_history( - env: Env, - offset: u32, - limit: u32, - ) -> Result { - require_initialized(&env)?; - bump_instance(&env); - - let total: u32 = env - .storage() - .instance() - .get(&DataKey::StakeEventCount) - .unwrap_or(0); - let mut events: Vec = Vec::new(&env); - let mut i = offset; - let mut count = 0u32; - while i < total && count < limit { - if let Some(event) = env - .storage() - .persistent() - .get::<_, StakeEvent>(&DataKey::StakeHistory(i)) - { - events.push_back(event); - } - i += 1; - count += 1; - } - - Ok(StakeHistoryPage { events, total }) - } - - /// Return a paginated list of admin action history events. - /// - /// `offset`: zero-based index of the first event to return. - /// `limit`: maximum number of events to return per call. - pub fn get_admin_action_history( - env: Env, - offset: u32, - limit: u32, - ) -> Result { - require_initialized(&env)?; - bump_instance(&env); - - let total: u32 = env - .storage() - .instance() - .get(&DataKey::AdminActionCount) - .unwrap_or(0); - let mut events: Vec = Vec::new(&env); - let mut i = offset; - let mut count = 0u32; - while i < total && count < limit { - if let Some(event) = env - .storage() - .persistent() - .get::<_, AdminActionEvent>(&DataKey::AdminActionHistory(i)) - { - events.push_back(event); - } - i += 1; - count += 1; - } - - Ok(AdminActionHistoryPage { events, total }) - } - - /// #310 — Return a paginated audit trail of credit rate changes. - /// - /// Each entry records the previous rate, the new rate, and the ledger at - /// which the change was applied. Events are stored in chronological order; - /// pass `offset = 0` and advance by `limit` to walk the full history. - /// - /// `offset`: zero-based index of the first event to return. - /// `limit`: maximum number of events to return per call. - pub fn get_credit_rate_history( - env: Env, - offset: u32, - limit: u32, - ) -> Result { - require_initialized(&env)?; - bump_instance(&env); - - let total: u32 = env - .storage() - .instance() - .get(&DataKey::CreditRateEventCount) - .unwrap_or(0); - let mut events: Vec = Vec::new(&env); - let mut i = offset; - let mut count = 0u32; - while i < total && count < limit { - if let Some(event) = env - .storage() - .persistent() - .get::<_, CreditRateEvent>(&DataKey::CreditRateHistory(i)) - { - events.push_back(event); - } - i += 1; - count += 1; - } - - Ok(CreditRateHistoryPage { events, total }) - } - - /// #311 — Return a paginated audit trail of global multiplier changes. - /// - /// Each entry records the previous multiplier, the new multiplier, and the - /// ledger at which the change was applied. Events are stored in - /// chronological order; pass `offset = 0` and advance by `limit` to walk - /// the full history. - /// - /// `offset`: zero-based index of the first event to return. - /// `limit`: maximum number of events to return per call. - pub fn get_global_multiplier_history( - env: Env, - offset: u32, - limit: u32, - ) -> Result { - require_initialized(&env)?; - bump_instance(&env); - - let total: u32 = env - .storage() - .instance() - .get(&DataKey::GlobalMultiplierEventCount) - .unwrap_or(0); - let mut events: Vec = Vec::new(&env); - let mut i = offset; - let mut count = 0u32; - while i < total && count < limit { - if let Some(event) = env - .storage() - .persistent() - .get::<_, GlobalMultiplierEvent>(&DataKey::GlobalMultiplierHistory(i)) - { - events.push_back(event); - } - i += 1; - count += 1; - } - - Ok(GlobalMultiplierHistoryPage { events, total }) - } - - // ── Feature 1: User Activity Tracking (Issue #554) ────────────────────── - - /// Get the activity summary for a user including last interaction and activity count. - /// - /// Returns a `UserActivity` struct with the last ledger the user interacted with - /// the pool, total activity count, and the ledger range of their involvement. - pub fn get_user_activity(env: Env, user: Address) -> Result, PoolError> { - require_initialized(&env)?; - bump_instance(&env); - - if let Some(last_activity) = get_user_last_activity(&env, &user) { - let activity_count = read_user_activity_count(&env, &user); - let current_ledger = env.ledger().sequence(); - let activity_span = current_ledger.saturating_sub(last_activity); - - Ok(Some(UserActivity { - last_activity_ledger: last_activity, - activity_count, - activity_span, - })) - } else { - Ok(None) - } - } - - // ── Feature 2: Performance Tier System (Issue #555) ──────────────────── - - /// Get the user's current performance tier and tier points. - /// - /// Returns a `TierInfo` struct containing the user's tier level (0-3), - /// accumulated tier points, and the reward multiplier for that tier. - /// Tier levels: 0=bronze, 1=silver, 2=gold, 3=platinum. - pub fn get_user_tier_info(env: Env, user: Address) -> Result { - require_initialized(&env)?; - bump_instance(&env); - - let tier_points = get_user_tier_points(&env, &user); - let position_ledgers = get_position(&env, &user) - .map(|p| env.ledger().sequence().saturating_sub(p.lock_ledger)) - .unwrap_or(0); - - let tier_level = calculate_user_tier(tier_points, position_ledgers); - let reward_multiplier = match tier_level { - 3 => 2000, // platinum: 2.0x - 2 => 1500, // gold: 1.5x - 1 => 1250, // silver: 1.25x - _ => 1000, // bronze: 1.0x - }; - - Ok(TierInfo { - tier_level, - tier_points, - reward_multiplier, - }) - } - - /// Award tier points to a user (admin-only). - /// - /// Admin can award tier points to users for special achievements or milestones. - /// Points accumulate and determine the user's performance tier level. - pub fn award_tier_points(env: Env, user: Address, points: i128) -> Result<(), PoolError> { - require_initialized(&env)?; - let admin = get_admin(&env)?; - admin.require_auth(); - bump_instance(&env); - - if points <= 0 { - return Err(PoolError::InvalidAmount); - } - - add_user_tier_points(&env, &user, points); - - let total_points = get_user_tier_points(&env, &user); - let position_ledgers = get_position(&env, &user) - .map(|p| env.ledger().sequence().saturating_sub(p.lock_ledger)) - .unwrap_or(0); - let new_tier = calculate_user_tier(total_points, position_ledgers); - set_user_tier(&env, &user, new_tier); - - #[allow(deprecated)] - env.events().publish( - (symbol_short!("pool"), symbol_short!("tier_pts")), - (user, points, new_tier), - ); - - Ok(()) - } - - // ── Feature 3: Bulk Reward Distribution (Issue #556) ─────────────────── - - /// Distribute rewards to multiple users at once (admin-only). - /// - /// Allows admin to reward multiple users in a single call (up to 50 users per call). - /// Each user receives the specified amount of credits. Capped at 50 users per call - /// for gas efficiency. - pub fn distribute_bulk_rewards( - env: Env, - users: Vec
, - reward_per_user: i128, - ) -> Result<(), PoolError> { - require_initialized(&env)?; - let admin = get_admin(&env)?; - admin.require_auth(); - - if users.len() > 50 { - return Err(PoolError::BatchTooLarge); - } - if reward_per_user <= 0 { - return Err(PoolError::InvalidAmount); - } - - bump_instance(&env); - - let total_distributed = reward_per_user - .checked_mul(users.len() as i128) - .ok_or(PoolError::ExceedsMaxStake)?; - - for user in users.iter() { - add_total_credits(&env, reward_per_user); - add_total_banked_credits(&env, reward_per_user); - let key = DataKey::BankedCredits(user.clone()); - let mut totals: BankedCreditTotals = env - .storage() - .persistent() - .get(&key) - .unwrap_or(BankedCreditTotals { - position_credits: 0, - stake_credits: 0, - }); - totals.stake_credits += reward_per_user; - set_banked_credits(&env, &user, totals); - } - - add_total_distributed_rewards(&env, total_distributed); - record_distribution_event(&env, users.len() as u32, total_distributed, &admin); - - #[allow(deprecated)] - env.events().publish( - (symbol_short!("pool"), symbol_short!("bulk_rwd")), - (users.len() as u32, total_distributed), - ); - - Ok(()) - } - - /// Get the total rewards distributed through bulk operations. - pub fn get_total_distributed_rewards(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - Ok(env - .storage() - .instance() - .get(&DataKey::TotalDistributedRewards) - .unwrap_or(0)) - } - - // ── Feature 4: Extended Pool Analytics (Issue #557) ──────────────────── - - /// Get extended pool analytics including average lock duration and peak TVL. - /// - /// Returns a `PoolAnalytics` struct with metrics useful for protocol health monitoring: - /// average lock duration, peak total staked, unique user count, and TVL ratio. - pub fn get_pool_analytics(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - - let locked_users = get_locked_users_list(&env); - let total_lock_duration: u32 = locked_users - .iter() - .filter_map(|user| { - get_position(&env, &user) - .map(|p| env.ledger().sequence().saturating_sub(p.lock_ledger)) - }) - .fold(0u32, |acc, duration| acc.saturating_add(duration)); - - let average_lock_duration = if !locked_users.is_empty() { - total_lock_duration / locked_users.len() - } else { - 0 - }; - - let peak_total_staked: i128 = env - .storage() - .instance() - .get(&DataKey::PeakTotalStaked) - .unwrap_or(0); - - let unique_users = read_unique_user_count(&env); - let total_deposits = read_total_deposits(&env); - let total_staked = read_total_staked(&env); - - let tvl_ratio = if total_staked > 0 { - ((total_deposits as u64) * 1000) / (total_staked as u64) - } else { - 0 - }; - - Ok(PoolAnalytics { - average_lock_duration, - peak_total_staked, - unique_user_count: unique_users, - tvl_ratio, - }) - } - - // ── Feature improvements: #566, #567, #568, #569 ─────────────────────── - - /// Return a paginated audit trail of bulk reward distributions (Issue #566). - /// - /// Each entry records the number of users rewarded, the total amount - /// distributed, the admin who ran the distribution, and the ledger at which - /// it happened. Events are stored in chronological order; pass `offset = 0` - /// and advance by `limit` to walk the full history. - /// - /// `offset`: zero-based index of the first event to return. - /// `limit`: maximum number of events to return per call. - pub fn get_distribution_history( - env: Env, - offset: u32, - limit: u32, - ) -> Result { - require_initialized(&env)?; - bump_instance(&env); - - let total: u32 = env - .storage() - .instance() - .get(&DataKey::DistributionEventCount) - .unwrap_or(0); - let mut events: Vec = Vec::new(&env); - let mut i = offset; - let mut count = 0u32; - while i < total && count < limit { - if let Some(event) = env - .storage() - .persistent() - .get::<_, DistributionEvent>(&DataKey::DistributionHistory(i)) - { - events.push_back(event); - } - i += 1; - count += 1; - } - - Ok(DistributionHistoryPage { events, total }) - } - - /// Return the admin proposed by `propose_admin` that has not yet accepted, or `None` (Issue #567). - pub fn get_pending_admin(env: Env) -> Result, PoolError> { - require_initialized(&env)?; - bump_instance(&env); - - Ok(env - .storage() - .instance() - .get::<_, Address>(&DataKey::PendingAdmin)) - } - - /// Return whether whitelist mode is currently enabled (Issue #568). - pub fn is_whitelist_enabled(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - - Ok(whitelist_enabled(&env)) - } - - /// Return the peak total staked amount ever recorded, in stroops (Issue #569). - pub fn get_peak_total_staked(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - - Ok(env - .storage() - .instance() - .get(&DataKey::PeakTotalStaked) - .unwrap_or(0)) - } - - // ── Feature improvements: #570, #571, #572, #573 ─────────────────────── - - /// Return the sum of all users' boost allocations, in percent points (Issue #570). - pub fn get_total_boost_allocation(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - - Ok(read_total_boost_allocations(&env)) - } - - /// Return the number of users with a non-zero boost allocation (Issue #571). - pub fn get_boost_user_count(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - - Ok(read_boost_user_count(&env)) - } - - /// Return the mean boost allocation per boosted user, rounded down (Issue #572). - pub fn get_average_boost_allocation(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - - let boost_users = read_boost_user_count(&env); - if boost_users == 0 { - return Ok(0); - } - Ok(read_total_boost_allocations(&env) / boost_users as u64) - } - - /// Return the ledger of the last `set_global_multiplier` call, or 0 if unset (Issue #573). - pub fn get_multiplier_change_ledger(env: Env) -> Result { - require_initialized(&env)?; - bump_instance(&env); - - Ok(read_global_multiplier_change_ledger(&env)) - } -} - -mod test; diff --git a/soroban/contracts/farming-pool/src/test.rs b/soroban/contracts/farming-pool/src/test.rs deleted file mode 100644 index 57429f8..0000000 --- a/soroban/contracts/farming-pool/src/test.rs +++ /dev/null @@ -1,4735 +0,0 @@ -#![cfg(test)] -use super::*; -use soroban_sdk::{ - testutils::{Address as _, Events, Ledger, MockAuth, MockAuthInvoke}, - token::{StellarAssetClient, TokenClient}, - Address, BytesN, Env, IntoVal, Symbol, Val, -}; - -// ── Test helpers ────────────────────────────────────────────────────────────── - -struct TestEnv { - env: Env, - client: FarmingPoolClient<'static>, - contract_id: Address, - token: TokenClient<'static>, - token_sac: StellarAssetClient<'static>, - admin: Address, - user: Address, -} - -fn upload_upgrade_target_wasm(env: &Env) -> BytesN<32> { - env.deployer().upload_contract_wasm(ADD_I32_WASM) -} - -const ADD_I32_WASM: &[u8] = &[ - 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x10, 0x03, 0x60, 0x02, 0x7e, 0x7e, 0x01, - 0x7e, 0x60, 0x02, 0x7f, 0x7f, 0x01, 0x7e, 0x60, 0x00, 0x00, 0x02, 0x0d, 0x02, 0x01, 0x78, 0x01, - 0x31, 0x00, 0x00, 0x01, 0x76, 0x01, 0x67, 0x00, 0x00, 0x03, 0x04, 0x03, 0x00, 0x01, 0x02, 0x05, - 0x03, 0x01, 0x00, 0x10, 0x06, 0x19, 0x03, 0x7f, 0x01, 0x41, 0x80, 0x80, 0xc0, 0x00, 0x0b, 0x7f, - 0x00, 0x41, 0x80, 0x80, 0xc0, 0x00, 0x0b, 0x7f, 0x00, 0x41, 0x80, 0x80, 0xc0, 0x00, 0x0b, 0x07, - 0x2f, 0x05, 0x06, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x02, 0x00, 0x03, 0x61, 0x64, 0x64, 0x00, - 0x02, 0x01, 0x5f, 0x00, 0x04, 0x0a, 0x5f, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x5f, 0x65, 0x6e, 0x64, - 0x03, 0x01, 0x0b, 0x5f, 0x5f, 0x68, 0x65, 0x61, 0x70, 0x5f, 0x62, 0x61, 0x73, 0x65, 0x03, 0x02, - 0x0a, 0xe3, 0x01, 0x03, 0xc5, 0x01, 0x02, 0x04, 0x7f, 0x01, 0x7e, 0x23, 0x00, 0x41, 0x20, 0x6b, - 0x22, 0x03, 0x24, 0x00, 0x02, 0x40, 0x20, 0x00, 0x42, 0xff, 0x01, 0x83, 0x42, 0x05, 0x52, 0x20, - 0x01, 0x42, 0xff, 0x01, 0x83, 0x42, 0x05, 0x52, 0x72, 0x45, 0x04, 0x40, 0x20, 0x00, 0x42, 0x20, - 0x88, 0xa7, 0x21, 0x04, 0x20, 0x01, 0x42, 0x20, 0x88, 0xa7, 0x21, 0x05, 0x20, 0x03, 0x42, 0x8e, - 0xd2, 0xa9, 0x13, 0x37, 0x03, 0x08, 0x42, 0x02, 0x21, 0x06, 0x41, 0x01, 0x21, 0x02, 0x03, 0x40, - 0x20, 0x02, 0x04, 0x40, 0x20, 0x02, 0x41, 0x01, 0x6b, 0x21, 0x02, 0x42, 0x8e, 0xd2, 0xa9, 0x13, - 0x21, 0x06, 0x0c, 0x01, 0x0b, 0x0b, 0x20, 0x03, 0x20, 0x06, 0x37, 0x03, 0x10, 0x20, 0x03, 0x41, - 0x10, 0x6a, 0x22, 0x02, 0x41, 0x01, 0x10, 0x03, 0x20, 0x03, 0x20, 0x01, 0x42, 0x80, 0x80, 0x80, - 0x80, 0x70, 0x83, 0x42, 0x05, 0x84, 0x37, 0x03, 0x18, 0x20, 0x03, 0x20, 0x00, 0x42, 0x80, 0x80, - 0x80, 0x80, 0x70, 0x83, 0x42, 0x05, 0x84, 0x37, 0x03, 0x10, 0x20, 0x02, 0x41, 0x02, 0x10, 0x03, - 0x10, 0x00, 0x1a, 0x20, 0x05, 0x41, 0x00, 0x48, 0x20, 0x04, 0x20, 0x05, 0x6a, 0x22, 0x02, 0x20, - 0x04, 0x48, 0x47, 0x0d, 0x01, 0x20, 0x03, 0x41, 0x20, 0x6a, 0x24, 0x00, 0x20, 0x02, 0xad, 0x42, - 0x20, 0x86, 0x42, 0x05, 0x84, 0x0f, 0x0b, 0x00, 0x0b, 0x00, 0x0b, 0x16, 0x00, 0x20, 0x00, 0xad, - 0x42, 0x20, 0x86, 0x42, 0x04, 0x84, 0x20, 0x01, 0xad, 0x42, 0x20, 0x86, 0x42, 0x04, 0x84, 0x10, - 0x01, 0x0b, 0x03, 0x00, 0x01, 0x0b, 0x00, 0x4b, 0x0e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x61, 0x63, - 0x74, 0x73, 0x70, 0x65, 0x63, 0x76, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x01, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x01, 0x62, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x01, 0x00, - 0x00, 0x00, 0x05, 0x00, 0x1e, 0x11, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x61, 0x63, 0x74, 0x65, 0x6e, - 0x76, 0x6d, 0x65, 0x74, 0x61, 0x76, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x14, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x73, 0x0e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x61, 0x63, 0x74, 0x6d, 0x65, - 0x74, 0x61, 0x76, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x72, 0x73, 0x76, 0x65, - 0x72, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x31, 0x2e, 0x37, 0x34, 0x2e, 0x30, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x72, 0x73, 0x73, 0x64, 0x6b, 0x76, 0x65, 0x72, - 0x00, 0x00, 0x00, 0x33, 0x32, 0x30, 0x2e, 0x30, 0x2e, 0x30, 0x2d, 0x72, 0x63, 0x32, 0x23, 0x37, - 0x63, 0x31, 0x35, 0x34, 0x62, 0x34, 0x66, 0x65, 0x36, 0x61, 0x33, 0x37, 0x64, 0x37, 0x63, 0x61, - 0x37, 0x31, 0x37, 0x37, 0x33, 0x34, 0x32, 0x64, 0x65, 0x64, 0x62, 0x36, 0x39, 0x66, 0x33, 0x31, - 0x30, 0x38, 0x30, 0x39, 0x35, 0x65, 0x66, 0x00, -]; - -fn setup(global_multiplier: u32, credit_rate: i128) -> TestEnv { - setup_with_lock_period(global_multiplier, credit_rate, 0) -} - -fn setup_uninitialized() -> (Env, FarmingPoolClient<'static>, Address) { - let env = Env::default(); - env.mock_all_auths(); - let user = Address::generate(&env); - let contract_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &contract_id); - let client = unsafe { - core::mem::transmute::, FarmingPoolClient<'static>>(client) - }; - (env, client, user) -} - -fn setup_with_lock_period( - global_multiplier: u32, - credit_rate: i128, - min_lock_period: u32, -) -> TestEnv { - let env = Env::default(); - env.mock_all_auths(); - let admin = Address::generate(&env); - let user = Address::generate(&env); - - // Deploy a Stellar Asset Contract for the stake token. - let token_admin = Address::generate(&env); - let asset = env.register_stellar_asset_contract_v2(token_admin.clone()); - let token_sac = StellarAssetClient::new(&env, &asset.address()); - token_sac.mint(&user, &1_000_000_000i128); - - let contract_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &contract_id); - let min_stake_amount = 100; - client.initialize( - &admin, - &asset.address(), - &global_multiplier, - &credit_rate, - &min_lock_period, - &min_stake_amount, - ); - - let token = TokenClient::new(&env, &asset.address()); - - // Transmute lifetime to 'static so the struct can own client & token. - // SAFETY: env owns the contract and token registrations; they live as long as env. - let client = unsafe { - core::mem::transmute::, FarmingPoolClient<'static>>(client) - }; - let token = unsafe { core::mem::transmute::, TokenClient<'static>>(token) }; - let token_sac = unsafe { - core::mem::transmute::, StellarAssetClient<'static>>(token_sac) - }; - - TestEnv { - env, - client, - contract_id, - token, - token_sac, - admin, - user, - } -} - -fn setup_without_mocked_auth() -> (Env, Address, FarmingPoolClient<'static>, Address, Address) { - let env = Env::default(); - let admin = Address::generate(&env); - let user = Address::generate(&env); - - let token_admin = Address::generate(&env); - let asset = env.register_stellar_asset_contract_v2(token_admin); - - let contract_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &contract_id); - client.initialize(&admin, &asset.address(), &2u32, &1i128, &0u32, &1_i128); - - let client = unsafe { - core::mem::transmute::, FarmingPoolClient<'static>>(client) - }; - - (env, contract_id, client, admin, user) -} - -fn advance_ledgers(env: &Env, by: u32) { - let current = env.ledger().sequence(); - env.ledger().with_mut(|l| l.sequence_number = current + by); -} - -#[test] -fn test_stake_uninitialized_returns_not_initialized() { - let (_env, client, user) = setup_uninitialized(); - match client.try_stake(&user, &100i128) { - Err(Ok(PoolError::NotInitialized)) => {} - _ => panic!("expected PoolError::NotInitialized"), - } -} - -#[test] -fn test_stake_rejects_invalid_amounts() { - let t = setup(2, 1); - - assert_eq!( - t.client.try_stake(&t.user, &0i128), - Err(Ok(PoolError::InvalidAmount)) - ); - assert_eq!( - t.client.try_stake(&t.user, &-1i128), - Err(Ok(PoolError::InvalidAmount)) - ); - assert_eq!( - t.client.try_stake(&t.user, &1i128), - Err(Ok(PoolError::BelowMinimumStake)) - ); - assert_eq!( - t.client.try_stake(&t.user, &(MAX_STAKE_AMOUNT + 1)), - Err(Ok(PoolError::ExceedsMaxStake)) - ); - assert!(t.client.get_stake(&t.user).is_none()); -} - -#[test] -fn test_stake_rejects_when_caller_balance_is_insufficient() { - let t = setup(2, 1); - let balance = t.token.balance(&t.user); - - assert_eq!( - t.client.try_stake(&t.user, &(balance + 1)), - Err(Ok(PoolError::InsufficientBalance)) - ); - assert!(t.client.get_stake(&t.user).is_none()); - assert_eq!(t.token.balance(&t.user), balance); -} - -#[test] -fn test_stake_rejects_top_up_above_max_without_changing_stake() { - let t = setup(2, 1); - t.token_sac.mint(&t.user, &MAX_STAKE_AMOUNT); - t.client.stake(&t.user, &MAX_STAKE_AMOUNT); - let balance = t.token.balance(&t.user); - let start_ledger = t.client.get_stake(&t.user).unwrap().start_ledger; - - assert_eq!( - t.client.try_stake(&t.user, &100i128), - Err(Ok(PoolError::ExceedsMaxStake)) - ); - - let stake = t.client.get_stake(&t.user).unwrap(); - assert_eq!(stake.amount, MAX_STAKE_AMOUNT); - assert_eq!(stake.start_ledger, start_ledger); - assert_eq!(t.token.balance(&t.user), balance); -} - -#[test] -fn test_stake_rejects_overflowing_stored_amount_without_trapping() { - let t = setup(2, 1); - t.env.as_contract(&t.contract_id, || { - t.env.storage().persistent().set( - &DataKey::UserStake(t.user.clone()), - &UserStake { - amount: i128::MAX, - start_ledger: t.env.ledger().sequence(), - credits_banked: 0, - credit_rate: 1, - multiplier: 1, - }, - ); - }); - - assert_eq!( - t.client.try_stake(&t.user, &100i128), - Err(Ok(PoolError::ExceedsMaxStake)) - ); - let stored_amount = t.env.as_contract(&t.contract_id, || { - t.env - .storage() - .persistent() - .get::(&DataKey::UserStake(t.user.clone())) - .unwrap() - .amount - }); - assert_eq!(stored_amount, i128::MAX); - assert_eq!(t.token.balance(&t.user), 1_000_000_000); -} - -#[test] -fn test_stake_emits_event() { - let t = setup(2, 1); - let amount = 1_000i128; - - t.client.stake(&t.user, &amount); - - assert_eq!( - t.env.events().all().filter_by_contract(&t.contract_id), - soroban_sdk::vec![ - &t.env, - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("staked").into_val(&t.env) - ], - (t.user.clone(), amount).into_val(&t.env), - ) - ] - ); -} - -#[test] -fn test_stake_more_than_balance_returns_insufficient_balance_without_writing_state() { - let t = setup(1, 1); - let balance = t.token.balance(&t.user); - - match t.client.try_stake(&t.user, &(balance + 1)) { - Err(Ok(PoolError::InsufficientBalance)) => {} - _ => panic!("expected PoolError::InsufficientBalance"), - } - - assert!(t.client.get_stake(&t.user).is_none()); - assert_eq!(t.client.total_staked(), 0); - assert_eq!(t.client.total_deposits(), 0); - assert_eq!(t.token.balance(&t.user), balance); -} - -#[test] -fn test_stake_overflowing_total_deposits_returns_typed_error_without_writing_state() { - let t = setup(1, 1); - t.env.as_contract(&t.contract_id, || { - t.env - .storage() - .instance() - .set(&DataKey::TotalDeposits, &(i128::MAX - 50)); - }); - - match t.client.try_stake(&t.user, &100i128) { - Err(Ok(PoolError::ExceedsMaxStake)) => {} - _ => panic!("expected PoolError::ExceedsMaxStake"), - } - - assert!(t.client.get_stake(&t.user).is_none()); - assert_eq!(t.client.total_staked(), 0); - assert_eq!(t.client.total_deposits(), i128::MAX - 50); -} - -#[test] -fn test_stake_overflowing_total_staked_returns_typed_error_without_writing_state() { - let t = setup(1, 1); - t.env.as_contract(&t.contract_id, || { - t.env - .storage() - .instance() - .set(&DataKey::TotalStaked, &(i128::MAX - 50)); - }); - - match t.client.try_stake(&t.user, &100i128) { - Err(Ok(PoolError::ExceedsMaxStake)) => {} - _ => panic!("expected PoolError::ExceedsMaxStake"), - } - - assert!(t.client.get_stake(&t.user).is_none()); - assert_eq!(t.client.total_staked(), i128::MAX - 50); - assert_eq!(t.client.total_deposits(), 0); -} - -// ── stake validation ordering / single-source-of-truth / token interface -// (#456, #457, #458, #459) ─────────────────────────────────────────────────── - -use crate::mock_fee_token::{MockFeeToken, MockFeeTokenClient}; -use crate::mock_reentrant_token::{MockReentrantToken, MockReentrantTokenClient}; - -/// #456: every pool-invariant check must be decided before the first -/// cross-contract call. `balance` runs the stake token's own code, so a -/// rejection that only happens afterwards gives hostile token code a window -/// in which the pool's validation sequence has already touched (or queried -/// around) live state. -#[test] -fn test_stake_overflowing_total_staked_is_rejected_before_querying_the_token() { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let user = Address::generate(&env); - - let farming_pool_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &farming_pool_id); - - let token_id = env.register(MockReentrantToken, ()); - let token_client = MockReentrantTokenClient::new(&env, &token_id); - token_client.configure(&farming_pool_id, &user); - - client.initialize(&admin, &token_id, &2u32, &100i128, &0u32, &0i128); - - env.as_contract(&farming_pool_id, || { - env.storage() - .instance() - .set(&DataKey::TotalStaked, &(i128::MAX - 50)); - }); - - assert_eq!( - client.try_stake(&user, &100i128), - Err(Ok(PoolError::ExceedsMaxStake)) - ); - // The stake token's `balance` never ran — the pool's own invariant was - // settled before any of the token's code was invoked. - assert_eq!(token_client.balance_call_count(), 0); - assert!(client.get_stake(&user).is_none()); -} - -/// #457: the total that passes the minimum/cap checks is the total that gets -/// persisted — one read, one source of truth, and a rejection writes nothing. -#[test] -fn test_stake_topup_persists_exactly_the_validated_total() { - let t = setup(2, 1); - t.client.stake(&t.user, &100i128); - - // The minimum applies to the *resulting* position, and the resulting - // position is exactly what lands in storage. - t.client.set_min_stake_amount(&150); - t.client.stake(&t.user, &50i128); - - let stake = t.client.get_stake(&t.user).unwrap(); - assert_eq!(stake.amount, 150); - assert_eq!(t.client.total_staked(), 150); - assert_eq!(t.client.total_deposits(), 150); - - // A top-up rejected against that same total leaves the record untouched. - t.client.set_min_stake_amount(&200); - assert_eq!( - t.client.try_stake(&t.user, &1i128), - Err(Ok(PoolError::BelowMinimumStake)) - ); - let stake = t.client.get_stake(&t.user).unwrap(); - assert_eq!(stake.amount, 150); - assert_eq!(t.client.total_staked(), 150); - assert_eq!(t.client.total_deposits(), 150); -} - -/// #458: a stake token that does not deliver the full deposit (fee-on-transfer -/// / deflationary) must not leave a stake credited against tokens the pool -/// never received — the last withdrawers would otherwise be unable to exit. -#[test] -fn test_stake_rejects_deposit_not_delivered_in_full() { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let user = Address::generate(&env); - - let token_id = env.register(MockFeeToken, ()); - let token = MockFeeTokenClient::new(&env, &token_id); - token.mint(&user, &1_000_000i128); - token.set_fee_bps(&100); // 1% skimmed per transfer - - let farming_pool_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &farming_pool_id); - client.initialize(&admin, &token_id, &2u32, &100i128, &0u32, &0i128); - - assert_eq!( - client.try_stake(&user, &1_000i128), - Err(Ok(PoolError::TransferAmountMismatch)) - ); - - // The whole invocation reverted — position, accounting and tokens alike. - assert!(client.get_stake(&user).is_none()); - assert_eq!(client.total_staked(), 0); - assert_eq!(client.total_deposits(), 0); - assert_eq!(token.balance(&user), 1_000_000); - assert_eq!(token.balance(&farming_pool_id), 0); - - // A token that delivers nothing at all fails the same check. - token.set_fee_bps(&10_000); - assert_eq!( - client.try_stake(&user, &1_000i128), - Err(Ok(PoolError::TransferAmountMismatch)) - ); - assert!(client.get_stake(&user).is_none()); - assert_eq!(token.balance(&farming_pool_id), 0); -} - -/// #459: a misconfigured `stake_token` — an address with no code, or a -/// contract without a token interface — is a typed configuration error, not -/// a host trap. -#[test] -fn test_stake_rejects_stake_token_that_does_not_answer_balance() { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let user = Address::generate(&env); - let not_a_token = Address::generate(&env); - - let farming_pool_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &farming_pool_id); - client.initialize(&admin, ¬_a_token, &2u32, &100i128, &0u32, &0i128); - - assert_eq!( - client.try_stake(&user, &100i128), - Err(Ok(PoolError::InvalidStakeToken)) - ); - assert!(client.get_stake(&user).is_none()); - assert_eq!(client.total_staked(), 0); - assert_eq!(client.total_deposits(), 0); -} - -#[test] -fn test_stake_rejects_stake_token_without_token_interface() { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let user = Address::generate(&env); - - // A live contract that simply has no `balance` entry point. - let not_a_token = env.register(FarmingPool, ()); - - let farming_pool_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &farming_pool_id); - client.initialize(&admin, ¬_a_token, &2u32, &100i128, &0u32, &0i128); - - assert_eq!( - client.try_stake(&user, &100i128), - Err(Ok(PoolError::InvalidStakeToken)) - ); - assert!(client.get_stake(&user).is_none()); - assert_eq!(client.total_staked(), 0); -} - -#[test] -fn test_unstake_emits_event() { - let t = setup(2, 1); - let amount = 1_000i128; - t.client.stake(&t.user, &amount); - advance_ledgers(&t.env, 10); - t.client.unstake(&t.user, &amount); - - assert_eq!( - t.env.events().all().filter_by_contract(&t.contract_id), - soroban_sdk::vec![ - &t.env, - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("chkpt").into_val(&t.env) - ], - (t.user.clone(), 10_000i128, 10_000i128).into_val(&t.env), - ), - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("unstaked").into_val(&t.env) - ], - (t.user.clone(), amount, 10_000i128).into_val(&t.env), - ) - ] - ); -} - -#[test] -fn test_total_staked_tracks_locked_and_flexible_positions() { - let t = setup(2, 1); - - assert_eq!(t.client.total_staked(), 0); - t.client.stake(&t.user, &1_000); - assert_eq!(t.client.total_staked(), 1_000); - - t.client.lock_assets(&t.user, &500); - assert_eq!(t.client.total_staked(), 1_500); - - t.client.unstake(&t.user, &1_000); - assert_eq!(t.client.total_staked(), 500); - - t.client.unlock_assets(&t.user, &500); - assert_eq!(t.client.total_staked(), 0); -} - -// ── total_distributed_credits tests ─────────────────────────────────────────── - -#[test] -fn test_total_distributed_credits_starts_at_zero() { - let t = setup(2, 1); - assert_eq!(t.client.total_distributed_credits(), 0); -} - -#[test] -fn test_get_total_earned_counts_credits_when_checkpointed() { - let t = setup(2, 1); - t.client.stake(&t.user, &1_000); - advance_ledgers(&t.env, 10); - - assert_eq!(t.client.get_credits(&t.user), 10_000); - assert_eq!(t.client.get_total_earned(), 0); - - t.client.unstake(&t.user, &1_000); - assert_eq!(t.client.get_total_earned(), 10_000); -} - -#[test] -fn test_total_distributed_credits_counts_banked_stake_accrual_on_checkpoint() { - let t = setup(2, 1); - t.client.stake(&t.user, &1_000); - assert_eq!(t.client.total_distributed_credits(), 0); - - advance_ledgers(&t.env, 10); - // Read-only views must not commit anything to the aggregate. - assert_eq!(t.client.get_credits(&t.user), 10_000); - assert_eq!(t.client.total_distributed_credits(), 0); - - // unstake checkpoints and banks 10_000 credits. - let banked = t.client.unstake(&t.user, &1_000); - assert_eq!(banked, 10_000); - assert_eq!(t.client.total_distributed_credits(), 10_000); -} - -#[test] -fn test_total_distributed_credits_counts_position_accrual_on_unlock() { - let t = setup_with_lock_period(1, 1, 0); - assert_eq!(t.client.total_distributed_credits(), 0); - - t.client.lock_assets(&t.user, &1_000); - assert_eq!(t.client.total_distributed_credits(), 0); - - advance_ledgers(&t.env, 10); - // Partial unlock checkpoints the position and banks 1_000 * 1 * 10. - t.client.unlock_assets(&t.user, &500); - assert_eq!(t.client.total_distributed_credits(), 10_000); -} - -#[test] -fn test_total_distributed_credits_accumulates_across_users_and_systems() { - let t = setup(2, 1); - let other = Address::generate(&t.env); - t.token_sac.mint(&other, &1_000_000_000i128); - - // User A flexible stake: 10 ledgers unbooted → 10_000 credits. - t.client.stake(&t.user, &1_000); - advance_ledgers(&t.env, 10); - t.client.stake(&t.user, &100); // checkpoints 10_000 - assert_eq!(t.client.total_distributed_credits(), 10_000); - - // User B locked position: 5 more ledgers → 500 credits. - t.client.lock_assets(&other, &100); - advance_ledgers(&t.env, 5); - t.client.unlock_assets(&other, &100); // checkpoints 500 - assert_eq!(t.client.total_distributed_credits(), 10_500); -} - -#[test] -fn test_total_credits_earned_tracks_lifetime_credits_across_withdrawals() { - let t = setup(2, 1); - t.client.stake(&t.user, &1_000); - - advance_ledgers(&t.env, 10); - assert_eq!(t.client.get_credits(&t.user), 10_000); - assert_eq!(t.client.total_credits_earned(&t.user), 0); - - t.client.unstake(&t.user, &1_000); - assert_eq!(t.client.total_credits_earned(&t.user), 10_000); - - advance_ledgers(&t.env, 5); - t.client.stake(&t.user, &500); - advance_ledgers(&t.env, 5); - t.client.unstake(&t.user, &500); - assert_eq!(t.client.total_credits_earned(&t.user), 12_500); -} - -#[test] -fn test_total_banked_credits_tracks_current_bank_across_users() { - let t = setup(2, 1); - let other = Address::generate(&t.env); - t.token_sac.mint(&other, &1_000_000_000i128); - - t.client.stake(&t.user, &1_000); - advance_ledgers(&t.env, 10); - assert_eq!(t.client.total_banked_credits(), 0); - - t.client.unstake(&t.user, &1_000); - assert_eq!(t.client.total_banked_credits(), 0); - - t.client.stake(&other, &2_000); - advance_ledgers(&t.env, 5); - t.client.unstake(&other, &2_000); - assert_eq!(t.client.total_banked_credits(), 0); -} - -#[test] -fn test_pause_uninitialized_returns_not_initialized() { - let (_env, client, _user) = setup_uninitialized(); - match client.try_pause() { - Err(Ok(PoolError::NotInitialized)) => {} - _ => panic!("expected PoolError::NotInitialized"), - } -} - -#[test] -fn test_schema_version_defaults_to_current_release() { - let t = setup(2, 1); - assert_eq!(t.client.schema_version(), SCHEMA_VERSION); -} - -#[test] -fn test_migrate_placeholder_requires_admin_and_stamps_current_version() { - let t = setup(2, 1); - assert_eq!(t.client.migrate(), SCHEMA_VERSION); - assert_eq!(t.client.schema_version(), SCHEMA_VERSION); -} - -#[test] -fn test_upgrade_preserves_stake_storage_and_enables_new_wasm() { - let t = setup(2, 1); - t.client.stake(&t.user, &1_000); - advance_ledgers(&t.env, 10); - - let before = t.env.as_contract(&t.contract_id, || { - t.env - .storage() - .persistent() - .get::(&DataKey::UserStake(t.user.clone())) - }); - let before = before.expect("stake storage must exist before upgrade"); - let new_wasm_hash = upload_upgrade_target_wasm(&t.env); - let old_wasm_hash = match t.contract_id.executable() { - Some(soroban_sdk::Executable::Wasm(hash)) => Some(hash), - _ => None, - }; - - t.client.upgrade(&new_wasm_hash); - - assert_eq!( - t.env.events().all(), - soroban_sdk::vec![ - &t.env, - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("upgraded").into_val(&t.env) - ], - (old_wasm_hash, new_wasm_hash.clone()).into_val(&t.env), - ) - ] - ); - - let stored = t.env.as_contract(&t.contract_id, || { - t.env - .storage() - .persistent() - .get::(&DataKey::UserStake(t.user.clone())) - }); - let stored = stored.expect("stake storage must survive wasm upgrade"); - assert_eq!(stored.amount, before.amount); - assert_eq!(stored.start_ledger, before.start_ledger); - assert_eq!(stored.credits_banked, before.credits_banked); - assert_eq!(stored.credit_rate, before.credit_rate); - - let args: soroban_sdk::Vec = - soroban_sdk::vec![&t.env, 2i32.into_val(&t.env), 40i32.into_val(&t.env)]; - let sum: i32 = t - .env - .invoke_contract(&t.contract_id, &Symbol::new(&t.env, "add"), args); - assert_eq!(sum, 42); -} - -#[test] -fn test_upgrade_requires_admin_auth() { - let (env, contract_id, client, admin, user) = setup_without_mocked_auth(); - let new_wasm_hash = upload_upgrade_target_wasm(&env); - - let result = client - .mock_auths(&[MockAuth { - address: &user, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "upgrade", - args: (&new_wasm_hash,).into_val(&env), - sub_invokes: &[], - }, - }]) - .try_upgrade(&new_wasm_hash); - - assert!(result.is_err(), "non-admin upgrade must be rejected"); - assert_eq!(client.admin(), admin); -} - -#[test] -fn test_admin_uninitialized_returns_not_initialized() { - let (_env, client, _user) = setup_uninitialized(); - match client.try_admin() { - Err(Ok(PoolError::NotInitialized)) => {} - _ => panic!("expected PoolError::NotInitialized"), - } -} - -#[test] -fn test_transfer_admin_uninitialized_returns_not_initialized() { - let (env, client, _user) = setup_uninitialized(); - let new_admin = Address::generate(&env); - match client.try_transfer_admin(&new_admin) { - Err(Ok(PoolError::NotInitialized)) => {} - _ => panic!("expected PoolError::NotInitialized"), - } -} - -// ── Boost calculation unit tests ────────────────────────────────────────────── - -#[test] -fn test_effective_stake_no_boost() { - // Without boost, effective stake equals staked amount (allocation_pct = 0 → multiplier has no effect). - let stake = compute_total_stake(1_000, 0, 5); - assert_eq!(stake, 1_000); -} - -#[test] -fn test_effective_stake_full_allocation_2x() { - // 100% allocation at 2× multiplier: virtual_stake = 1000 * 2 = 2000, principal = 0. - let stake = compute_total_stake(1_000, 100, 2); - assert_eq!(stake, 2_000); -} - -#[test] -fn test_effective_stake_half_allocation_2x() { - // 50% allocation at 2×: principal = 500, virtual = 500*2 = 1000. total = 1500. - let stake = compute_total_stake(1_000, 50, 2); - assert_eq!(stake, 1_500); -} - -#[test] -fn test_effective_stake_25pct_allocation_3x() { - // 25% allocation at 3×: boosted = 250, principal = 750, virtual = 750. total = 1500. - let stake = compute_total_stake(1_000, 25, 3); - assert_eq!(stake, 1_500); -} - -#[test] -fn test_effective_stake_1pct_allocation_10x() { - // Minimal allocation at high multiplier. - // boosted = 10, principal = 990, virtual = 100. total = 1090. - let stake = compute_total_stake(1_000, 1, 10); - assert_eq!(stake, 1_090); -} - -// ── Boost system integration tests ─────────────────────────────────────────── - -#[test] -fn test_set_boost_and_get_config() { - let t = setup(2, 1); - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &50u32); - let cfg = t - .client - .get_boost_config(&t.user) - .expect("boost config should be set"); - assert_eq!(cfg.allocation_pct, 50); - assert_eq!(cfg.multiplier, 2); -} - -#[test] -fn test_get_boost_config_none_before_set() { - let t = setup(2, 1); - let cfg = t - .client - .get_boost_config(&t.user) - .expect("boost config should default to zero allocation"); - assert_eq!(cfg.allocation_pct, 0); - assert_eq!(cfg.multiplier, 2); -} - -#[test] -fn test_total_credits_tracks_cumulative_accrual() { - let t = setup(2, 1); - t.client.stake(&t.user, &1_000); - assert_eq!(t.client.total_credits(), 0); - - advance_ledgers(&t.env, 10); - t.client.set_boost(&t.user, &50u32); - assert_eq!(t.client.total_credits(), 10_000); - - advance_ledgers(&t.env, 10); - t.client.set_boost(&t.user, &50u32); - assert_eq!(t.client.total_credits(), 25_000); -} - -#[test] -fn test_credits_without_boost_accrue_at_face_value() { - // credit_rate = 1, no boost → credits = amount * ledgers - let t = setup(2, 1); - t.client.stake(&t.user, &1_000); - advance_ledgers(&t.env, 10); - assert_eq!(t.client.get_credits(&t.user), 1_000 * 10); -} - -#[test] -fn test_credits_with_50pct_boost_2x_multiplier() { - // effective_stake = 1500, credit_rate = 1, ledgers = 10 → 15000 credits - let t = setup(2, 1); - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &50u32); - advance_ledgers(&t.env, 10); - assert_eq!(t.client.get_credits(&t.user), 1_500 * 10); -} - -#[test] -fn test_credits_with_100pct_boost_2x_multiplier() { - // effective_stake = 2000, 10 ledgers → 20000 credits - let t = setup(2, 1); - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &100u32); - advance_ledgers(&t.env, 10); - assert_eq!(t.client.get_credits(&t.user), 2_000 * 10); -} - -#[test] -fn test_boost_update_preserves_previously_earned_credits() { - // Stake, earn 5 ledgers unbooted, then set 50% boost, earn 5 more. - // First 5: credits = 1000 * 5 = 5000 (no boost) - // Next 5: credits = 1500 * 5 = 7500 (50% boost, 2×) - // Total: 12500 - let t = setup(2, 1); - t.client.stake(&t.user, &1_000); - advance_ledgers(&t.env, 5); - t.client.set_boost(&t.user, &50u32); // checkpoints 5000 credits - advance_ledgers(&t.env, 5); - assert_eq!(t.client.get_credits(&t.user), 12_500); -} - -#[test] -fn test_boost_can_be_updated_repeatedly_without_losing_credits() { - // 10 ledgers at 50% boost (effective 1500), then 10 at 100% (effective 2000). - let t = setup(2, 1); - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &50u32); - advance_ledgers(&t.env, 10); - t.client.set_boost(&t.user, &100u32); // checkpoints 15000 - advance_ledgers(&t.env, 10); - assert_eq!(t.client.get_credits(&t.user), 15_000 + 20_000); -} - -#[test] -fn test_set_boost_rejects_without_active_stake() { - let t = setup(2, 1); - let res = t.client.try_set_boost(&t.user, &50u32); - assert_eq!(res, Err(Ok(PoolError::NoActiveStake))); -} - -#[test] -fn test_set_boost_rejects_zero_allocation() { - // Soroban host wraps contract panics in HostError; use try_ client variants to inspect them. - let t = setup(2, 1); - t.client.stake(&t.user, &1_000); - assert_eq!( - t.client.try_set_boost(&t.user, &0u32), - Err(Ok(PoolError::InvalidAllocation)) - ); -} - -#[test] -fn test_set_boost_rejects_over_100_allocation() { - let t = setup(2, 1); - t.client.stake(&t.user, &1_000); - assert_eq!( - t.client.try_set_boost(&t.user, &101u32), - Err(Ok(PoolError::InvalidAllocation)) - ); -} - -#[test] -fn test_admin_sets_global_multiplier() { - let t = setup(2, 1); - t.client.set_global_multiplier(&3u32); - // Boost config for a user should reflect new multiplier. - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &50u32); - let cfg = t.client.get_boost_config(&t.user).unwrap(); - assert_eq!(cfg.multiplier, 3); -} - -#[test] -fn test_get_pool_info_aggregates_pool_parameters() { - // #395: a pool overview is one invocation, and its values must agree with - // the individual getters so the aggregate is not a second source of truth. - let t = setup_with_lock_period(3, 42, 12); - - let info = t.client.get_pool_info(); - - assert_eq!(info.credit_rate, t.client.credit_rate()); - assert_eq!(info.min_lock_period, t.client.min_lock_period()); - assert_eq!(info.min_stake_amount, t.client.get_min_stake_amount()); - assert_eq!(info.total_staked, t.client.total_staked()); - assert_eq!( - info.total_distributed_credits, - t.client.total_distributed_credits() - ); - assert_eq!( - info.total_banked_credits, - t.client.total_banked_credits() - ); - assert_eq!(info.is_paused, t.client.is_paused()); - - // Configured-at-initialize values are reflected. - assert_eq!(info.credit_rate, 42); - assert_eq!(info.global_multiplier, 3); - assert_eq!(info.min_lock_period, 12); - assert!(!info.is_paused); -} - -#[test] -fn test_get_pool_info_requires_initialized_pool() { - // #395: like every other getter, the aggregate must not answer for an - // uninitialized pool — a zeroed PoolInfo would be indistinguishable from - // a real pool that happens to hold nothing. - let (_env, client, _user) = setup_uninitialized(); - - let result = client.try_get_pool_info(); - assert!(matches!(result, Err(Ok(PoolError::NotInitialized)))); -} - -#[test] -fn test_set_credit_rate_event_carries_old_and_new_rate_across_changes() { - // #396: an indexer can only track rate history if `rate_set` carries the - // rate the pool is leaving as well as the one it is moving to. The event - // does publish (old_rate, new_rate, ledger), so this test pins that - // contract across a sequence of changes: each event's first payload value - // is the previous event's second, which is what makes deltas computable - // without reading storage. - let t = setup_with_lock_period(2, 10, 12); - - t.client.set_credit_rate(&25i128); - t.client.set_credit_rate(&40i128); - t.client.set_credit_rate(&5i128); - - // `events().all()` only reflects the most recent top-level call, so only - // the last `set_credit_rate` invocation's event is present here. It must - // pair the just-superseded rate (40) with the new one (5), not the - // pool's original rate. - assert_eq!( - t.env.events().all(), - soroban_sdk::vec![ - &t.env, - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("rate_set").into_val(&t.env) - ], - (40i128, 5i128, 0u32).into_val(&t.env), - ) - ] - ); - - // Final value still matches the public getter. - assert_eq!(t.client.credit_rate(), 5i128); -} - -#[test] -fn test_set_credit_rate_updates_public_getters() { - let t = setup_with_lock_period(2, 1, 12); - t.client.set_credit_rate(&4i128); - assert_eq!( - t.env.events().all(), - soroban_sdk::vec![ - &t.env, - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("rate_set").into_val(&t.env) - ], - (1i128, 4i128, 0u32).into_val(&t.env), - ) - ] - ); - - t.client.set_min_lock_period(&25u32); - assert_eq!( - t.env.events().all(), - soroban_sdk::vec![ - &t.env, - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("lock_set").into_val(&t.env) - ], - (12u32, 25u32).into_val(&t.env), - ) - ] - ); - - assert_eq!(t.client.credit_rate(), 4); - assert_eq!(t.client.get_credit_rate(), 4); - assert_eq!(t.client.min_lock_period(), 25); - assert_eq!(t.client.get_min_lock_period(), 25); -} - -#[test] -fn test_min_lock_period_seconds() { - // 12 ledgers * ~5s/ledger = 60 seconds (#166). - let t = setup_with_lock_period(2, 1, 12); - assert_eq!(t.client.min_lock_period_seconds(), 60); - assert_eq!(t.client.get_min_lock_period_seconds(), 60); - - // 30 ledgers -> 150 seconds; also confirms it tracks set_min_lock_period. - t.client.set_min_lock_period(&25u32); - assert_eq!(t.client.min_lock_period_seconds(), 125); - assert_eq!(t.client.get_min_lock_period_seconds(), 125); -} - -#[test] -fn test_set_credit_rate_rejects_zero_with_typed_error() { - let t = setup(2, 1); - let result = t.client.try_set_credit_rate(&0i128); - assert!(matches!(result, Err(Ok(PoolError::InvalidCreditRate)))); -} - -// ── #89: value ceilings on set_global_multiplier / set_credit_rate ────────── - -#[test] -fn test_set_credit_rate_rejects_above_ceiling() { - let t = setup(2, 1); - let result = t.client.try_set_credit_rate(&(MAX_CREDIT_RATE + 1)); - assert!(matches!(result, Err(Ok(PoolError::InvalidCreditRate)))); -} - -#[test] -fn test_set_credit_rate_accepts_exactly_the_ceiling() { - let t = setup(2, 1); - t.client.set_credit_rate(&MAX_CREDIT_RATE); - assert_eq!(t.client.credit_rate(), MAX_CREDIT_RATE); -} - -#[test] -fn test_set_credit_rate_requires_admin_auth() { - let (env, contract_id, client, _admin, user) = setup_without_mocked_auth(); - - let result = client - .mock_auths(&[MockAuth { - address: &user, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "set_credit_rate", - args: (&5i128,).into_val(&env), - sub_invokes: &[], - }, - }]) - .try_set_credit_rate(&5i128); - - assert!( - result.is_err(), - "non-admin set_credit_rate must be rejected" - ); -} - -#[test] -fn test_set_min_lock_period_requires_admin_auth() { - let (env, contract_id, client, _admin, user) = setup_without_mocked_auth(); - - let result = client - .mock_auths(&[MockAuth { - address: &user, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "set_min_lock_period", - args: (&9u32,).into_val(&env), - sub_invokes: &[], - }, - }]) - .try_set_min_lock_period(&9u32); - - assert!( - result.is_err(), - "non-admin set_min_lock_period must be rejected" - ); -} - -#[test] -fn test_credit_rate_change_does_not_retroactively_alter_staked_credits() { - let t = setup(2, 1); - t.client.stake(&t.user, &1_000); - advance_ledgers(&t.env, 10); - - t.client.set_credit_rate(&3i128); - assert_eq!(t.client.get_credits(&t.user), 10_000); - - t.client.stake(&t.user, &100); // checkpoints under the old rate (top-up must clear min_stake_amount) - advance_ledgers(&t.env, 5); - assert_eq!(t.client.get_credits(&t.user), 26_500); -} - -#[test] -fn test_credit_rate_change_does_not_retroactively_alter_locked_credits() { - let t = setup(1, 1); - t.client.lock_assets(&t.user, &1_000); - advance_ledgers(&t.env, 10); - - t.client.set_credit_rate(&3i128); - assert_eq!(t.client.calculate_credits(&t.user), 10_000); - - t.client.lock_assets(&t.user, &100); // checkpoints under the old rate (top-up must clear min_stake_amount) - advance_ledgers(&t.env, 5); - assert_eq!(t.client.calculate_credits(&t.user), 26_500); -} - -#[test] -fn test_admin_multiplier_change_applies_from_next_checkpoint() { - // 10 ledgers at 50% boost @ 2×, then user checkpoints (banking 2× credits), - // then admin bumps to 3×, then 10 more ledgers at 50% @ 3×. - let t = setup(2, 1); - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &50u32); - advance_ledgers(&t.env, 10); - // User checkpoints at 2× before admin changes the multiplier. - // effective_stake = 1500 → 15000 banked. - t.client.set_boost(&t.user, &50u32); - t.client.set_global_multiplier(&3u32); - // User checkpoints to adopt the new global multiplier - t.client.set_boost(&t.user, &50u32); - advance_ledgers(&t.env, 10); - // Next 10 ledgers: effective_stake = 2000 (50% @ 3×) → 20000 -> total 35,000 - assert_eq!(t.client.get_credits(&t.user), 35_000); -} - -#[test] -fn test_admin_multiplier_change_applies_to_existing_stake_without_manual_checkpoint() { - let t = setup(2, 1); - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &50u32); - advance_ledgers(&t.env, 10); - - t.client.set_global_multiplier(&3u32); - advance_ledgers(&t.env, 10); - - assert_eq!(t.client.get_credits(&t.user), 35_000); -} - -#[test] -fn test_get_credits_matches_checkpoint_accrual_after_multiplier_change() { - // Regression for #223: get_credits and checkpoint must use the same - // multiplier source, so an un-checkpointed read equals exactly what the - // next checkpointing operation banks. - let t = setup(2, 1); - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &50u32); - advance_ledgers(&t.env, 10); - - t.client.set_global_multiplier(&3u32); - advance_ledgers(&t.env, 10); - - // Read-only view: must not mutate stake state. - let viewed = t.client.get_credits(&t.user); - assert_eq!(viewed, 35_000); - - // unstake checkpoints → banked credits must equal the viewed total. - let banked = t.client.unstake(&t.user, &1_000); - assert_eq!(banked, viewed); - - // The aggregate counter must agree with the banked amount too. - assert_eq!(t.client.total_distributed_credits(), banked); -} - -#[test] -fn test_admin_multiplier_rejects_zero() { - // Updated for #89: the old bare `assert!` (matched via `should_panic`) - // was replaced with a typed `PoolError::InvalidGlobalMultiplier` return, - // so this now asserts via `try_set_global_multiplier` like the rest of - // the typed-error suite instead of panic-message matching. - let t = setup(2, 1); - let result = t.client.try_set_global_multiplier(&0u32); - assert!(matches!( - result, - Err(Ok(PoolError::InvalidGlobalMultiplier)) - )); -} - -#[test] -fn test_set_global_multiplier_rejects_above_ceiling() { - let t = setup(2, 1); - let result = t - .client - .try_set_global_multiplier(&(MAX_GLOBAL_MULTIPLIER + 1)); - assert!(matches!( - result, - Err(Ok(PoolError::InvalidGlobalMultiplier)) - )); -} - -#[test] -fn test_set_global_multiplier_accepts_exactly_the_ceiling() { - let t = setup(2, 1); - t.client.set_global_multiplier(&MAX_GLOBAL_MULTIPLIER); - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &50u32); - let cfg = t.client.get_boost_config(&t.user).unwrap(); - assert_eq!(cfg.multiplier, MAX_GLOBAL_MULTIPLIER); -} - -#[test] -fn test_initialize_rejects_global_multiplier_above_ceiling() { - let env = Env::default(); - env.mock_all_auths(); - let admin = Address::generate(&env); - let token_admin = Address::generate(&env); - let asset = env.register_stellar_asset_contract_v2(token_admin); - let contract_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &contract_id); - - let result = client.try_initialize( - &admin, - &asset.address(), - &(MAX_GLOBAL_MULTIPLIER + 1), - &1i128, - &0u32, - &1i128, - ); - assert!(matches!( - result, - Err(Ok(PoolError::InvalidGlobalMultiplier)) - )); -} - -#[test] -fn test_initialize_rejects_credit_rate_above_ceiling() { - let env = Env::default(); - env.mock_all_auths(); - let admin = Address::generate(&env); - let token_admin = Address::generate(&env); - let asset = env.register_stellar_asset_contract_v2(token_admin); - let contract_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &contract_id); - - let result = client.try_initialize( - &admin, - &asset.address(), - &2u32, - &(MAX_CREDIT_RATE + 1), - &0u32, - &1i128, - ); - assert!(matches!(result, Err(Ok(PoolError::InvalidCreditRate)))); -} - -#[test] -fn test_initialize_rejects_min_lock_period_above_ceiling() { - let env = Env::default(); - env.mock_all_auths(); - let admin = Address::generate(&env); - let token_admin = Address::generate(&env); - let asset = env.register_stellar_asset_contract_v2(token_admin); - let contract_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &contract_id); - - // Same ceiling `set_min_lock_period` enforces, so a pool cannot be deployed - // with a lock period the admin setter would refuse. - let result = client.try_initialize( - &admin, - &asset.address(), - &2u32, - &1i128, - &(MAX_LOCK_PERIOD + 1), - &1i128, - ); - assert_eq!(result, Err(Ok(PoolError::InvalidLockPeriod))); -} - -/// Ties the #89 fix to its own derivation: at both ceilings simultaneously -/// (`MAX_GLOBAL_MULTIPLIER`, `MAX_CREDIT_RATE`), full boost allocation (100%, -/// `compute_total_stake`'s worst case), the derivation's `amount_max`, and -/// its `elapsed_max` (~10 years of ledgers), the boost-path credit preview -/// (`get_credits`, which exercises the exact `compute_total_stake` / -/// `compute_credits` chain the derivation bounds) must not overflow/panic and -/// must equal the worst-case product computed by the same formula. This is a -/// single deterministic boundary point, not a fuzz/property suite (#75/#76 -/// remain out of scope). -#[test] -fn test_compute_credits_no_overflow_at_ceilings() { - // Matches the doc comment on MAX_GLOBAL_MULTIPLIER/MAX_CREDIT_RATE. - const AMOUNT_MAX: i128 = 1_000_000_000_000_000_000; // 10^18 - const ELAPSED_MAX: u32 = 63_072_000; // ~10 years at 5s/ledger - - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let user = Address::generate(&env); - - let token_admin = Address::generate(&env); - let asset = env.register_stellar_asset_contract_v2(token_admin); - let token_sac = StellarAssetClient::new(&env, &asset.address()); - token_sac.mint(&user, &AMOUNT_MAX); - - let contract_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &contract_id); - client.initialize( - &admin, - &asset.address(), - &MAX_GLOBAL_MULTIPLIER, - &MAX_CREDIT_RATE, - &0u32, - &1i128, - ); - - client.stake(&user, &AMOUNT_MAX); - client.set_boost(&user, &100u32); - advance_ledgers(&env, ELAPSED_MAX); - - // No panic/overflow trap here is itself the assertion; also check the - // value is exactly the worst-case product the derivation computed. - let credits = client.get_credits(&user); - let expected = - AMOUNT_MAX * MAX_GLOBAL_MULTIPLIER as i128 * MAX_CREDIT_RATE * ELAPSED_MAX as i128; - assert_eq!(credits, expected); -} - -#[test] -fn test_unstake_returns_tokens_and_credits() { - let t = setup(2, 1); - let initial_balance = t.token.balance(&t.user); - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &50u32); - advance_ledgers(&t.env, 10); - let credits = t.client.unstake(&t.user, &1_000); - assert_eq!(credits, 15_000); // 1500 * 10 - assert_eq!(t.token.balance(&t.user), initial_balance); - assert!(t.client.get_stake(&t.user).is_none()); -} - -#[test] -fn test_flash_stake_unstake_in_same_ledger_yields_no_credits() { - // Regression for #169: stake has no lock period, so a user CAN immediately - // unstake — but an immediate round-trip in the same ledger must earn no - // credits, i.e. flash-staking provides no reward and no leverage. - let t = setup(2, 1); - let initial_balance = t.token.balance(&t.user); - - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &100u32); - let credits = t.client.unstake(&t.user, &1_000); - - assert_eq!(credits, 0, "flash staking must not mint credits"); - assert_eq!( - t.token.balance(&t.user), - initial_balance, - "principal fully returned" - ); - assert!(t.client.get_stake(&t.user).is_none()); - assert_eq!(t.client.get_credits(&t.user), 0); -} - -#[test] -fn test_additional_stake_checkpoints_credits() { - // Stake 1000, earn 10 ledgers (= 10000 credits), then stake 500 more. - // After checkpoint: banked = 10000, amount = 1500. - // Earn 10 more ledgers with 0 boost: 1500 * 10 = 15000. - // Total: 25000. - let t = setup(1, 1); // multiplier=1 so no boost effect here - t.client.stake(&t.user, &1_000); - advance_ledgers(&t.env, 10); - t.client.stake(&t.user, &500); // triggers checkpoint - advance_ledgers(&t.env, 10); - assert_eq!(t.client.get_credits(&t.user), 25_000); -} - -#[test] -fn test_get_credits_zero_without_stake() { - let t = setup(2, 1); - assert_eq!(t.client.get_credits(&t.user), 0); - assert_eq!(t.client.get_position_credits(&t.user), 0); - assert_eq!(t.client.get_stake_credits(&t.user), 0); -} - -#[test] -fn test_get_position_credits_and_get_stake_credits_distinguish_systems() { - let t = setup(2, 1); - - // Flexible stake in UserStake system (1000 tokens, 50% boost at 2x -> effective 1500) - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &50u32); - - // Time-locked position in Position system (500 tokens, credit rate 1) - t.client.lock_assets(&t.user, &500); - - advance_ledgers(&t.env, 10); - - // Position credits are amount * credit_rate * elapsed with no boost - // factor — `boost` only applies to the flexible UserStake system (see - // `get_stake_credits`/`get_credits` doc comments). - // Position credits = 500 * 1 * 10 = 5_000 - assert_eq!(t.client.get_position_credits(&t.user), 5_000); - assert_eq!(t.client.calculate_credits(&t.user), 5_000); - - // Stake credits = 1500 * 1 * 10 = 15_000 - assert_eq!(t.client.get_stake_credits(&t.user), 15_000); - - // Combined get_credits = 5_000 + 15_000 = 20_000 - assert_eq!(t.client.get_credits(&t.user), 20_000); -} - -// ── lock_assets tests ───────────────────────────────────────────────────────── - -#[test] -fn test_admin_getter_returns_current_admin() { - let t = setup(2, 1); - assert_eq!(t.client.admin(), t.admin); -} - -#[test] -fn test_propose_admin_does_not_change_admin_until_accepted() { - let (env, contract_id, client, old_admin, _user) = setup_without_mocked_auth(); - let new_admin = Address::generate(&env); - - client - .mock_auths(&[MockAuth { - address: &old_admin, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "propose_admin", - args: (&new_admin,).into_val(&env), - sub_invokes: &[], - }, - }]) - .propose_admin(&new_admin); - assert_eq!(client.admin(), old_admin); - - client - .mock_auths(&[MockAuth { - address: &new_admin, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "accept_admin", - args: ().into_val(&env), - sub_invokes: &[], - }, - }]) - .accept_admin(); - assert_eq!(client.admin(), new_admin); -} - -#[test] -fn test_accept_admin_requires_proposed_address_auth() { - let (env, contract_id, client, old_admin, user) = setup_without_mocked_auth(); - let new_admin = Address::generate(&env); - client - .mock_auths(&[MockAuth { - address: &old_admin, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "propose_admin", - args: (&new_admin,).into_val(&env), - sub_invokes: &[], - }, - }]) - .propose_admin(&new_admin); - - let current_admin_result = client - .mock_auths(&[MockAuth { - address: &old_admin, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "accept_admin", - args: ().into_val(&env), - sub_invokes: &[], - }, - }]) - .try_accept_admin(); - assert!( - current_admin_result.is_err(), - "current admin may not accept" - ); - - let third_party_result = client - .mock_auths(&[MockAuth { - address: &user, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "accept_admin", - args: ().into_val(&env), - sub_invokes: &[], - }, - }]) - .try_accept_admin(); - assert!(third_party_result.is_err(), "third parties may not accept"); - assert_eq!(client.admin(), old_admin); -} - -#[test] -fn test_propose_admin_can_be_overwritten_or_cancelled_before_acceptance() { - let t = setup(2, 1); - let first_proposal = Address::generate(&t.env); - let second_proposal = Address::generate(&t.env); - - t.client.propose_admin(&first_proposal); - t.client.propose_admin(&second_proposal); - t.client.accept_admin(); - assert_eq!(t.client.admin(), second_proposal); - - t.client.propose_admin(&second_proposal); - let result = t.client.try_accept_admin(); - assert!(matches!(result, Err(Ok(PoolError::NoPendingAdmin)))); - assert_eq!(t.client.admin(), second_proposal); -} - -#[test] -fn test_propose_admin_emits_event() { - let t = setup(2, 1); - let new_admin = Address::generate(&t.env); - t.client.propose_admin(&new_admin); - assert_eq!( - t.env.events().all(), - soroban_sdk::vec![ - &t.env, - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("adm_prop").into_val(&t.env) - ], - (t.admin.clone(), new_admin).into_val(&t.env), - ) - ] - ); -} - -#[test] -fn test_transfer_admin_changes_admin() { - let t = setup(2, 1); - let new_admin = Address::generate(&t.env); - t.client.transfer_admin(&new_admin); - assert_eq!(t.client.admin(), new_admin); -} - -#[test] -fn test_set_global_multiplier_emits_old_and_new() { - let t = setup(2, 1); - - // Pool was initialized with global_multiplier = 2. - t.client.set_global_multiplier(&5); - - assert_eq!( - t.env.events().all(), - soroban_sdk::vec![ - &t.env, - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("boost").into_val(&t.env), - soroban_sdk::symbol_short!("mult_set").into_val(&t.env) - ], - (2u32, 5u32).into_val(&t.env), - ) - ] - ); -} - -#[test] -fn test_set_global_multiplier_event_reports_previous_value() { - let t = setup(2, 1); - - t.client.set_global_multiplier(&5); - t.client.set_global_multiplier(&3); - - // `events().all()` only reflects the most recent top-level call, so only - // the second `set_global_multiplier` invocation's event is present here. - // It must pair the just-superseded value (5) with the new one (3), not - // the pool's original multiplier (2). - assert_eq!( - t.env.events().all(), - soroban_sdk::vec![ - &t.env, - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("boost").into_val(&t.env), - soroban_sdk::symbol_short!("mult_set").into_val(&t.env) - ], - (5u32, 3u32).into_val(&t.env), - ) - ] - ); -} - -#[test] -fn test_transfer_admin_emits_event() { - let t = setup(2, 1); - let new_admin = Address::generate(&t.env); - t.client.transfer_admin(&new_admin); - assert_eq!( - t.env.events().all(), - soroban_sdk::vec![ - &t.env, - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("adm_xfr").into_val(&t.env) - ], - (t.admin.clone(), new_admin.clone()).into_val(&t.env), - ) - ] - ); -} - -#[test] -fn test_transfer_admin_requires_current_admin_auth() { - let (env, contract_id, client, admin, user) = setup_without_mocked_auth(); - let new_admin = Address::generate(&env); - let result = client - .mock_auths(&[MockAuth { - address: &user, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "transfer_admin", - args: (&new_admin,).into_val(&env), - sub_invokes: &[], - }, - }]) - .try_transfer_admin(&new_admin); - assert!(result.is_err(), "non-admin transfer_admin must be rejected"); - assert_eq!(client.admin(), admin); -} - -#[test] -fn test_old_admin_loses_privileges_after_transfer() { - let (env, contract_id, client, old_admin, _user) = setup_without_mocked_auth(); - let new_admin = Address::generate(&env); - client - .mock_auths(&[MockAuth { - address: &old_admin, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "transfer_admin", - args: (&new_admin,).into_val(&env), - sub_invokes: &[], - }, - }]) - .transfer_admin(&new_admin); - - let old_pause = client - .mock_auths(&[MockAuth { - address: &old_admin, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "pause", - args: ().into_val(&env), - sub_invokes: &[], - }, - }]) - .try_pause(); - assert!(old_pause.is_err(), "old admin must not be able to pause"); - - let old_multiplier = client - .mock_auths(&[MockAuth { - address: &old_admin, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "set_global_multiplier", - args: (&3u32,).into_val(&env), - sub_invokes: &[], - }, - }]) - .try_set_global_multiplier(&3u32); - assert!( - old_multiplier.is_err(), - "old admin must not be able to set global multiplier" - ); - - client - .mock_auths(&[MockAuth { - address: &new_admin, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "pause", - args: ().into_val(&env), - sub_invokes: &[], - }, - }]) - .pause(); - assert!(client.is_paused(), "new admin should be able to pause"); -} - -#[test] -fn test_lock_assets_creates_position() { - let t = setup(1, 1); - let initial_balance = t.token.balance(&t.user); - t.client.lock_assets(&t.user, &500); - let pos = t - .client - .get_user_position(&t.user) - .expect("position should exist"); - assert_eq!(pos.amount, 500); - assert_eq!(pos.total_credits, 0); - assert_eq!(t.token.balance(&t.user), initial_balance - 500); - assert_eq!(t.token.balance(&t.contract_id), 500); -} - -#[test] -fn test_lock_assets_credits_total_deposits() { - let t = setup(1, 1); - t.client.lock_assets(&t.user, &500); - t.client.stake(&t.user, &300i128); - assert_eq!(t.client.total_deposits(), 800); - assert_eq!(t.client.total_staked(), 800); -} - -#[test] -fn test_lock_assets_overflowing_total_deposits_returns_typed_error_without_writing_state() { - let t = setup(1, 1); - t.env.as_contract(&t.contract_id, || { - t.env - .storage() - .instance() - .set(&DataKey::TotalDeposits, &(i128::MAX - 50)); - }); - - match t.client.try_lock_assets(&t.user, &100i128) { - Err(Ok(PoolError::ExceedsMaxStake)) => {} - _ => panic!("expected PoolError::ExceedsMaxStake"), - } - - assert!(t.client.get_user_position(&t.user).is_none()); - assert_eq!(t.client.total_staked(), 0); - assert_eq!(t.client.total_deposits(), i128::MAX - 50); -} - -#[test] -fn test_has_position_returns_false_without_position() { - let t = setup(1, 1); - assert!(!t.client.has_position(&t.user)); -} - -#[test] -fn test_has_position_returns_true_after_lock() { - let t = setup(1, 1); - t.client.lock_assets(&t.user, &500); - assert!(t.client.has_position(&t.user)); -} - -#[test] -fn test_has_position_returns_false_after_full_unlock() { - let t = setup(1, 1); - t.client.lock_assets(&t.user, &500); - advance_ledgers(&t.env, 100); - t.client.unlock_assets(&t.user, &500); - assert!(!t.client.has_position(&t.user)); -} - -#[test] -fn test_lock_assets_additional_lock_checkpoints_credits() { - // Lock 1000, advance 10 ledgers (10000 credits), then lock 500 more. - // After checkpoint: banked = 10000, amount = 1500. - // Earn 10 more ledgers with 0 boost: 1500 * 10 = 15000. - // Total: 25000. - let t = setup(1, 1); // multiplier=1 so no boost effect here - t.client.lock_assets(&t.user, &1_000); - advance_ledgers(&t.env, 10); - t.client.lock_assets(&t.user, &500); // banks 10000 - advance_ledgers(&t.env, 10); - assert_eq!(t.client.get_credits(&t.user), 25_000); -} - -#[test] -fn test_multiple_locks_credit_only_new_amount_for_later_ledgers() { - let t = setup(1, 1); - t.client.lock_assets(&t.user, &100); - advance_ledgers(&t.env, 1_000); - - t.client.lock_assets(&t.user, &100); - advance_ledgers(&t.env, 1_000); - - assert_eq!(t.client.calculate_credits(&t.user), 300_000); -} - -#[test] -fn test_lock_assets_rejects_zero_amount() { - let t = setup(1, 1); - assert_eq!( - t.client.try_lock_assets(&t.user, &0i128), - Err(Ok(PoolError::InvalidAmount)) - ); -} - -#[test] -fn test_lock_assets_rejects_negative_amount() { - let t = setup(1, 1); - assert_eq!( - t.client.try_lock_assets(&t.user, &-1i128), - Err(Ok(PoolError::InvalidAmount)) - ); -} - -#[test] -fn test_lock_assets_rejects_insufficient_balance() { - let t = setup(1, 1); - // User only has 1_000_000_000 tokens; try to lock more. - assert!(t - .client - .try_lock_assets(&t.user, &2_000_000_000i128) - .is_err()); -} - -#[test] -fn test_lock_assets_rejects_insufficient_balance_without_writing_state() { - let t = setup(1, 1); - let balance = t.token.balance(&t.user); - - assert_eq!( - t.client.try_lock_assets(&t.user, &(balance + 1)), - Err(Ok(PoolError::InsufficientBalance)) - ); - - assert!(t.client.get_user_position(&t.user).is_none()); - assert_eq!(t.client.total_locked(), 0); - assert_eq!(t.client.total_staked(), 0); - assert_eq!(t.token.balance(&t.user), balance); -} - -#[test] -fn test_lock_assets_overflowing_total_staked_returns_typed_error_without_writing_state() { - let t = setup(1, 1); - t.env.as_contract(&t.contract_id, || { - t.env - .storage() - .instance() - .set(&DataKey::TotalStaked, &(i128::MAX - 50)); - }); - - assert_eq!( - t.client.try_lock_assets(&t.user, &100i128), - Err(Ok(PoolError::ExceedsMaxStake)) - ); - - assert!(t.client.get_user_position(&t.user).is_none()); - assert_eq!(t.client.total_locked(), 0); - assert_eq!(t.client.total_staked(), i128::MAX - 50); -} - -/// `lock_assets` applies the same received-amount check as `stake`: a token that -/// skims a fee must not leave a locked position backed by less than its amount. -#[test] -fn test_lock_assets_rejects_deposit_not_delivered_in_full() { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let user = Address::generate(&env); - - let token_id = env.register(MockFeeToken, ()); - let token = MockFeeTokenClient::new(&env, &token_id); - token.mint(&user, &1_000_000i128); - token.set_fee_bps(&100); // 1% skimmed per transfer - - let farming_pool_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &farming_pool_id); - client.initialize(&admin, &token_id, &2u32, &100i128, &0u32, &0i128); - - assert_eq!( - client.try_lock_assets(&user, &1_000i128), - Err(Ok(PoolError::TransferAmountMismatch)) - ); - - // The whole invocation reverted: position, totals and tokens are untouched. - assert!(client.get_user_position(&user).is_none()); - assert_eq!(client.total_locked(), 0); - assert_eq!(client.total_staked(), 0); - assert_eq!(token.balance(&user), 1_000_000); - assert_eq!(token.balance(&farming_pool_id), 0); -} - -#[test] -fn test_lock_assets_emits_event() { - let t = setup(1, 1); - t.client.lock_assets(&t.user, &1_000); - - assert_eq!( - t.env.events().all().filter_by_contract(&t.contract_id), - soroban_sdk::vec![ - &t.env, - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("locked").into_val(&t.env) - ], - (t.user.clone(), 1_000i128, 1_000i128).into_val(&t.env), - ) - ] - ); - - // Top up with another 500 tokens; event should include 500 as lock amount and 1500 as total position. - // Soroban test env only retains the most recent invocation's events. - t.client.lock_assets(&t.user, &500); - assert_eq!( - t.env.events().all().filter_by_contract(&t.contract_id), - soroban_sdk::vec![ - &t.env, - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("locked").into_val(&t.env) - ], - (t.user.clone(), 500i128, 1_500i128).into_val(&t.env), - ) - ] - ); -} - -// ── unlock_assets tests ─────────────────────────────────────────────────────── - -#[test] -fn test_unlock_assets_full_returns_tokens_and_credits() { - let t = setup(1, 1); - let initial_balance = t.token.balance(&t.user); - t.client.lock_assets(&t.user, &1_000); - advance_ledgers(&t.env, 10); - t.client.unlock_assets(&t.user, &1_000); - // All tokens returned, position removed, credits = 1000 * 10. - assert_eq!(t.token.balance(&t.user), initial_balance); - assert_eq!(t.token.balance(&t.contract_id), 0); - assert!(t.client.get_user_position(&t.user).is_none()); - assert_eq!(t.client.calculate_credits(&t.user), 0); -} - -#[test] -fn test_unlock_assets_partial_keeps_remaining_position() { - let t = setup(1, 1); - let initial_balance = t.token.balance(&t.user); - t.client.lock_assets(&t.user, &500); - advance_ledgers(&t.env, 10); - t.client.unlock_assets(&t.user, &200); // partial unlock - - let pos = t - .client - .get_user_position(&t.user) - .expect("position should still exist"); - assert_eq!(pos.amount, 300); - // 500 * 10 = 5000 credits banked during checkpoint. - assert_eq!(pos.total_credits, 5_000); - assert_eq!(t.token.balance(&t.user), initial_balance - 300); - assert_eq!(t.token.balance(&t.contract_id), 300); -} - -#[test] -fn test_unlock_assets_emits_event_with_credits_earned() { - let t = setup(1, 1); - t.client.lock_assets(&t.user, &1_000); - advance_ledgers(&t.env, 10); - t.client.unlock_assets(&t.user, &1_000); - - // The unlocked event must include credits_earned (the delta from this - // checkpoint) alongside total_credits so indexers can track credit - // distributions without storage scans. - assert_eq!( - t.env.events().all().filter_by_contract(&t.contract_id), - soroban_sdk::vec![ - &t.env, - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("chkpt").into_val(&t.env) - ], - (t.user.clone(), 10_000i128, 10_000i128).into_val(&t.env), - ), - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("unlocked").into_val(&t.env) - ], - (t.user.clone(), 1_000i128, 10_000i128, 10_000i128).into_val(&t.env), - ) - ] - ); -} - -// ── unlock_assets split-invariance (#123) ───────────────────────────────────── -// -// #75 covers *when* checkpoints happen (time-invariance); this covers a -// distinct axis: whether a withdrawal's final outcome is invariant to *how* -// its amount is partitioned across multiple unlock_assets calls. -// checkpoint_position runs on every call and folds already-accrued credits -// into total_credits — a genuinely different code path per partial call — -// so this isn't guaranteed by construction, only by checkpoint_position's -// formula being linear in `amount`. - -#[test] -fn test_unlock_assets_final_outcome_is_invariant_to_how_the_withdrawal_is_split() { - // Each partition sums to 1_000, varying both the *count* and *sizing* of - // partial calls — [1] is a single full unlock, [2] two equal halves, - // [3] three uneven pieces, [4] a pathological near-all-at-once split - // that would catch an off-by-one on the first/last piece specifically. - let partitions: [&[i128]; 4] = [&[1_000], &[500, 500], &[300, 200, 500], &[1, 1, 998]]; - - // Every call after the first happens with zero further ledger advance, - // so only the first checkpoint in a partition ever has elapsed > 0 — - // isolating split-invariance from the already-covered time axis. - const ELAPSED_LEDGERS: u32 = 10; - // amount(1_000) * credit_rate(1, from setup(1, 1)) * ELAPSED_LEDGERS. - const EXPECTED_TOTAL_CREDITS: i128 = 1_000 * ELAPSED_LEDGERS as i128; - - for partition in partitions { - let t = setup(1, 1); - let initial_balance = t.token.balance(&t.user); - - t.client.lock_assets(&t.user, &1_000); - advance_ledgers(&t.env, ELAPSED_LEDGERS); - - let mut last_part = 0i128; - for &part in partition { - t.client.unlock_assets(&t.user, &part); - last_part = part; - } - // The test env only retains the most recent invocation's events, so - // this must be captured immediately after the last unlock — any - // further contract/token call below would overwrite it. - let final_unlock_events = t.env.events().all().filter_by_contract(&t.contract_id); - - assert!( - t.client.get_user_position(&t.user).is_none(), - "position must be fully cleared regardless of partition {partition:?}", - ); - assert_eq!( - t.client.calculate_credits(&t.user), - 0, - "no position left means no further accruing credits, for partition {partition:?}", - ); - assert_eq!( - t.token.balance(&t.user), - initial_balance, - "the full locked amount must come back regardless of partition {partition:?}", - ); - assert_eq!(t.token.balance(&t.contract_id), 0); - - // This is exactly the last unlock's event — and since every call - // after the first has zero elapsed ledgers, its total_credits is - // the same cumulative value the *first* checkpoint alone produced, - // regardless of how many pieces the withdrawal was split into. - if partition.len() == 1 { - assert_eq!( - final_unlock_events, - soroban_sdk::vec![ - &t.env, - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("chkpt").into_val(&t.env) - ], - ( - t.user.clone(), - EXPECTED_TOTAL_CREDITS, - EXPECTED_TOTAL_CREDITS - ) - .into_val(&t.env), - ), - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("unlocked").into_val(&t.env) - ], - (t.user.clone(), last_part, EXPECTED_TOTAL_CREDITS, EXPECTED_TOTAL_CREDITS) - .into_val(&t.env), - ) - ], - "final cumulative total_credits must be identical across partitions {partition:?}", - ); - } else { - assert_eq!( - final_unlock_events, - soroban_sdk::vec![ - &t.env, - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("unlocked").into_val(&t.env) - ], - (t.user.clone(), last_part, 0i128, EXPECTED_TOTAL_CREDITS).into_val(&t.env), - ) - ], - "final cumulative total_credits must be identical across partitions {partition:?}", - ); - } - } -} - -#[test] -fn test_unlock_assets_split_across_min_lock_period_boundary_reaches_same_final_state_as_single_unlock( -) { - // Compare: (A) unlock everything in one call the moment the position - // matures, vs. (B) an early partial attempt that's correctly rejected - // before maturity, followed by completing the withdrawal (split across - // two calls) once matured. Both must reach an identical final state. - const MIN_LOCK_PERIOD: u32 = 10; - - let a = setup_with_lock_period(1, 1, MIN_LOCK_PERIOD); - let a_initial_balance = a.token.balance(&a.user); - a.client.lock_assets(&a.user, &1_000); - advance_ledgers(&a.env, MIN_LOCK_PERIOD); - a.client.unlock_assets(&a.user, &1_000); - - let b = setup_with_lock_period(1, 1, MIN_LOCK_PERIOD); - let b_initial_balance = b.token.balance(&b.user); - b.client.lock_assets(&b.user, &1_000); - advance_ledgers(&b.env, MIN_LOCK_PERIOD - 2); // before maturity - assert!( - b.client.try_unlock_assets(&b.user, &500).is_err(), - "an unlock attempted before the min lock period elapses must be rejected" - ); - advance_ledgers(&b.env, 2); // now exactly at maturity - b.client.unlock_assets(&b.user, &400); - b.client.unlock_assets(&b.user, &600); - - assert!(a.client.get_user_position(&a.user).is_none()); - assert!(b.client.get_user_position(&b.user).is_none()); - assert_eq!( - a.token.balance(&a.user) - a_initial_balance, - b.token.balance(&b.user) - b_initial_balance, - ); - assert_eq!( - a.token.balance(&a.contract_id), - b.token.balance(&b.contract_id) - ); -} - -#[test] -fn test_lock_assets_topup_after_maturity_extends_unlock_ledger() { - let t = setup_with_lock_period(1, 1, 100); - t.client.lock_assets(&t.user, &100); - advance_ledgers(&t.env, 100); - - t.client.lock_assets(&t.user, &500); - let position = t.client.get_user_position(&t.user).unwrap(); - assert_eq!(position.unlock_ledger, 200); - assert!(t.client.try_unlock_assets(&t.user, &600).is_err()); - - advance_ledgers(&t.env, 99); - assert!(t.client.try_unlock_assets(&t.user, &600).is_err()); - advance_ledgers(&t.env, 1); - t.client.unlock_assets(&t.user, &600); - assert!(t.client.get_user_position(&t.user).is_none()); -} - -#[test] -fn test_lock_assets_topup_does_not_shorten_existing_unlock_ledger() { - let t = setup_with_lock_period(1, 1, 1_000); - t.client.lock_assets(&t.user, &1_000); - let original_position = t.client.get_user_position(&t.user).unwrap(); - - advance_ledgers(&t.env, 10); - t.client.set_min_lock_period(&5u32); - t.client.lock_assets(&t.user, &100); - - let topped_up_position = t.client.get_user_position(&t.user).unwrap(); - assert_eq!( - topped_up_position.unlock_ledger, - original_position.unlock_ledger - ); -} - -// ── calculate_credits tests ─────────────────────────────────────────────────── - -#[test] -fn test_min_lock_period_change_does_not_affect_existing_position_unlock_ledger() { - let t = setup_with_lock_period(1, 1, 100); - t.client.lock_assets(&t.user, &1_000); - let position = t.client.get_user_position(&t.user).unwrap(); - assert_eq!(position.unlock_ledger, position.lock_ledger + 100); - - t.client.set_min_lock_period(&5u32); - advance_ledgers(&t.env, 50); - assert!(t.client.try_unlock_assets(&t.user, &1_000).is_err()); - - advance_ledgers(&t.env, 50); - t.client.unlock_assets(&t.user, &1_000); - assert!(t.client.get_user_position(&t.user).is_none()); -} - -#[test] -fn test_new_positions_use_updated_min_lock_period() { - let t = setup_with_lock_period(1, 1, 100); - t.client.set_min_lock_period(&5u32); - t.client.lock_assets(&t.user, &1_000); - - let position = t.client.get_user_position(&t.user).unwrap(); - assert_eq!(position.unlock_ledger, position.lock_ledger + 5); -} - -#[test] -fn test_calculate_credits_zero_without_position() { - let t = setup(1, 1); - assert_eq!(t.client.calculate_credits(&t.user), 0); -} - -#[test] -fn test_calculate_credits_accrues_over_time() { - // credit_rate = 2, amount = 500, ledgers = 20 → credits = 500 * 2 * 20 = 20000 - let t = setup(1, 2); - t.client.lock_assets(&t.user, &500); - advance_ledgers(&t.env, 20); - assert_eq!(t.client.calculate_credits(&t.user), 20_000); -} - -#[test] -fn test_calculate_credits_includes_banked_plus_accruing() { - // Lock, advance 10 (banked = 10000 at second lock), add more, advance 10 more. - // Second period: (1000 + 500) * 1 * 10 = 15000. Total = 25000. - let t = setup(1, 1); - t.client.lock_assets(&t.user, &1_000); - advance_ledgers(&t.env, 10); - t.client.lock_assets(&t.user, &500); // banks 10000 - advance_ledgers(&t.env, 10); - assert_eq!(t.client.calculate_credits(&t.user), 25_000); -} - -#[test] -fn test_calculate_credits_reflects_partial_unlock_checkpoint() { - // Lock 1000, advance 10 → 10000. Unlock 400 (banks 10000). Remaining 600 accrues. - // Advance 5 more: 600 * 1 * 5 = 3000. Total banked+accruing = 10000 + 3000 = 13000. - let t = setup(1, 1); - t.client.lock_assets(&t.user, &1_000); - advance_ledgers(&t.env, 10); - t.client.unlock_assets(&t.user, &400); // banks 10000 into pos.total_credits - advance_ledgers(&t.env, 5); - assert_eq!(t.client.calculate_credits(&t.user), 13_000); -} - -// ── get_user_position tests ─────────────────────────────────────────────────── - -#[test] -fn test_get_user_position_none_before_lock() { - let t = setup(1, 1); - assert!(t.client.get_user_position(&t.user).is_none()); -} - -#[test] -fn test_get_user_position_returns_correct_fields() { - let t = setup(1, 1); - let start = t.env.ledger().sequence(); - t.client.lock_assets(&t.user, &750); - let pos = t.client.get_user_position(&t.user).unwrap(); - assert_eq!(pos.amount, 750); - assert_eq!(pos.lock_ledger, start); - assert_eq!(pos.unlock_ledger, start); - assert_eq!(pos.checkpoint_ledger, start); - assert_eq!(pos.total_credits, 0); - assert_eq!(pos.credit_rate, 1); -} - -#[test] -fn test_get_user_position_returns_accrued_credits() { - let t = setup(1, 1); - t.client.lock_assets(&t.user, &1_000); - advance_ledgers(&t.env, 5); - - let pos = t.client.get_user_position(&t.user).unwrap(); - assert_eq!(pos.total_credits, 5_000); - assert_eq!(pos.amount, 1_000); -} - -#[test] -fn test_get_stake_returns_accrued_credits() { - let t = setup(2, 1); - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &50u32); - advance_ledgers(&t.env, 5); - - let stake = t.client.get_stake(&t.user).unwrap(); - assert_eq!(stake.credits_banked, 7_500); - assert_eq!(stake.amount, 1_000); -} - -#[test] -fn test_get_user_position_none_after_full_unlock() { - let t = setup(1, 1); - t.client.lock_assets(&t.user, &1_000); - advance_ledgers(&t.env, 5); - t.client.unlock_assets(&t.user, &1_000); - assert!(t.client.get_user_position(&t.user).is_none()); -} - -#[test] -fn test_get_stakers_pages_distinct_flexible_and_locked_users() { - let t = setup(1, 1); - let locked_only_user = Address::generate(&t.env); - t.token_sac.mint(&locked_only_user, &1_000_000); - - t.client.stake(&t.user, &1_000); - t.client.lock_assets(&t.user, &500); - t.client.lock_assets(&locked_only_user, &500); - - let first_page = t.client.get_stakers(&0, &1); - assert_eq!(first_page.len(), 1); - assert_eq!(first_page.get(0), Some(t.user.clone())); - - let second_page = t.client.get_stakers(&1, &1); - assert_eq!(second_page.len(), 1); - assert_eq!(second_page.get(0), Some(locked_only_user)); - assert!(t.client.get_stakers(&2, &10).is_empty()); -} - -// ── pause / unpause tests ───────────────────────────────────────────────────── - -#[test] -fn test_pool_not_paused_initially() { - let t = setup(1, 1); - assert!(!t.client.is_paused()); -} - -#[test] -fn test_pause_blocks_lock_assets() { - let t = setup(1, 1); - t.client.pause(); - assert!(t.client.is_paused()); - assert!(t.client.try_lock_assets(&t.user, &100i128).is_err()); - assert!(t.client.is_paused()); - - match t.client.try_lock_assets(&t.user, &100i128) { - Err(Ok(PoolError::Paused)) => {} - other => panic!("expected PoolError::Paused, got: {:?}", other), - } -} - -#[test] -fn test_pause_blocks_unlock_assets() { - let t = setup(1, 1); - t.client.lock_assets(&t.user, &1_000); - t.client.pause(); - - match t.client.try_unlock_assets(&t.user, &1_000) { - Err(Ok(PoolError::Paused)) => {} - other => panic!("expected PoolError::Paused, got: {:?}", other), - } -} - -#[test] -fn test_unpause_restores_operations() { - let t = setup(1, 1); - t.client.pause(); - t.client.unpause(); - assert!(!t.client.is_paused()); - // Lock and unlock should work again. - t.client.lock_assets(&t.user, &500); - t.client.unlock_assets(&t.user, &500); -} - -#[test] -fn test_pause_emits_event() { - let t = setup(1, 1); - t.client.pause(); - assert_eq!( - t.env.events().all().filter_by_contract(&t.contract_id), - soroban_sdk::vec![ - &t.env, - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("paused").into_val(&t.env) - ], - ().into_val(&t.env), - ) - ] - ); -} - -#[test] -fn test_unpause_emits_event() { - let t = setup(1, 1); - t.client.pause(); - t.client.unpause(); - assert_eq!( - t.env.events().all().filter_by_contract(&t.contract_id), - soroban_sdk::vec![ - &t.env, - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("unpaused").into_val(&t.env) - ], - ().into_val(&t.env), - ) - ] - ); -} - -#[test] -fn test_pause_staking_emits_event() { - let t = setup(1, 1); - t.client.pause_staking(); - assert_eq!( - t.env.events().all().filter_by_contract(&t.contract_id), - soroban_sdk::vec![ - &t.env, - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("stg_pause").into_val(&t.env) - ], - ().into_val(&t.env), - ) - ] - ); -} - -#[test] -fn test_pause_withdrawals_emits_event() { - let t = setup(1, 1); - t.client.pause_withdrawals(); - assert_eq!( - t.env.events().all().filter_by_contract(&t.contract_id), - soroban_sdk::vec![ - &t.env, - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("wd_pause").into_val(&t.env) - ], - ().into_val(&t.env), - ) - ] - ); -} - -#[test] -fn test_unpause_staking_emits_event() { - let t = setup(1, 1); - t.client.pause_staking(); - t.client.unpause_staking(); - assert_eq!( - t.env.events().all().filter_by_contract(&t.contract_id), - soroban_sdk::vec![ - &t.env, - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("stg_unps").into_val(&t.env) - ], - ().into_val(&t.env), - ) - ] - ); -} - -#[test] -fn test_unpause_withdrawals_emits_event() { - let t = setup(1, 1); - t.client.pause_withdrawals(); - t.client.unpause_withdrawals(); - assert_eq!( - t.env.events().all().filter_by_contract(&t.contract_id), - soroban_sdk::vec![ - &t.env, - ( - t.contract_id.clone(), - soroban_sdk::vec![ - &t.env, - soroban_sdk::symbol_short!("pool").into_val(&t.env), - soroban_sdk::symbol_short!("wd_unps").into_val(&t.env) - ], - ().into_val(&t.env), - ) - ] - ); -} - -#[test] -fn test_pause_staking_blocks_new_stakes_but_allows_withdrawals() { - let t = setup(2, 1); - t.client.stake(&t.user, &1_000); - t.client.pause_staking(); - - assert!(t.client.try_stake(&t.user, &100i128).is_err()); - assert!(t.client.try_lock_assets(&t.user, &100i128).is_err()); - t.client.unstake(&t.user, &1_000); -} - -#[test] -fn test_pause_withdrawals_blocks_unstake_but_allows_new_stakes() { - let t = setup(2, 1); - t.client.pause_withdrawals(); - t.client.stake(&t.user, &1_000); - - assert!(t.client.try_unstake(&t.user, &1_000).is_err()); - assert!(t.client.try_unlock_assets(&t.user, &100i128).is_err()); -} - -#[test] -fn test_pause_blocks_stake() { - let t = setup(1, 1); - t.client.pause(); - - match t.client.try_stake(&t.user, &100i128) { - Err(Ok(PoolError::Paused)) => {} - other => panic!("expected PoolError::Paused, got: {:?}", other), - } -} - -#[test] -fn test_unpause_restores_stake() { - let t = setup(1, 1); - t.client.pause(); - t.client.unpause(); - t.client.stake(&t.user, &500); - assert_eq!(t.client.get_stake(&t.user).unwrap().amount, 500); -} - -#[test] -fn test_pause_blocks_unstake() { - let t = setup(1, 1); - t.client.stake(&t.user, &1_000); - t.client.pause(); - assert!(t.client.try_unstake(&t.user, &1_000).is_err()); -} - -#[test] -fn test_unpause_restores_unstake() { - let t = setup(1, 1); - t.client.stake(&t.user, &1_000); - t.client.pause(); - t.client.unpause(); - t.client.unstake(&t.user, &1_000); - assert!(t.client.get_stake(&t.user).is_none()); -} - -#[test] -fn test_pause_blocks_set_boost() { - let t = setup(1, 1); - t.client.stake(&t.user, &1_000); - t.client.pause(); - assert!(t.client.try_set_boost(&t.user, &50u32).is_err()); -} - -#[test] -fn test_unpause_restores_set_boost() { - let t = setup(1, 1); - t.client.stake(&t.user, &1_000); - t.client.pause(); - t.client.unpause(); - t.client.set_boost(&t.user, &50u32); - assert_eq!( - t.client.get_boost_config(&t.user).unwrap().allocation_pct, - 50 - ); -} - -#[test] -fn test_set_global_multiplier_callable_while_paused() { - let t = setup(1, 1); - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &50u32); - t.client.pause(); - t.client.set_global_multiplier(&3u32); - // The pool-level multiplier takes effect immediately; an existing - // staker's own snapshot (`get_boost_config`) only refreshes on their next - // interaction (stake/set_boost/etc.), by the same design as `credit_rate`. - assert_eq!(t.client.get_pool_info().global_multiplier, 3); -} - -// ── multi-user isolation ────────────────────────────────────────────────────── - -#[test] -fn test_multiple_users_independent_positions() { - let t = setup(1, 1); - let user2 = Address::generate(&t.env); - t.token_sac.mint(&user2, &500_000i128); - t.client.lock_assets(&t.user, &1_000); - t.client.lock_assets(&user2, &2_000); - advance_ledgers(&t.env, 10); - // Each user's credits are independent. - assert_eq!(t.client.calculate_credits(&t.user), 10_000); // 1000 * 10 - assert_eq!(t.client.calculate_credits(&user2), 20_000); // 2000 * 10 -} - -#[test] -fn test_one_user_unlock_does_not_affect_another() { - let t = setup(1, 1); - let user2 = Address::generate(&t.env); - t.token_sac.mint(&user2, &500_000i128); - t.client.lock_assets(&t.user, &1_000); - t.client.lock_assets(&user2, &2_000); - advance_ledgers(&t.env, 10); - t.client.unlock_assets(&t.user, &1_000); - // user2's position is untouched. - let pos2 = t - .client - .get_user_position(&user2) - .expect("user2 position should exist"); - assert_eq!(pos2.amount, 2_000); -} - -// ── emergency_withdraw tests ────────────────────────────────────────────────── - -#[test] -fn test_emergency_withdraw_while_paused() { - let t = setup(1, 1); - let initial_balance = t.token.balance(&t.user); - // Lock 500, stake 300, advance 10 ledgers so credits accrue. - t.client.lock_assets(&t.user, &500); - t.client.stake(&t.user, &300); - advance_ledgers(&t.env, 10); - // Trigger credit checkpoints: second lock banks 500*1*10=5_000 into pos.total_credits; - // second stake banks 300*1*10=3_000 into stake.credits_banked. - t.client.lock_assets(&t.user, &100); - t.client.stake(&t.user, &100); - t.client.pause(); - let returned = t.client.emergency_withdraw(&t.user); - // 600 locked + 400 staked = 1_000 total tokens returned. - assert_eq!(returned, 1_000); - assert_eq!(t.token.balance(&t.user), initial_balance); - assert!( - t.client.get_user_position(&t.user).is_none(), - "position should be cleared" - ); - assert!( - t.client.get_stake(&t.user).is_none(), - "stake should be cleared" - ); - // 5_000 (lock credits) + 3_000 (stake credits) preserved as a combined total. - assert_eq!(t.client.get_banked_credits(&t.user), 8_000); - // Individual histories must not be merged into a single figure (#145): the - // lock/unlock position and boost stake credits remain separately retrievable. - let split = t.client.get_banked_credits_split(&t.user); - assert_eq!(split.position_credits, 5_000); - assert_eq!(split.stake_credits, 3_000); -} - -#[test] -fn test_emergency_withdraw_banks_unbanked_credits() { - let t = setup(1, 1); - t.client.lock_assets(&t.user, &500); - t.client.stake(&t.user, &300); - advance_ledgers(&t.env, 10); - t.client.pause(); - t.client.emergency_withdraw(&t.user); - // No manual checkpoint: 500*1*10 and 300*1*10 must still be banked (#295). - let split = t.client.get_banked_credits_split(&t.user); - assert_eq!(split.position_credits, 5_000); - assert_eq!(split.stake_credits, 3_000); -} - -#[test] -fn test_emergency_withdraw_while_unpaused_returns_not_paused() { - let t = setup(1, 1); - t.client.lock_assets(&t.user, &1_000); - let result = t.client.try_emergency_withdraw(&t.user); - assert!(matches!(result, Err(Ok(PoolError::NotPaused)))); -} - -#[test] -fn test_emergency_withdraw_requires_user_auth() { - let (env, contract_id, client, _admin, user) = setup_without_mocked_auth(); - - // Attacker cannot withdraw user's funds without user's auth - let attacker = Address::generate(&env); - let unauth_result = client - .mock_auths(&[MockAuth { - address: &attacker, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "emergency_withdraw", - args: (&user,).into_val(&env), - sub_invokes: &[], - }, - }]) - .try_emergency_withdraw(&user); - assert!( - unauth_result.is_err(), - "third party cannot trigger emergency withdraw without user auth" - ); -} - -// ── Whitelist system tests ─────────────────────────────────────────────────── - -#[test] -fn test_whitelist_disabled_by_default_allows_all() { - let t = setup(2, 1); - // User can stake when whitelist is not enabled (disabled by default) - t.client.stake(&t.user, &1_000); - assert_eq!(t.client.get_stake(&t.user).unwrap().amount, 1_000); - - // User can lock when whitelist is not enabled - let user2 = Address::generate(&t.env); - t.token_sac.mint(&user2, &500_000i128); - t.client.lock_assets(&user2, &500); - assert_eq!(t.client.get_user_position(&user2).unwrap().amount, 500); -} - -#[test] -fn test_whitelist_blocks_unstaked_locked_unapproved() { - let t = setup(2, 1); - t.client.enable_whitelist(); - - // Unapproved user cannot stake - let result_stake = t.client.try_stake(&t.user, &1_000); - assert!(matches!(result_stake, Err(Ok(PoolError::NotWhitelisted)))); - - // Unapproved user cannot lock - let result_lock = t.client.try_lock_assets(&t.user, &500); - assert!(matches!(result_lock, Err(Ok(PoolError::NotWhitelisted)))); -} - -#[test] -fn test_user_added_and_removed_from_whitelist() { - let t = setup(2, 1); - t.client.enable_whitelist(); - - // Initially not whitelisted - assert!(!t.client.is_whitelisted(&t.user)); - - // Add user to whitelist - t.client.add_to_whitelist(&t.user); - assert!(t.client.is_whitelisted(&t.user)); - - // Now user can stake and lock - t.client.stake(&t.user, &1_000); - t.client.lock_assets(&t.user, &500); - assert_eq!(t.client.get_stake(&t.user).unwrap().amount, 1_000); - assert_eq!(t.client.get_user_position(&t.user).unwrap().amount, 500); - - // Remove user from whitelist - t.client.remove_from_whitelist(&t.user); - assert!(!t.client.is_whitelisted(&t.user)); - - // Removed user cannot stake additional tokens or lock additional tokens - let result_stake = t.client.try_stake(&t.user, &500); - assert!(matches!(result_stake, Err(Ok(PoolError::NotWhitelisted)))); -} - -#[test] -fn test_disable_whitelist_restores_open_access() { - let t = setup(2, 1); - t.client.enable_whitelist(); - - // Blocked initially - assert!(t.client.try_stake(&t.user, &1_000).is_err()); - - // Disable whitelist - t.client.disable_whitelist(); - - // Stake succeeds now - t.client.stake(&t.user, &1_000); - assert_eq!(t.client.get_stake(&t.user).unwrap().amount, 1_000); -} - -#[test] -fn test_whitelist_count_reflects_adds_and_removes() { - let t = setup(2, 1); - assert_eq!(t.client.whitelist_count(), 0); - assert_eq!(t.client.get_whitelist_count(), 0); - - let user1 = Address::generate(&t.env); - let user2 = Address::generate(&t.env); - - t.client.add_to_whitelist(&user1); - assert_eq!(t.client.whitelist_count(), 1); - - t.client.add_to_whitelist(&user2); - assert_eq!(t.client.whitelist_count(), 2); - - // Re-adding an existing entry must not double-count. - t.client.add_to_whitelist(&user1); - assert_eq!(t.client.whitelist_count(), 2); - - t.client.remove_from_whitelist(&user1); - assert_eq!(t.client.whitelist_count(), 1); - - // Removing a non-member is a no-op for the count. - t.client.remove_from_whitelist(&Address::generate(&t.env)); - assert_eq!(t.client.whitelist_count(), 1); - - t.client.remove_from_whitelist(&user2); - assert_eq!(t.client.whitelist_count(), 0); -} - -#[test] -fn test_whitelist_count_matches_get_whitelisted_users_total() { - let t = setup(2, 1); - - let mut users = soroban_sdk::Vec::new(&t.env); - for _ in 0..5 { - users.push_back(Address::generate(&t.env)); - } - t.client.batch_add_to_whitelist(&users); - - let listed = t.client.get_whitelisted_users(&0u32, &100u32); - assert_eq!(t.client.whitelist_count(), listed.total); - assert_eq!(t.client.whitelist_count(), 5); -} - -#[test] -fn test_whitelist_count_uninitialized_returns_not_initialized() { - let (_env, client, _admin) = setup_uninitialized(); - assert!(matches!( - client.try_whitelist_count(), - Err(Ok(PoolError::NotInitialized)) - )); -} - -#[test] -fn test_batch_add_to_whitelist() { - let t = setup(2, 1); - t.client.enable_whitelist(); - - let user1 = Address::generate(&t.env); - let user2 = Address::generate(&t.env); - let user3 = Address::generate(&t.env); - - t.token_sac.mint(&user1, &500_000i128); - t.token_sac.mint(&user2, &500_000i128); - t.token_sac.mint(&user3, &500_000i128); - - let mut users = soroban_sdk::Vec::new(&t.env); - users.push_back(user1.clone()); - users.push_back(user2.clone()); - users.push_back(user3.clone()); - - t.client.batch_add_to_whitelist(&users); - - assert!(t.client.is_whitelisted(&user1)); - assert!(t.client.is_whitelisted(&user2)); - assert!(t.client.is_whitelisted(&user3)); - - // They can all stake - t.client.stake(&user1, &100); - t.client.stake(&user2, &100); - t.client.stake(&user3, &100); - - assert_eq!(t.client.get_stake(&user1).unwrap().amount, 100); - assert_eq!(t.client.get_stake(&user2).unwrap().amount, 100); - assert_eq!(t.client.get_stake(&user3).unwrap().amount, 100); -} - -#[test] -fn test_batch_add_to_whitelist_exceeds_limit() { - let t = setup(2, 1); - let mut users = soroban_sdk::Vec::new(&t.env); - for _ in 0..51 { - users.push_back(Address::generate(&t.env)); - } - assert_eq!( - t.client.try_batch_add_to_whitelist(&users), - Err(Ok(PoolError::BatchTooLarge)) - ); -} - -#[test] -fn test_batch_remove_from_whitelist() { - let t = setup(2, 1); - t.client.enable_whitelist(); - - let user1 = Address::generate(&t.env); - let user2 = Address::generate(&t.env); - let user3 = Address::generate(&t.env); - - let mut users = soroban_sdk::Vec::new(&t.env); - users.push_back(user1.clone()); - users.push_back(user2.clone()); - users.push_back(user3.clone()); - - t.client.batch_add_to_whitelist(&users); - assert!(t.client.is_whitelisted(&user1)); - assert!(t.client.is_whitelisted(&user2)); - assert!(t.client.is_whitelisted(&user3)); - - // Batch remove exactly mirrors the batch add (#167): all three gone at once. - t.client.batch_remove_from_whitelist(&users); - assert!(!t.client.is_whitelisted(&user1)); - assert!(!t.client.is_whitelisted(&user2)); - assert!(!t.client.is_whitelisted(&user3)); - - // Removed users can no longer stake. - let result_stake = t.client.try_stake(&user1, &100); - assert!(matches!(result_stake, Err(Ok(PoolError::NotWhitelisted)))); -} - -#[test] -fn test_batch_remove_from_whitelist_exceeds_limit() { - let t = setup(2, 1); - let mut users = soroban_sdk::Vec::new(&t.env); - for _ in 0..51 { - users.push_back(Address::generate(&t.env)); - } - assert_eq!( - t.client.try_batch_remove_from_whitelist(&users), - Err(Ok(PoolError::BatchTooLarge)) - ); -} - -#[test] -fn test_batch_remove_from_whitelist_requires_admin_auth() { - let (env, contract_id, client, _admin, user) = setup_without_mocked_auth(); - - let users = soroban_sdk::vec![&env, user.clone()]; - let result = client - .mock_auths(&[MockAuth { - address: &user, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "batch_remove_from_whitelist", - args: (users.clone(),).into_val(&env), - sub_invokes: &[], - }, - }]) - .try_batch_remove_from_whitelist(&users); - - assert!( - result.is_err(), - "non-admin batch_remove_from_whitelist must be rejected" - ); -} - -#[test] -#[should_panic(expected = "Error(Contract, #6)")] -fn test_set_min_stake_amount_lock_assets() { - let t = setup(1, 1); - - t.client.lock_assets(&t.user, &10i128); -} - -#[test] -#[should_panic(expected = "Error(Contract, #6)")] -fn test_set_min_stake_amount_stake() { - let t = setup(1, 1); - - t.client.stake(&t.user, &50); -} - -#[test] -fn test_set_min_stake_amount_lock_assets_pass() { - let t = setup(1, 1); - - t.client.lock_assets(&t.user, &100i128); -} - -#[test] -fn test_set_min_stake_amount() { - let t = setup(1, 1); - - let min_stake = t.client.get_min_stake_amount(); - assert_eq!(min_stake, 100); - - let amount = 200_i128; - - t.client.set_min_stake_amount(&amount); - let min_stake = t.client.get_min_stake_amount(); - assert_eq!(min_stake, amount); -} -// ── lock_assets checks-effects-interactions (#69) ───────────────────────────── -// -// `stake_token` is an admin-supplied address, not necessarily a trusted -// Stellar Asset Contract — a non-standard `transfer` implementation could -// attempt to call back into FarmingPool before returning. Empirically -// (verified against this project's soroban-env-host 25.0.1), Soroban's host -// already rejects same-contract reentrancy outright — `ContractReentryMode` -// defaults to `Prohibited`, so any attempt to call back into a contract -// that's already on the invocation's call stack traps with "Contract -// re-entry is not allowed" *before* any of our code (fixed or not) runs. -// These tests exercise both realistic reentrant-token shapes and confirm -// the CEI reordering fix doesn't change correct-path behavior. - -use crate::mock_reentrant_token::{MockNaiveReentrantToken, MockNaiveReentrantTokenClient}; - -extern crate std; - -#[test] -fn test_lock_assets_reentrant_transfer_is_rejected_and_final_state_is_correct() { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let user = Address::generate(&env); - - let farming_pool_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &farming_pool_id); - - let token_id = env.register(MockReentrantToken, ()); - let token_client = MockReentrantTokenClient::new(&env, &token_id); - token_client.configure(&farming_pool_id, &user); - - client.initialize(&admin, &token_id, &2u32, &100i128, &0u32, &0i128); - - // Succeeds fully: the mock token catches the rejected reentry gracefully - // (via try_invoke_contract) rather than trapping the whole call. - client.lock_assets(&user, &500i128); - - // The reentrant get_user_position call — attempted mid-transfer, before - // lock_assets would have returned — was rejected by the host. - assert!(token_client.reentry_was_rejected()); - - // And with set_position now happening before the transfer, the position - // this call was computing is correctly persisted once it completes. - let position = client.get_user_position(&user).unwrap(); - assert_eq!(position.amount, 500); -} - -#[test] -fn test_lock_assets_reverts_entirely_if_stake_token_naively_reenters() { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let user = Address::generate(&env); - - let farming_pool_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &farming_pool_id); - - let token_id = env.register(MockNaiveReentrantToken, ()); - let token_client = MockNaiveReentrantTokenClient::new(&env, &token_id); - token_client.configure(&farming_pool_id, &user); - - client.initialize(&admin, &token_id, &2u32, &100i128, &0u32, &0i128); - - // The naive mock token doesn't catch the host's rejection, so the - // reentrant call traps — and with it, the entire lock_assets invocation, - // including our set_position write. Assert the whole call aborts rather - // than silently succeeding or leaving a partial state behind. - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - client.lock_assets(&user, &500i128); - })); - assert!( - result.is_err(), - "lock_assets should trap when stake_token attempts reentrancy" - ); - - // Soroban's per-invocation atomicity means the trap rolled back - // everything, including the effects-first set_position write — no - // partial position was left behind. - assert!(client.get_user_position(&user).is_none()); -} - -// ── stake / unstake / unlock_assets / emergency_withdraw reentrancy -// coverage (#128) ────────────────────────────────────────────────────────── -// -// `lock_assets` was the only function with MockReentrantToken/ -// MockNaiveReentrantToken coverage, despite `stake`, `unstake`, -// `unlock_assets`, and `emergency_withdraw` sharing the identical threat -// model documented above `lock_assets` (an admin-supplied, not necessarily -// trusted `stake_token`) — and despite being exactly the functions #70, -// #71, and #72 flag for transfer-before-state-update ordering, which makes -// this coverage doubly valuable as a regression suite once those land. -// -// Pre-existing Position/UserStake state some of these tests need is seeded -// directly via `set_position`/`set_user_stake` under `env.as_contract` -// rather than by first calling `lock_assets`/`stake` normally: `stake_token` -// is fixed at `initialize` and has no setter, so a token already configured -// to reenter would also reenter during that setup call. For the graceful -// mock that's merely redundant; for the naive mock it's fatal — that setup -// call would itself trap, long before the function actually under test ever -// runs. - -fn seed_position(env: &Env, contract_id: &Address, user: &Address, amount: i128) { - env.as_contract(contract_id, || { - let current = env.ledger().sequence(); - set_position( - env, - user, - &Position { - amount, - lock_ledger: current, - unlock_ledger: current, - checkpoint_ledger: current, - total_credits: 0, - credit_rate: read_credit_rate(env), - }, - ); - }); -} - -fn seed_user_stake(env: &Env, contract_id: &Address, user: &Address, amount: i128) { - env.as_contract(contract_id, || { - let current = env.ledger().sequence(); - set_user_stake( - env, - user, - &UserStake { - amount, - start_ledger: current, - credits_banked: 0, - credit_rate: read_credit_rate(env), - multiplier: read_global_multiplier(env), - }, - ); - }); -} - -// ── stake (top-up branch) ────────────────────────────────────────────────── - -#[test] -fn test_stake_topup_reentrant_transfer_is_rejected_and_final_state_is_correct() { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let user = Address::generate(&env); - - let farming_pool_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &farming_pool_id); - - let token_id = env.register(MockReentrantToken, ()); - let token_client = MockReentrantTokenClient::new(&env, &token_id); - token_client.configure(&farming_pool_id, &user); - - client.initialize(&admin, &token_id, &2u32, &100i128, &0u32, &0i128); - - // Pre-existing stake to top up — the branch with more state a reentrant - // call could observe mid-update, per the issue's own callout. - seed_user_stake(&env, &farming_pool_id, &user, 500i128); - - // Reenter the same state-mutating function under test, not just a - // read-only getter — the more realistic worst-case reentry attempt. - let reentrant_args: soroban_sdk::Vec = - soroban_sdk::vec![&env, user.clone().into_val(&env), 200i128.into_val(&env)]; - token_client.configure_reentrant_call(&Symbol::new(&env, "stake"), &reentrant_args); - - client.stake(&user, &200i128); - - assert!(token_client.reentry_was_rejected()); - - let stake = client.get_stake(&user).unwrap(); - assert_eq!(stake.amount, 700); -} - -#[test] -fn test_stake_reverts_entirely_if_stake_token_naively_reenters() { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let user = Address::generate(&env); - - let farming_pool_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &farming_pool_id); - - let token_id = env.register(MockNaiveReentrantToken, ()); - let token_client = MockNaiveReentrantTokenClient::new(&env, &token_id); - token_client.configure(&farming_pool_id, &user); - - client.initialize(&admin, &token_id, &2u32, &100i128, &0u32, &0i128); - - seed_user_stake(&env, &farming_pool_id, &user, 500i128); - - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - client.stake(&user, &200i128); - })); - assert!( - result.is_err(), - "stake should trap when stake_token attempts reentrancy" - ); - - // Trap rolled back the whole call, including set_user_stake — the - // seeded stake is untouched, no top-up applied. - let stake = client.get_stake(&user).unwrap(); - assert_eq!(stake.amount, 500); -} - -// ── stake validation (top-up) ──────────────────────────────────────────────── - -#[test] -fn test_stake_topup_min_stake_applies_to_resulting_position() { - // The minimum is checked against the caller's total position, as in - // `lock_assets`. A top-up smaller than the minimum is accepted when the - // resulting position clears it. - let t = setup(1, 1); - t.client.stake(&t.user, &100); - t.client.set_min_stake_amount(&500); - - t.client.stake(&t.user, &450); - - assert_eq!(t.client.get_stake(&t.user).unwrap().amount, 550); -} - -#[test] -#[should_panic(expected = "Error(Contract, #6)")] -fn test_stake_topup_below_min_stake_for_resulting_position_is_rejected() { - let t = setup(1, 1); - t.client.stake(&t.user, &100); - t.client.set_min_stake_amount(&500); - - // 100 + 100 = 200 total, still below the 500 minimum. - t.client.stake(&t.user, &100); -} - -#[test] -#[should_panic(expected = "Error(Contract, #18)")] -fn test_stake_topup_exceeding_max_stake_returns_typed_error() { - let t = setup(1, 1); - t.token_sac.mint(&t.user, &2_000_000_000_000_000_000i128); - t.client.stake(&t.user, &1_000_000_000_000_000_000i128); - - // Total would be 10^18 + 1, one above MAX_STAKE_AMOUNT. - t.client.stake(&t.user, &1); -} - -// ── unstake ───────────────────────────────────────────────────────────────── - -#[test] -fn test_unstake_reentrant_transfer_is_rejected_and_final_state_is_correct() { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let user = Address::generate(&env); - - let farming_pool_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &farming_pool_id); - - let token_id = env.register(MockReentrantToken, ()); - let token_client = MockReentrantTokenClient::new(&env, &token_id); - token_client.configure(&farming_pool_id, &user); - - client.initialize(&admin, &token_id, &2u32, &100i128, &0u32, &0i128); - - seed_user_stake(&env, &farming_pool_id, &user, 1_000i128); - - let reentrant_args: soroban_sdk::Vec = - soroban_sdk::vec![&env, user.clone().into_val(&env)]; - token_client.configure_reentrant_call(&Symbol::new(&env, "unstake"), &reentrant_args); - - client.unstake(&user, &1_000); - - assert!(token_client.reentry_was_rejected()); - assert!(client.get_stake(&user).is_none()); -} - -#[test] -fn test_unstake_reverts_entirely_if_stake_token_naively_reenters() { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let user = Address::generate(&env); - - let farming_pool_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &farming_pool_id); - - let token_id = env.register(MockNaiveReentrantToken, ()); - let token_client = MockNaiveReentrantTokenClient::new(&env, &token_id); - token_client.configure(&farming_pool_id, &user); - - client.initialize(&admin, &token_id, &2u32, &100i128, &0u32, &0i128); - - seed_user_stake(&env, &farming_pool_id, &user, 500i128); - - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - client.unstake(&user, &500); - })); - assert!( - result.is_err(), - "unstake should trap when stake_token attempts reentrancy" - ); - - // Trap rolled back the whole call — the seeded stake is still present, - // no unstake applied. - let stake = client.get_stake(&user).unwrap(); - assert_eq!(stake.amount, 500); -} - -// ── unlock_assets ─────────────────────────────────────────────────────────── - -#[test] -fn test_unlock_assets_reentrant_transfer_is_rejected_and_final_state_is_correct() { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let user = Address::generate(&env); - - let farming_pool_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &farming_pool_id); - - let token_id = env.register(MockReentrantToken, ()); - let token_client = MockReentrantTokenClient::new(&env, &token_id); - token_client.configure(&farming_pool_id, &user); - - client.initialize(&admin, &token_id, &2u32, &100i128, &0u32, &0i128); - - seed_position(&env, &farming_pool_id, &user, 500i128); - - let reentrant_args: soroban_sdk::Vec = - soroban_sdk::vec![&env, user.clone().into_val(&env), 200i128.into_val(&env)]; - token_client.configure_reentrant_call(&Symbol::new(&env, "unlock_assets"), &reentrant_args); - - client.unlock_assets(&user, &200i128); - - assert!(token_client.reentry_was_rejected()); - - let position = client.get_user_position(&user).unwrap(); - assert_eq!(position.amount, 300); -} - -#[test] -fn test_unlock_assets_reverts_entirely_if_stake_token_naively_reenters() { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let user = Address::generate(&env); - - let farming_pool_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &farming_pool_id); - - let token_id = env.register(MockNaiveReentrantToken, ()); - let token_client = MockNaiveReentrantTokenClient::new(&env, &token_id); - token_client.configure(&farming_pool_id, &user); - - client.initialize(&admin, &token_id, &2u32, &100i128, &0u32, &0i128); - - seed_position(&env, &farming_pool_id, &user, 500i128); - - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - client.unlock_assets(&user, &200i128); - })); - assert!( - result.is_err(), - "unlock_assets should trap when stake_token attempts reentrancy" - ); - - // Trap rolled back the whole call — the seeded position is untouched, - // no partial unlock was applied. - let position = client.get_user_position(&user).unwrap(); - assert_eq!(position.amount, 500); -} - -// ── emergency_withdraw ─────────────────────────────────────────────────────── -// -// Makes two separate token.transfer calls in sequence when a user has both -// a Position and a UserStake — the two tests below target reentry during -// the first (Position-only) and second (Position + UserStake) transfer -// respectively, since `reentry_was_rejected()` reflects the most recent -// `transfer` call. - -#[test] -fn test_emergency_withdraw_reentrant_transfer_during_position_payout_is_rejected_and_final_state_is_correct( -) { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let user = Address::generate(&env); - - let farming_pool_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &farming_pool_id); - - let token_id = env.register(MockReentrantToken, ()); - let token_client = MockReentrantTokenClient::new(&env, &token_id); - token_client.configure(&farming_pool_id, &user); - - client.initialize(&admin, &token_id, &2u32, &100i128, &0u32, &0i128); - - seed_position(&env, &farming_pool_id, &user, 500i128); - client.pause(); - - let reentrant_args: soroban_sdk::Vec = - soroban_sdk::vec![&env, user.clone().into_val(&env)]; - token_client - .configure_reentrant_call(&Symbol::new(&env, "emergency_withdraw"), &reentrant_args); - - let returned = client.emergency_withdraw(&user); - - assert!(token_client.reentry_was_rejected()); - assert_eq!(returned, 500); - assert!(client.get_user_position(&user).is_none()); -} - -#[test] -fn test_emergency_withdraw_reentrant_transfer_during_stake_payout_is_rejected_and_final_state_is_correct( -) { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let user = Address::generate(&env); - - let farming_pool_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &farming_pool_id); - - let token_id = env.register(MockReentrantToken, ()); - let token_client = MockReentrantTokenClient::new(&env, &token_id); - token_client.configure(&farming_pool_id, &user); - - client.initialize(&admin, &token_id, &2u32, &100i128, &0u32, &0i128); - - seed_position(&env, &farming_pool_id, &user, 500i128); - seed_user_stake(&env, &farming_pool_id, &user, 300i128); - client.pause(); - - let reentrant_args: soroban_sdk::Vec = - soroban_sdk::vec![&env, user.clone().into_val(&env)]; - token_client - .configure_reentrant_call(&Symbol::new(&env, "emergency_withdraw"), &reentrant_args); - - let returned = client.emergency_withdraw(&user); - - assert!(token_client.reentry_was_rejected()); - assert_eq!(returned, 800); - assert!(client.get_user_position(&user).is_none()); - assert!(client.get_stake(&user).is_none()); -} - -#[test] -fn test_emergency_withdraw_reverts_entirely_if_stake_token_naively_reenters() { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let user = Address::generate(&env); - - let farming_pool_id = env.register(FarmingPool, ()); - let client = FarmingPoolClient::new(&env, &farming_pool_id); - - let token_id = env.register(MockNaiveReentrantToken, ()); - let token_client = MockNaiveReentrantTokenClient::new(&env, &token_id); - token_client.configure(&farming_pool_id, &user); - - client.initialize(&admin, &token_id, &2u32, &100i128, &0u32, &0i128); - - seed_position(&env, &farming_pool_id, &user, 500i128); - seed_user_stake(&env, &farming_pool_id, &user, 300i128); - client.pause(); - - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - client.emergency_withdraw(&user); - })); - assert!( - result.is_err(), - "emergency_withdraw should trap when stake_token attempts reentrancy" - ); - - // Trap rolled back everything — both seeded records are untouched, and - // neither of emergency_withdraw's two transfers actually completed. - let position = client.get_user_position(&user).unwrap(); - assert_eq!(position.amount, 500); - let stake = client.get_stake(&user).unwrap(); - assert_eq!(stake.amount, 300); -} - -#[test] -fn test_staked_user_count_increments_and_decrements_correctly() { - let t = setup(1, 10); - assert_eq!(t.client.staked_user_count(), 0); - assert_eq!(t.client.get_staked_user_count(), 0); - - let user2 = Address::generate(&t.env); - t.token_sac.mint(&user2, &10_000); - - // User 1 stakes: count becomes 1 - t.client.stake(&t.user, &1_000); - assert_eq!(t.client.staked_user_count(), 1); - - // User 1 stakes more: count stays 1 - t.client.stake(&t.user, &500); - assert_eq!(t.client.staked_user_count(), 1); - - // User 2 locks position: count becomes 2 - t.client.lock_assets(&user2, &2_000); - assert_eq!(t.client.staked_user_count(), 2); - - // User 1 unstakes completely: count becomes 1 - t.client.unstake(&t.user, &1_500); - assert_eq!(t.client.staked_user_count(), 1); - - // User 2 unlocks position completely: count becomes 0 - advance_ledgers(&t.env, 10); - t.client.unlock_assets(&user2, &2_000); - assert_eq!(t.client.staked_user_count(), 0); -} - -#[test] -fn test_lock_assets_top_up_extends_unlock_ledger() { - let t = setup_with_lock_period(1, 10, 10); - let start_ledger = t.env.ledger().sequence(); - - // Initial lock of 1,000 for 10 ledgers - t.client.lock_assets(&t.user, &1_000); - let pos1 = t.client.get_user_position(&t.user).unwrap(); - assert_eq!(pos1.unlock_ledger, start_ledger + 10); - - // Advance ledgers by 5 - advance_ledgers(&t.env, 5); - - // Top-up lock of 500: fresh lock period extends unlock_ledger to start_ledger + 5 + 10 = start_ledger + 15 - t.client.lock_assets(&t.user, &500); - let pos2 = t.client.get_user_position(&t.user).unwrap(); - assert_eq!(pos2.amount, 1_500); - assert_eq!(pos2.unlock_ledger, start_ledger + 15); - - // Trying to unlock at ledger start_ledger + 12 should fail - advance_ledgers(&t.env, 7); // now sequence is start_ledger + 12 - assert!( - t.client.try_unlock_assets(&t.user, &1_500).is_err(), - "unlock before extended lock period must fail" - ); - - // Advancing past start_ledger + 15 allows full unlock - advance_ledgers(&t.env, 3); // now sequence is start_ledger + 15 - t.client.unlock_assets(&t.user, &1_500); -} - -#[test] -fn test_lock_count_increments_on_every_lock_operation() { - let t = setup(1, 10); - assert_eq!(t.client.lock_count(), 0); - assert_eq!(t.client.get_lock_count(), 0); - - let user2 = Address::generate(&t.env); - t.token_sac.mint(&user2, &10_000); - - // Flexible staking does not affect lock_count - t.client.stake(&t.user, &1_000); - assert_eq!(t.client.lock_count(), 0); - t.client.unstake(&t.user, &1_000); - assert_eq!(t.client.lock_count(), 0); - - // User 1 locks: lock_count becomes 1 - t.client.lock_assets(&t.user, &1_000); - assert_eq!(t.client.lock_count(), 1); - assert_eq!(t.client.get_lock_count(), 1); - - // User 2 locks: lock_count becomes 2 - t.client.lock_assets(&user2, &2_000); - assert_eq!(t.client.lock_count(), 2); - - // User 1 top-up (additional lock operation): lock_count becomes 3 - t.client.lock_assets(&t.user, &500); - assert_eq!(t.client.lock_count(), 3); - - // Unlocking assets does not decrement lock_count - advance_ledgers(&t.env, 10); - t.client.unlock_assets(&user2, &2_000); - assert_eq!(t.client.lock_count(), 3); -} - -#[test] -fn test_unstake_count_increments_on_every_unstake_operation() { - let t = setup(1, 10); - assert_eq!(t.client.unstake_count(), 0); - assert_eq!(t.client.get_unstake_count(), 0); - - let user2 = Address::generate(&t.env); - t.token_sac.mint(&user2, &10_000); - - // Staking does not affect unstake_count - t.client.stake(&t.user, &1_000); - t.client.stake(&user2, &2_000); - assert_eq!(t.client.unstake_count(), 0); - - // Lock/unlock operations do not affect unstake_count - let user3 = Address::generate(&t.env); - t.token_sac.mint(&user3, &10_000); - t.client.lock_assets(&user3, &1_000); - assert_eq!(t.client.unstake_count(), 0); - - // User 1 unstakes: unstake_count becomes 1 - t.client.unstake(&t.user, &1_000); - assert_eq!(t.client.unstake_count(), 1); - assert_eq!(t.client.get_unstake_count(), 1); - - // User 2 unstakes: unstake_count becomes 2 - t.client.unstake(&user2, &1_000); - assert_eq!(t.client.unstake_count(), 2); - assert_eq!(t.client.get_unstake_count(), 2); - - // Unlocking assets does not increment unstake_count - advance_ledgers(&t.env, 10); - t.client.unlock_assets(&user3, &1_000); - assert_eq!(t.client.unstake_count(), 2); -} - -#[test] -fn test_get_all_positions_returns_empty_when_no_positions() { - let t = setup(1, 1); - let positions = t.client.get_all_positions(&0u32, &100u32); - assert_eq!(positions.len(), 0); -} - -#[test] -fn test_get_all_positions_returns_all_positions() { - let t = setup(1, 1); - let user2 = Address::generate(&t.env); - let user3 = Address::generate(&t.env); - t.token_sac.mint(&user2, &10_000); - t.token_sac.mint(&user3, &10_000); - - t.client.lock_assets(&t.user, &1_000); - t.client.lock_assets(&user2, &2_000); - t.client.lock_assets(&user3, &3_000); - - let positions = t.client.get_all_positions(&0u32, &100u32); - assert_eq!(positions.len(), 3); - - // Verify each position has the correct amount - let mut found_1000 = false; - let mut found_2000 = false; - let mut found_3000 = false; - for (_, p) in positions.iter() { - if p.amount == 1_000 { - found_1000 = true; - } - if p.amount == 2_000 { - found_2000 = true; - } - if p.amount == 3_000 { - found_3000 = true; - } - } - assert!(found_1000, "should find position with amount 1000"); - assert!(found_2000, "should find position with amount 2000"); - assert!(found_3000, "should find position with amount 3000"); -} - -#[test] -fn test_get_all_positions_paginates_with_limit() { - let t = setup(1, 1); - let user2 = Address::generate(&t.env); - let user3 = Address::generate(&t.env); - t.token_sac.mint(&user2, &10_000); - t.token_sac.mint(&user3, &10_000); - - t.client.lock_assets(&t.user, &1_000); - t.client.lock_assets(&user2, &2_000); - t.client.lock_assets(&user3, &3_000); - - // Request only 2 positions - let positions = t.client.get_all_positions(&0u32, &2u32); - assert_eq!(positions.len(), 2); -} - -#[test] -fn test_get_all_positions_with_start_offset() { - let t = setup(1, 1); - let user2 = Address::generate(&t.env); - let user3 = Address::generate(&t.env); - t.token_sac.mint(&user2, &10_000); - t.token_sac.mint(&user3, &10_000); - - t.client.lock_assets(&t.user, &1_000); - t.client.lock_assets(&user2, &2_000); - t.client.lock_assets(&user3, &3_000); - - // Skip first position, get remaining - let positions = t.client.get_all_positions(&1u32, &100u32); - assert_eq!(positions.len(), 2); -} - -#[test] -fn test_get_all_positions_start_beyond_total_returns_empty() { - let t = setup(1, 1); - t.client.lock_assets(&t.user, &1_000); - - let positions = t.client.get_all_positions(&10u32, &100u32); - assert_eq!(positions.len(), 0); -} - -#[test] -fn test_get_all_positions_after_unlock_excludes_removed_position() { - let t = setup(1, 1); - let user2 = Address::generate(&t.env); - t.token_sac.mint(&user2, &10_000); - - t.client.lock_assets(&t.user, &1_000); - t.client.lock_assets(&user2, &2_000); - - // Advance past lock period and unlock user1 - advance_ledgers(&t.env, 100); - t.client.unlock_assets(&t.user, &1_000); - - let positions = t.client.get_all_positions(&0u32, &100u32); - assert_eq!(positions.len(), 1); - assert_eq!(positions.get(0).unwrap().1.amount, 2_000); -} - -#[test] -fn test_checkpoint_emits_chkpt_event() { - let t = setup(1, 10); - t.client.stake(&t.user, &1_000); - advance_ledgers(&t.env, 10); - t.client.stake(&t.user, &500); - - let events = t.env.events().all().filter_by_contract(&t.contract_id); - assert_ne!(events, soroban_sdk::vec![&t.env]); -} - -#[test] -fn test_active_stake_count_lifecycle() { - let t = setup(1, 10); - assert_eq!(t.client.active_stake_count(), 0); - assert_eq!(t.client.get_active_stake_count(), 0); - - let user2 = Address::generate(&t.env); - t.token_sac.mint(&user2, &10_000); - - // User 1 stakes: active_stake_count becomes 1 - t.client.stake(&t.user, &1_000); - assert_eq!(t.client.active_stake_count(), 1); - assert_eq!(t.client.get_active_stake_count(), 1); - - // User 1 stakes more (top up): active_stake_count remains 1 - t.client.stake(&t.user, &500); - assert_eq!(t.client.active_stake_count(), 1); - - // User 2 stakes: active_stake_count becomes 2 - t.client.stake(&user2, &2_000); - assert_eq!(t.client.active_stake_count(), 2); - - // User 3 locks position: locked position does not increment active_stake_count - let user3 = Address::generate(&t.env); - t.token_sac.mint(&user3, &10_000); - t.client.lock_assets(&user3, &1_000); - assert_eq!(t.client.active_stake_count(), 2); - - // User 1 unstakes: active_stake_count becomes 1 - t.client.unstake(&t.user, &1_500); - assert_eq!(t.client.active_stake_count(), 1); - - // Pool pauses, User 2 emergency withdraws: active_stake_count becomes 0 - t.client.pause(); - t.client.emergency_withdraw(&user2); - assert_eq!(t.client.active_stake_count(), 0); - assert_eq!(t.client.get_active_stake_count(), 0); -} - -#[test] -fn test_credit_rate_change_count_tracking() { - let t = setup(1, 10); - assert_eq!(t.client.credit_rate_change_count(), 0); - assert_eq!(t.client.get_credit_rate_change_count(), 0); - - // First rate change - t.client.set_credit_rate(&5i128); - assert_eq!(t.client.credit_rate_change_count(), 1); - assert_eq!(t.client.get_credit_rate_change_count(), 1); - - // Second rate change - t.client.set_credit_rate(&10i128); - assert_eq!(t.client.credit_rate_change_count(), 2); - assert_eq!(t.client.get_credit_rate_change_count(), 2); - - // Invalid rate change does not increment count - assert!(t.client.try_set_credit_rate(&0i128).is_err()); - assert_eq!(t.client.credit_rate_change_count(), 2); - assert_eq!(t.client.get_credit_rate_change_count(), 2); -} - -#[test] -fn test_migrate_schema_version_framework() { - let t = setup(1, 10); - let prev = t.client.migrate(); - assert_eq!(prev, 1); -} - -// ── #284: compute_total_stake must not truncate small boosts to zero ───────── - -#[test] -fn test_compute_total_stake_preserves_small_boosts() { - // Single trailing division: amount * (100 - alloc + alloc * mult) / 100. - // Cases where the old split form (`amount * alloc / 100` first) truncated - // `boosted` to zero and silently disabled the boost. - assert_eq!(compute_total_stake(1, 1, 1000), 10); // 1 * 1099 / 100 - assert_eq!(compute_total_stake(10, 1, 1000), 109); // 10 * 1099 / 100 - assert_eq!(compute_total_stake(99, 1, 1000), 1088); // 99 * 1099 / 100 - assert_eq!(compute_total_stake(1, 100, 2), 2); - assert_eq!(compute_total_stake(50, 50, 2), 75); -} - -#[test] -fn test_compute_total_stake_matches_split_form_for_large_amounts() { - // Refactor guard: for amounts divisible by 100 both formulations agree, - // so existing 1_000-based expectations are unaffected by the #284 fix. - let cases = [(1_000u32, 50u32, 2u32, 1_500i128), (1_000, 100, 2, 2_000)]; - for (amount, alloc, mult, expected) in cases { - assert_eq!(compute_total_stake(amount as i128, alloc, mult), expected); - } -} - -// ── #285: set_boost requires an active flexible stake ──────────────────────── - -#[test] -fn test_set_boost_rejects_position_only_user() { - // Boost accrues only via the UserStake path: a time-locked Position alone - // must not satisfy set_boost's active-stake check. - let t = setup(2, 1); - t.client.lock_assets(&t.user, &1_000); - let res = t.client.try_set_boost(&t.user, &50u32); - assert_eq!(res, Err(Ok(PoolError::NoActiveStake))); -} - -// ── #286: set_global_multiplier has zero effect on the Position path ──────── - -#[test] -fn test_set_global_multiplier_has_zero_effect_on_position_accrual() { - // Position accrual is amount * credit_rate * elapsed with no multiplier - // term, so bumping the global multiplier must leave both the accrued view - // and future accrual unchanged. - let t = setup(2, 1); - t.client.lock_assets(&t.user, &1_000); - advance_ledgers(&t.env, 10); - assert_eq!(t.client.calculate_credits(&t.user), 10_000); - - t.client.set_global_multiplier(&100u32); - // Already-accrued credits are untouched by the change itself. - assert_eq!(t.client.calculate_credits(&t.user), 10_000); - - advance_ledgers(&t.env, 10); - // Future position accrual still ignores the multiplier. - assert_eq!(t.client.calculate_credits(&t.user), 20_000); - assert_eq!(t.client.get_position_credits(&t.user), 20_000); -} - -#[test] -fn test_set_global_multiplier_does_not_rewrite_already_accrued_stake_credits() { - // Pin the already-accrued portion: changing the multiplier with no ledger - // advance must not move the read-only view; only later ledgers accrue at - // the new multiplier. - let t = setup(2, 1); - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &50u32); - advance_ledgers(&t.env, 10); - assert_eq!(t.client.get_credits(&t.user), 15_000); - - t.client.set_global_multiplier(&3u32); - assert_eq!(t.client.get_credits(&t.user), 15_000); - - advance_ledgers(&t.env, 10); - assert_eq!(t.client.get_credits(&t.user), 35_000); -} - -// ── #287: i128 overflow boundaries for compute_total_stake/compute_credits ── - -#[test] -fn test_compute_total_stake_i128_boundary_table() { - // Deterministic boundary sweep: every case is cross-checked against the - // checked-arithmetic oracle, so any silent wrapping in the implementation - // fails the test. Values approach i128::MAX quotients without exceeding - // them, plus tiny-amount boost cases from #284. - let cases: [(i128, u32, u32); 8] = [ - (1, 1, 1000), - (99, 1, 1000), - (10_000_000_000_000_000_000, 100, 1000), - (i128::MAX / 200_000, 100, 1000), - (i128::MAX / 101, 1, 2), - (1_000_000_000_000_000_000, 50, 1000), - (i128::MAX / 100_100, 100, 1000), - (0, 100, 1000), - ]; - for (amount, alloc, mult) in cases { - let alloc_i = alloc as i128; - let mult_i = mult as i128; - let factor = (100i128 - alloc_i) - .checked_add(alloc_i.checked_mul(mult_i).unwrap()) - .unwrap(); - let expected = amount - .checked_mul(factor) - .and_then(|v| v.checked_div(100)) - .unwrap(); - assert_eq!(compute_total_stake(amount, alloc, mult), expected); - } -} - -#[test] -fn test_compute_credits_i128_overflow_boundaries() { - // The #89 ceiling product must fit with the documented 16x headroom, and - // inputs beyond it must observably overflow checked arithmetic — proving - // the boundary is load-bearing rather than arbitrary. - const AMOUNT_MAX: i128 = 1_000_000_000_000_000_000; - const ELAPSED_MAX: i128 = 63_072_000; - let worst = AMOUNT_MAX - .checked_mul(MAX_GLOBAL_MULTIPLIER as i128) - .and_then(|v| v.checked_mul(MAX_CREDIT_RATE)) - .and_then(|v| v.checked_mul(ELAPSED_MAX)) - .expect("ceiling product must fit in i128"); - assert!(worst <= i128::MAX / 16); - assert_eq!( - compute_credits( - AMOUNT_MAX, - 100, - MAX_GLOBAL_MULTIPLIER, - MAX_CREDIT_RATE, - ELAPSED_MAX as u32 - ), - worst - ); - - // Just beyond the boundary, checked arithmetic refuses — the ceilings exist - // to keep on-chain (unchecked) math away from this region. - assert!(i128::MAX.checked_mul(2).is_none()); - assert!(worst.checked_mul(16).is_some()); - assert!(worst.checked_mul(26).is_some()); - assert!(worst.checked_mul(27).is_none()); - assert!(worst.checked_mul(28).is_none()); - - // Small-value sanity across theCredits chain (allocation sweep). - for alloc in [0u32, 1, 25, 50, 99, 100] { - let total = compute_total_stake(99, alloc, 10); - let credits = compute_credits(99, alloc, 10, 7, 13); - assert_eq!(credits, total * 7 * 13); - } -} - -// ── #289: persistent-storage TTL extension ──────────────────────────────────── - -#[test] -fn test_user_stake_ttl_extended_after_stake() { - let t = setup(1, 1); - t.client.stake(&t.user, &1_000); - - // Advance past the threshold; without the bump the entry would have expired. - advance_ledgers(&t.env, USER_TTL_THRESHOLD + 100); - assert!(t.client.get_stake(&t.user).is_some()); -} - -#[test] -fn test_user_position_ttl_extended_after_lock() { - let t = setup(1, 1); - t.client.lock_assets(&t.user, &1_000); - - advance_ledgers(&t.env, USER_TTL_THRESHOLD + 100); - let pos = t.client.get_user_position(&t.user); - assert_eq!(pos.unwrap().amount, 1_000); -} - -#[test] -fn test_banked_credits_ttl_extended() { - let t = setup(1, 1); - t.client.recover_banked_credits(&t.user, &40, &60); - - advance_ledgers(&t.env, USER_TTL_THRESHOLD + 200); - assert_eq!(t.client.get_banked_credits(&t.user), 100); -} - -#[test] -fn test_user_boost_ttl_extended() { - let t = setup(1, 1); - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &50); - - advance_ledgers(&t.env, USER_TTL_THRESHOLD + 100); - let config = t.client.get_boost_config(&t.user).unwrap(); - assert_eq!(config.allocation_pct, 50); - assert_eq!(config.multiplier, 1); -} - -// ── #288: credit independence from checkpoint frequency ─────────────────────── - -#[test] -fn test_total_credits_independent_of_checkpoint_frequency() { - let t = setup(1, 10); - let user_b = Address::generate(&t.env); - t.token_sac.mint(&user_b, &10_000); - - t.client.stake(&t.user, &10_000); - t.client.stake(&user_b, &10_000); - - // Neither user checkpoints; get_credits computes live accrual for both. - advance_ledgers(&t.env, 100); - assert_eq!(t.client.get_credits(&t.user), t.client.get_credits(&user_b)); -} - -#[test] -fn test_credits_same_regardless_of_intermediate_checkpoints() { - let t = setup(1, 10); - let user_b = Address::generate(&t.env); - t.token_sac.mint(&user_b, &10_000); - - t.client.stake(&t.user, &10_000); - t.client.stake(&user_b, &10_000); - t.client.set_boost(&t.user, &50); - t.client.set_boost(&user_b, &50); - - // Re-applying the same boost checkpoints A without changing its rate. - advance_ledgers(&t.env, 30); - t.client.set_boost(&t.user, &50); - advance_ledgers(&t.env, 30); - t.client.set_boost(&t.user, &50); - - // B never checkpoints; totals must still match. - advance_ledgers(&t.env, 40); - assert_eq!(t.client.get_credits(&t.user), t.client.get_credits(&user_b)); -} - -#[test] -fn test_position_credits_independent_of_checkpoint_frequency() { - let t = setup(1, 10); - let user_b = Address::generate(&t.env); - t.token_sac.mint(&user_b, &5_000); - - t.client.lock_assets(&t.user, &5_000); - t.client.lock_assets(&user_b, &5_000); - - // A reads its position mid-way; B is untouched. - advance_ledgers(&t.env, 50); - let _ = t.client.get_user_position(&t.user); - - advance_ledgers(&t.env, 50); - assert_eq!( - t.client.get_position_credits(&t.user), - t.client.get_position_credits(&user_b) - ); -} - -#[test] -fn test_compute_credits_overflow_prevention() { - let t = setup(2, 1); - t.client.stake(&t.user, &1000); - t.client.set_boost(&t.user, &100); - t.env.as_contract(&t.contract_id, || { - let amt = crate::compute_credits(1000, 0, 1, 100, 10); - assert_eq!(amt, 1_000_000); - }); -} - -#[test] -#[should_panic(expected = "credits arithmetic overflow")] -fn test_compute_credits_overflow_panics() { - let t = setup(2, 1); - t.env.as_contract(&t.contract_id, || { - let _ = crate::compute_credits(10i128.pow(18), 100, 1_000, 100_000_000, u32::MAX); - }); -} - -#[test] -fn test_checkpoint_formulas_produce_identical_results() { - let t = setup(2, 1); - let user_b = Address::generate(&t.env); - t.token_sac.mint(&user_b, &1_000); - - t.client.stake(&t.user, &1000); - t.client.lock_assets(&user_b, &1000); - - advance_ledgers(&t.env, 10); - - let credits_a = t.client.get_stake_credits(&t.user); - let credits_b = t.client.get_position_credits(&user_b); - - assert_eq!(credits_a, credits_b); - assert!(credits_a > 0); -} - -#[test] -fn test_set_min_lock_period_bounds() { - let t = setup(2, 1); - - assert!(t.client.try_set_min_lock_period(&63_072_000).is_ok()); - assert_eq!(t.client.min_lock_period(), 63_072_000); - - assert_eq!( - t.client.try_set_min_lock_period(&63_072_001), - Err(Ok(PoolError::InvalidLockPeriod)) - ); -} - -#[test] -fn test_partial_unstake_works_and_leaves_correct_balance() { - let t = setup(2, 1); - let initial = t.token.balance(&t.user); - t.client.stake(&t.user, &1000); - t.client.set_boost(&t.user, &50); - advance_ledgers(&t.env, 10); - - let credits_claimed = t.client.unstake(&t.user, &400); - assert!(credits_claimed > 0); - assert_eq!(t.token.balance(&t.user), initial - 600); - - let stake = t.client.get_stake(&t.user).unwrap(); - assert_eq!(stake.amount, 600); - assert_eq!(stake.credits_banked, credits_claimed); - assert_eq!(t.client.total_staked(), 600); - - advance_ledgers(&t.env, 10); - let final_credits = t.client.unstake(&t.user, &600); - assert!(final_credits > credits_claimed); - - assert!(t.client.get_stake(&t.user).is_none()); - assert_eq!(t.token.balance(&t.user), initial); -} - -#[test] -fn test_unstake_more_than_balance_fails() { - let t = setup(2, 1); - t.client.stake(&t.user, &1000); - assert_eq!( - t.client.try_unstake(&t.user, &1001), - Err(Ok(PoolError::InsufficientBalance)) - ); -} - -// ── #310 — get_credit_rate_history ──────────────────────────────────────────── - -#[test] -fn test_credit_rate_history_empty_before_any_change() { - let t = setup(2, 1); - let page = t.client.get_credit_rate_history(&0, &10); - assert_eq!(page.total, 0); - assert_eq!(page.events.len(), 0); -} - -#[test] -fn test_credit_rate_history_records_single_change() { - let t = setup(2, 1); - let initial_rate: i128 = 1; - let new_rate: i128 = 5; - - t.client.set_credit_rate(&new_rate); - - let page = t.client.get_credit_rate_history(&0, &10); - assert_eq!(page.total, 1); - assert_eq!(page.events.len(), 1); - - let event = page.events.get(0).unwrap(); - assert_eq!(event.old_rate, initial_rate); - assert_eq!(event.new_rate, new_rate); -} - -#[test] -fn test_credit_rate_history_records_multiple_changes_in_order() { - let t = setup(2, 1); - - t.client.set_credit_rate(&2); - t.client.set_credit_rate(&3); - t.client.set_credit_rate(&4); - - let page = t.client.get_credit_rate_history(&0, &10); - assert_eq!(page.total, 3); - - assert_eq!(page.events.get(0).unwrap().old_rate, 1); - assert_eq!(page.events.get(0).unwrap().new_rate, 2); - assert_eq!(page.events.get(1).unwrap().old_rate, 2); - assert_eq!(page.events.get(1).unwrap().new_rate, 3); - assert_eq!(page.events.get(2).unwrap().old_rate, 3); - assert_eq!(page.events.get(2).unwrap().new_rate, 4); -} - -#[test] -fn test_credit_rate_history_pagination() { - let t = setup(2, 1); - - for rate in [2i128, 3, 4, 5, 6] { - t.client.set_credit_rate(&rate); - } - - let page1 = t.client.get_credit_rate_history(&0, &3); - assert_eq!(page1.total, 5); - assert_eq!(page1.events.len(), 3); - - let page2 = t.client.get_credit_rate_history(&3, &10); - assert_eq!(page2.total, 5); - assert_eq!(page2.events.len(), 2); - - // Contiguous: last event of page1 + first event of page2 must be consecutive. - assert_eq!( - page1.events.get(2).unwrap().new_rate, - page2.events.get(0).unwrap().old_rate - ); -} - -#[test] -fn test_credit_rate_history_offset_past_end_returns_empty() { - let t = setup(2, 1); - t.client.set_credit_rate(&2); - - let page = t.client.get_credit_rate_history(&999, &10); - assert_eq!(page.total, 1); - assert_eq!(page.events.len(), 0); -} - -// ── #311 — get_global_multiplier_history ───────────────────────────────────── - -#[test] -fn test_global_multiplier_history_empty_before_any_change() { - let t = setup(2, 1); - let page = t.client.get_global_multiplier_history(&0, &10); - assert_eq!(page.total, 0); - assert_eq!(page.events.len(), 0); -} - -#[test] -fn test_global_multiplier_history_records_single_change() { - let t = setup(2, 1); - - t.client.set_global_multiplier(&3); - - let page = t.client.get_global_multiplier_history(&0, &10); - assert_eq!(page.total, 1); - assert_eq!(page.events.len(), 1); - - let event = page.events.get(0).unwrap(); - assert_eq!(event.old_multiplier, 2); // setup initialises with multiplier=2 - assert_eq!(event.new_multiplier, 3); -} - -#[test] -fn test_global_multiplier_history_records_multiple_changes_in_order() { - let t = setup(2, 1); - - t.client.set_global_multiplier(&3); - t.client.set_global_multiplier(&5); - t.client.set_global_multiplier(&1); - - let page = t.client.get_global_multiplier_history(&0, &10); - assert_eq!(page.total, 3); - - assert_eq!(page.events.get(0).unwrap().old_multiplier, 2); - assert_eq!(page.events.get(0).unwrap().new_multiplier, 3); - assert_eq!(page.events.get(1).unwrap().old_multiplier, 3); - assert_eq!(page.events.get(1).unwrap().new_multiplier, 5); - assert_eq!(page.events.get(2).unwrap().old_multiplier, 5); - assert_eq!(page.events.get(2).unwrap().new_multiplier, 1); -} - -#[test] -fn test_global_multiplier_history_pagination() { - let t = setup(2, 1); - - for m in [3u32, 4, 5, 1, 2] { - t.client.set_global_multiplier(&m); - } - - let page1 = t.client.get_global_multiplier_history(&0, &3); - assert_eq!(page1.total, 5); - assert_eq!(page1.events.len(), 3); - - let page2 = t.client.get_global_multiplier_history(&3, &10); - assert_eq!(page2.total, 5); - assert_eq!(page2.events.len(), 2); - - assert_eq!( - page1.events.get(2).unwrap().new_multiplier, - page2.events.get(0).unwrap().old_multiplier - ); -} - -#[test] -fn test_global_multiplier_history_offset_past_end_returns_empty() { - let t = setup(2, 1); - t.client.set_global_multiplier(&3); - - let page = t.client.get_global_multiplier_history(&999, &10); - assert_eq!(page.total, 1); - assert_eq!(page.events.len(), 0); -} - -// ── Issue #364: Test checkpoint with changed multiplier ────────────────────── - -/// Test that checkpoint correctly uses the current global multiplier when it -/// has changed since the user staked (regression test for issue #358/#364). -#[test] -fn test_checkpoint_uses_current_multiplier_after_change() { - // Setup pool with multiplier=1 - let t = setup(1, 1); - - // User stakes - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &50u32); - - // Advance time - advance_ledgers(&t.env, 10); - - // Admin changes multiplier to 2 - t.client.set_global_multiplier(&2u32); - - // Advance more time - advance_ledgers(&t.env, 10); - - // Get credits (this internally calls checkpoint) - let credits = t.client.get_credits(&t.user); - - // Expected calculation: - // - First 10 ledgers: effective_stake = 1000 * (100 - 50 + 50*1) / 100 = 1000, credits = 1000 * 1 * 10 = 10,000 - // - Next 10 ledgers: effective_stake = 1000 * (100 - 50 + 50*2) / 100 = 1500, credits = 1500 * 1 * 10 = 15,000 - // Total: 25,000 - assert_eq!(credits, 25_000); - - // Verify the stake record has been updated with new multiplier - let stake = t.client.get_stake(&t.user).unwrap(); - assert_eq!(stake.multiplier, 2, "Checkpoint should update to current multiplier"); -} - -/// Test that get_credits and checkpoint use the same multiplier (issue #358). -#[test] -fn test_get_credits_consistent_with_checkpoint_after_multiplier_change() { - let t = setup(1, 1); - - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &50u32); - advance_ledgers(&t.env, 10); - - // Change multiplier - t.client.set_global_multiplier(&3u32); - advance_ledgers(&t.env, 10); - - // Read credits without checkpointing - let credits_before = t.client.get_credits(&t.user); - - // Now trigger a checkpoint via set_boost - t.client.set_boost(&t.user, &50u32); - - // Credits after checkpoint should match what we read before - let credits_after = t.client.get_credits(&t.user); - assert_eq!( - credits_before, credits_after, - "get_credits should match checkpoint's calculation" - ); -} - -/// Test that position credits also use current multiplier after change. -#[test] -fn test_position_checkpoint_uses_current_multiplier() { - let t = setup_with_lock_period(1, 1, 10); - - // User locks assets - t.client.lock_assets(&t.user, &1_000); - - // Advance time - advance_ledgers(&t.env, 10); - - // Admin changes multiplier to 2 - t.client.set_global_multiplier(&2u32); - - // Advance more time - advance_ledgers(&t.env, 10); - - // Get position credits - let credits = t.client.calculate_credits(&t.user); - - // Position accrual doesn't use boost, so multiplier shouldn't affect it - // Expected: 1000 * 1 * 20 = 20,000 (no boost for positions) - assert_eq!(credits, 20_000); -} - -// ── #392: withdraw_credits tests ────────────────────────────────────────────── - -#[test] -fn test_withdraw_credits_success() { - let t = setup(1, 10); - t.client.stake(&t.user, &1_000); - advance_ledgers(&t.env, 5); - - let credits = t.client.withdraw_credits(&t.user); - assert_eq!(credits, 50_000); // 1000 * 10 * 5 = 50,000 - - let stake = t.client.get_stake(&t.user).unwrap(); - assert_eq!(stake.amount, 1_000); - assert_eq!(stake.credits_banked, 0); - - // Immediate second withdrawal returns 0 because 0 ledgers elapsed - let immediate = t.client.withdraw_credits(&t.user); - assert_eq!(immediate, 0); -} - -#[test] -fn test_withdraw_credits_requires_active_stake() { - let t = setup(1, 10); - let res = t.client.try_withdraw_credits(&t.user); - assert_eq!(res, Err(Ok(PoolError::NoActiveStake))); -} - -#[test] -fn test_withdraw_credits_blocked_when_withdrawals_paused() { - let t = setup(1, 10); - t.client.stake(&t.user, &1_000); - advance_ledgers(&t.env, 5); - - t.client.pause_withdrawals(); - let res = t.client.try_withdraw_credits(&t.user); - assert_eq!(res, Err(Ok(PoolError::Paused))); -} - -// ── #390: migrate schema version upgrade tests ─────────────────────────────── - -#[test] -fn test_migrate_upgrades_schema_version_step_by_step() { - let t = setup(1, 10); - t.env.as_contract(&t.contract_id, || { - t.env.storage().instance().set(&DataKey::SchemaVersion, &0u32); - }); - assert_eq!(t.client.schema_version(), 0); - - let prev = t.client.migrate(); - assert_eq!(prev, 0); - assert_eq!(t.client.schema_version(), 1); -} - -// ── Feature improvements: #558, #559, #560, #561 ────────────────────────────── - -#[test] -fn test_stake_token_getter() { - let t = setup(1, 10); - assert_eq!(t.client.stake_token(), t.token.address); - assert_eq!(t.client.get_stake_token(), t.token.address); -} - -#[test] -fn test_granular_pause_getters() { - let t = setup(1, 10); - - // Initially neither granular pause is active - assert_eq!(t.client.is_paused(), false); - assert_eq!(t.client.is_staking_paused(), false); - assert_eq!(t.client.is_withdrawals_paused(), false); - - // Staking paused independently - t.client.pause_staking(); - assert_eq!(t.client.is_staking_paused(), true); - assert_eq!(t.client.is_withdrawals_paused(), false); - assert_eq!(t.client.is_paused(), false); - - t.client.unpause_staking(); - assert_eq!(t.client.is_staking_paused(), false); - - // Withdrawals paused independently - t.client.pause_withdrawals(); - assert_eq!(t.client.is_staking_paused(), false); - assert_eq!(t.client.is_withdrawals_paused(), true); - assert_eq!(t.client.is_paused(), false); - - t.client.unpause_withdrawals(); - assert_eq!(t.client.is_withdrawals_paused(), false); - - // Global pause sets both - t.client.pause(); - assert_eq!(t.client.is_paused(), true); - assert_eq!(t.client.is_staking_paused(), true); - assert_eq!(t.client.is_withdrawals_paused(), true); - - t.client.unpause(); - assert_eq!(t.client.is_paused(), false); - assert_eq!(t.client.is_staking_paused(), false); - assert_eq!(t.client.is_withdrawals_paused(), false); -} - -#[test] -fn test_has_stake_lifecycle() { - let t = setup(1, 10); - - // User initially has no stake - assert_eq!(t.client.has_stake(&t.user), false); - - // User stakes - t.client.stake(&t.user, &1_000); - assert_eq!(t.client.has_stake(&t.user), true); - - // Partial unstake leaves stake active - t.client.unstake(&t.user, &400); - assert_eq!(t.client.has_stake(&t.user), true); - - // Full unstake removes active stake - t.client.unstake(&t.user, &600); - assert_eq!(t.client.has_stake(&t.user), false); -} - -#[test] -fn test_get_user_summary_aggregate() { - let t = setup_with_lock_period(1, 10, 50); - - // Fresh user (not whitelisted initially) - let summary0 = t.client.get_user_summary(&t.user); - assert_eq!(summary0.staked_amount, 0); - assert_eq!(summary0.locked_amount, 0); - assert_eq!(summary0.total_credits, 0); - assert_eq!(summary0.boost_allocation, 0); - assert_eq!(summary0.is_whitelisted, false); - assert_eq!(summary0.unlock_ledger, 0); - - // Add user to whitelist - t.client.add_to_whitelist(&t.user); - - // User stakes flexible amount and configures boost - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &40); - - // User locks position - t.client.lock_assets(&t.user, &500); - - advance_ledgers(&t.env, 5); - - let summary1 = t.client.get_user_summary(&t.user); - assert_eq!(summary1.staked_amount, 1_000); - assert_eq!(summary1.locked_amount, 500); - assert!(summary1.total_credits > 0); - assert_eq!(summary1.boost_allocation, 40); - assert_eq!(summary1.is_whitelisted, true); - assert!(summary1.unlock_ledger > 0); -} - -// ── Feature improvements: #566, #567, #568, #569 ────────────────────────────── - -#[test] -fn test_get_distribution_history_pages_bulk_rewards() { - let t = setup(1, 10); - - let empty = t.client.get_distribution_history(&0, &10); - assert_eq!(empty.total, 0); - assert_eq!(empty.events.len(), 0); - - let mut first = Vec::new(&t.env); - first.push_back(t.user.clone()); - t.client.distribute_bulk_rewards(&first, &100); - - let mut second = Vec::new(&t.env); - second.push_back(t.user.clone()); - second.push_back(Address::generate(&t.env)); - t.client.distribute_bulk_rewards(&second, &50); - - let page = t.client.get_distribution_history(&0, &10); - assert_eq!(page.total, 2); - assert_eq!(page.events.len(), 2); - let e0 = page.events.get(0).unwrap(); - assert_eq!(e0.user_count, 1); - assert_eq!(e0.total_amount, 100); - assert_eq!(e0.admin, t.admin); - let e1 = page.events.get(1).unwrap(); - assert_eq!(e1.user_count, 2); - assert_eq!(e1.total_amount, 100); - - let tail = t.client.get_distribution_history(&1, &1); - assert_eq!(tail.total, 2); - assert_eq!(tail.events.len(), 1); - assert_eq!(tail.events.get(0).unwrap().user_count, 2); - - let past_end = t.client.get_distribution_history(&5, &10); - assert_eq!(past_end.total, 2); - assert_eq!(past_end.events.len(), 0); -} - -#[test] -fn test_get_pending_admin_tracks_handoff() { - let t = setup(1, 10); - assert_eq!(t.client.get_pending_admin(), None); - - let new_admin = Address::generate(&t.env); - t.client.propose_admin(&new_admin); - assert_eq!(t.client.get_pending_admin(), Some(new_admin.clone())); - assert_eq!(t.client.admin(), t.admin); - - t.client.accept_admin(); - assert_eq!(t.client.get_pending_admin(), None); - assert_eq!(t.client.admin(), new_admin); -} - -#[test] -fn test_is_whitelist_enabled_reflects_toggle() { - let t = setup(1, 10); - assert_eq!(t.client.is_whitelist_enabled(), false); - - t.client.enable_whitelist(); - assert_eq!(t.client.is_whitelist_enabled(), true); - - t.client.disable_whitelist(); - assert_eq!(t.client.is_whitelist_enabled(), false); -} - -#[test] -fn test_get_peak_total_staked_survives_unstake() { - let t = setup(1, 10); - assert_eq!(t.client.get_peak_total_staked(), 0); - - t.client.stake(&t.user, &1_000); - assert_eq!(t.client.get_peak_total_staked(), 1_000); - - t.client.unstake(&t.user, &600); - assert_eq!(t.client.total_staked(), 400); - assert_eq!(t.client.get_peak_total_staked(), 1_000); -} - -// ── Feature improvements: #570, #571, #572, #573 ────────────────────────────── - -#[test] -fn test_get_total_boost_allocation_sums_across_users() { - let t = setup(1, 10); - assert_eq!(t.client.get_total_boost_allocation(), 0); - - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &40); - assert_eq!(t.client.get_total_boost_allocation(), 40); - - // Re-setting an existing allocation adjusts the total by the delta - t.client.set_boost(&t.user, &60); - assert_eq!(t.client.get_total_boost_allocation(), 60); - - let user2 = Address::generate(&t.env); - t.token_sac.mint(&user2, &500_000i128); - t.client.stake(&user2, &1_000); - t.client.set_boost(&user2, &30); - assert_eq!(t.client.get_total_boost_allocation(), 90); -} - -#[test] -fn test_get_boost_user_count_counts_only_boosted_users() { - let t = setup(1, 10); - assert_eq!(t.client.get_boost_user_count(), 0); - - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &40); - assert_eq!(t.client.get_boost_user_count(), 1); - - // Re-setting the same user's allocation does not count them twice - t.client.set_boost(&t.user, &60); - assert_eq!(t.client.get_boost_user_count(), 1); - - // A staked user without a boost is not counted - let user2 = Address::generate(&t.env); - t.token_sac.mint(&user2, &500_000i128); - t.client.stake(&user2, &1_000); - assert_eq!(t.client.get_boost_user_count(), 1); - - t.client.set_boost(&user2, &30); - assert_eq!(t.client.get_boost_user_count(), 2); -} - -#[test] -fn test_get_average_boost_allocation_rounds_down() { - let t = setup(1, 10); - assert_eq!(t.client.get_average_boost_allocation(), 0); - - t.client.stake(&t.user, &1_000); - t.client.set_boost(&t.user, &40); - assert_eq!(t.client.get_average_boost_allocation(), 40); - - let user2 = Address::generate(&t.env); - t.token_sac.mint(&user2, &500_000i128); - t.client.stake(&user2, &1_000); - t.client.set_boost(&user2, &31); - // (40 + 31) / 2 = 35.5, rounded down to 35 - assert_eq!(t.client.get_average_boost_allocation(), 35); -} - -#[test] -fn test_get_multiplier_change_ledger_tracks_last_change() { - let t = setup(1, 10); - assert_eq!(t.client.get_multiplier_change_ledger(), 0); - - advance_ledgers(&t.env, 5); - let first_change = t.env.ledger().sequence(); - t.client.set_global_multiplier(&2u32); - assert_eq!(t.client.get_multiplier_change_ledger(), first_change); - - advance_ledgers(&t.env, 7); - let second_change = t.env.ledger().sequence(); - t.client.set_global_multiplier(&3u32); - assert_eq!(t.client.get_multiplier_change_ledger(), second_change); -} diff --git a/soroban/contracts/vesting-wallet/src/lib.rs b/soroban/contracts/vesting-wallet/src/lib.rs index 92883cd..5a364c9 100644 --- a/soroban/contracts/vesting-wallet/src/lib.rs +++ b/soroban/contracts/vesting-wallet/src/lib.rs @@ -277,11 +277,7 @@ impl VestingWallet { .instance() .set(&DataKey::ReleasedAmount, &(released + releasable)); - token_client.transfer( - &env.current_contract_address(), - &beneficiary, - &releasable, - ); + token_client.transfer(&env.current_contract_address(), &beneficiary, &releasable); #[allow(deprecated)] env.events().publish( diff --git a/soroban/contracts/vesting-wallet/src/test.rs b/soroban/contracts/vesting-wallet/src/test.rs index 7e99a57..e800bea 100644 --- a/soroban/contracts/vesting-wallet/src/test.rs +++ b/soroban/contracts/vesting-wallet/src/test.rs @@ -100,8 +100,7 @@ fn test_release_rejects_when_wallet_balance_is_insufficient() { // Simulate an underfunded wallet (for example, tokens recovered by an // administrator). The release must fail before updating ReleasedAmount. - t.token - .transfer(&t.contract_id, &t.admin, &600i128); + t.token.transfer(&t.contract_id, &t.admin, &600i128); assert_eq!( t.client.try_release(), @@ -1104,9 +1103,7 @@ fn test_transfer_beneficiary_rejects_the_zero_address() { let t = setup(0, 200, 1_000); let good = Address::generate(&t.env); - let rejected = t - .client - .try_transfer_beneficiary(&zero_address(&t.env)); + let rejected = t.client.try_transfer_beneficiary(&zero_address(&t.env)); assert!( matches!(rejected, Err(Ok(VestingError::InvalidInput))), "transfer_beneficiary must reject the zero address, which could strand the funds"