diff --git a/soroban/contracts/factory/src/lib.rs b/soroban/contracts/factory/src/lib.rs index d9eec9d..978a9d7 100644 --- a/soroban/contracts/factory/src/lib.rs +++ b/soroban/contracts/factory/src/lib.rs @@ -153,6 +153,9 @@ impl Factory { if env.storage().instance().has(&DataKey::Admin) { return Err(FactoryError::AlreadyInitialized); } + if pool_wasm_hash == BytesN::from_array(&env, &[0u8; 32]) { + return Err(FactoryError::InvalidWasmHash); + } env.storage().instance().set(&DataKey::Admin, &admin); env.storage() .instance() @@ -578,6 +581,8 @@ impl Factory { /// Allows the admin to point future pool deployments at a corrected or upgraded /// farming-pool build without redeploying the factory itself. Existing deployed /// pools are unaffected — Soroban contract bytecode is immutable once deployed. + /// Validates that `new_hash` is non-zero. Callers/admins must verify that the target + /// WASM has been uploaded to the chain before calling this function. /// /// Emits a `wasm_set` event with `(old_hash, new_hash)` so that the previous /// hash is discoverable off-chain for rollback scenarios. @@ -587,6 +592,10 @@ impl Factory { admin.require_auth(); bump_instance(&env); + if new_hash == BytesN::from_array(&env, &[0u8; 32]) { + return Err(FactoryError::InvalidWasmHash); + } + let old_hash: BytesN<32> = env.storage().instance().get(&DataKey::WasmHash).unwrap(); env.storage().instance().set(&DataKey::WasmHash, &new_hash); #[allow(deprecated)] diff --git a/soroban/contracts/factory/src/test.rs b/soroban/contracts/factory/src/test.rs index d1568de..e0d9c02 100644 --- a/soroban/contracts/factory/src/test.rs +++ b/soroban/contracts/factory/src/test.rs @@ -249,6 +249,27 @@ fn test_pool_wasm_hash_returns_stored_hash() { assert_eq!(t.client.pool_wasm_hash(), t.wasm_hash); } +#[test] +fn test_initialize_rejects_zero_wasm_hash() { + let (env, client) = setup_uninitialized(); + let admin = Address::generate(&env); + let zero_hash = BytesN::from_array(&env, &[0u8; 32]); + assert_eq!( + client.try_initialize(&admin, &zero_hash), + Err(Ok(FactoryError::InvalidWasmHash)) + ); +} + +#[test] +fn test_set_pool_wasm_hash_rejects_zero_hash() { + let t = setup(); + let zero_hash = BytesN::from_array(&t.env, &[0u8; 32]); + assert_eq!( + t.client.try_set_pool_wasm_hash(&zero_hash), + Err(Ok(FactoryError::InvalidWasmHash)) + ); +} + // ── pool_count ──────────────────────────────────────────────────────────────── #[test] diff --git a/soroban/contracts/farming-pool/src/lib.rs b/soroban/contracts/farming-pool/src/lib.rs index fac9654..5ec4821 100644 --- a/soroban/contracts/farming-pool/src/lib.rs +++ b/soroban/contracts/farming-pool/src/lib.rs @@ -244,6 +244,34 @@ fn subtract_total_staked(env: &Env, amount: i128) { ); } +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 set_banked_credits(env: &Env, user: &Address, totals: BankedCreditTotals) { let key = DataKey::BankedCredits(user.clone()); env.storage().persistent().set(&key, &totals); @@ -624,6 +652,7 @@ impl FarmingPool { 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); @@ -654,6 +683,9 @@ impl FarmingPool { // 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); + } add_total_staked(&env, amount); let stake_token = get_stake_token(&env)?; @@ -678,6 +710,7 @@ impl FarmingPool { assert!(amount > 0, "amount must be positive"); bump_instance(&env); + let was_staked = is_user_staked(&env, &user); let mut position = get_position(&env, &user).expect("no active position"); assert!(amount <= position.amount, "insufficient locked balance"); @@ -706,6 +739,9 @@ impl FarmingPool { } else { set_position(&env, &user, &position); } + if was_staked && !is_user_staked(&env, &user) { + decrement_staked_user_count(&env); + } subtract_total_staked(&env, amount); let stake_token = get_stake_token(&env)?; @@ -862,6 +898,7 @@ impl FarmingPool { } bump_instance(&env); + let was_staked = is_user_staked(&env, &user); let mut total_returned = 0i128; let mut position_credits = 0i128; let mut stake_credits = 0i128; @@ -884,6 +921,10 @@ impl FarmingPool { remove_user_stake(&env, &user); } + if was_staked && !is_user_staked(&env, &user) { + decrement_staked_user_count(&env); + } + if total_returned == 0 { return Err(PoolError::NoActiveStake); } @@ -1121,6 +1162,7 @@ impl FarmingPool { bump_instance(&env); + let was_staked = is_user_staked(&env, &from); let current = env.ledger().sequence(); let mut new_stake = if let Some(mut existing) = get_user_stake(&env, &from) { checkpoint(&env, &from, &mut existing); @@ -1141,6 +1183,9 @@ impl FarmingPool { // 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 !was_staked && is_user_staked(&env, &from) { + increment_staked_user_count(&env); + } add_total_staked(&env, amount); // Pull tokens from caller into the contract. @@ -1171,6 +1216,7 @@ impl FarmingPool { require_withdrawals_not_paused(&env)?; bump_instance(&env); + let was_staked = is_user_staked(&env, &from); let mut stake = get_user_stake(&env, &from).expect("no active stake"); checkpoint(&env, &from, &mut stake); let total_credits = stake.credits_banked; @@ -1189,6 +1235,9 @@ impl FarmingPool { ); remove_user_stake(&env, &from); + if was_staked && !is_user_staked(&env, &from) { + decrement_staked_user_count(&env); + } subtract_total_staked(&env, stake.amount); Ok(total_credits) } diff --git a/soroban/contracts/farming-pool/src/test.rs b/soroban/contracts/farming-pool/src/test.rs index 8942cdc..25db8ef 100644 --- a/soroban/contracts/farming-pool/src/test.rs +++ b/soroban/contracts/farming-pool/src/test.rs @@ -2694,3 +2694,70 @@ fn test_emergency_withdraw_reverts_entirely_if_stake_token_naively_reenters() { 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_admin_client.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); + 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(1, 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 + match t.client.try_unlock_assets(&t.user, &1_500) { + Err(Ok(PoolError::MinimumLockNotElapsed)) => {} + other => assert!( + other.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); +} + + diff --git a/soroban/contracts/vesting-wallet/src/test.rs b/soroban/contracts/vesting-wallet/src/test.rs index 0df095f..fbe6a76 100644 --- a/soroban/contracts/vesting-wallet/src/test.rs +++ b/soroban/contracts/vesting-wallet/src/test.rs @@ -2,9 +2,10 @@ use super::*; use soroban_sdk::{ + symbol_short, testutils::{Address as _, Events, Ledger}, token::{StellarAssetClient, TokenClient}, - Address, Env, + Address, Env, IntoVal, }; // ── Test helpers ──────────────────────────────────────────────────────────────