From f8859795739786f3afee8f3d3e2e5066089f0a45 Mon Sep 17 00:00:00 2001 From: Cyber-Mitch Date: Wed, 26 Aug 2026 21:42:43 +0100 Subject: [PATCH] feat(farming-pool): support partial withdrawal in unstake (closes #77) --- .../factory/tests/factory_pool_integration.rs | 4 +- .../factory/tests/fixtures/farming_pool.wasm | Bin 41540 -> 43198 bytes soroban/contracts/farming-pool/src/lib.rs | 64 +++++++- soroban/contracts/farming-pool/src/test.rs | 150 +++++++++++++++++- soroban/contracts/farming-pool/src/types.rs | 3 + 5 files changed, 208 insertions(+), 13 deletions(-) diff --git a/soroban/contracts/factory/tests/factory_pool_integration.rs b/soroban/contracts/factory/tests/factory_pool_integration.rs index 3f16463..e193d74 100644 --- a/soroban/contracts/factory/tests/factory_pool_integration.rs +++ b/soroban/contracts/factory/tests/factory_pool_integration.rs @@ -262,7 +262,7 @@ fn end_to_end_create_pool_then_stake_and_unstake() { let period2_ledgers: i128 = 20; advance_ledgers(&env, 20); - let total_credits = pool_client.unstake(&user); + let total_credits = pool_client.unstake(&user, &stake_amount); // Reconcile against farming-pool's own accrual formula: // total_stake = principal + (boosted_amount * multiplier) @@ -284,7 +284,7 @@ fn end_to_end_create_pool_then_stake_and_unstake() { assert_eq!(token.balance(&pool_address), 0); // Internal stake state is cleared: a second unstake has nothing to act on. - assert!(pool_client.try_unstake(&user).is_err()); + assert!(pool_client.try_unstake(&user, &stake_amount).is_err()); } /// Lock/unlock lifecycle against a factory-deployed pool: lock_assets → diff --git a/soroban/contracts/factory/tests/fixtures/farming_pool.wasm b/soroban/contracts/factory/tests/fixtures/farming_pool.wasm index d15716a47fac1a5c83620f3c45747bae7b5762a2..2f15c242ec7e9fec552b4b464d1255c1a5532d8e 100755 GIT binary patch delta 14307 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 gc^z8b(6}lK>)u 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 diff --git a/soroban/contracts/farming-pool/src/lib.rs b/soroban/contracts/farming-pool/src/lib.rs index 132d0db..e50a423 100644 --- a/soroban/contracts/farming-pool/src/lib.rs +++ b/soroban/contracts/farming-pool/src/lib.rs @@ -710,27 +710,81 @@ impl FarmingPool { Ok(()) } - pub fn unstake(env: Env, from: Address) -> Result { + /// Withdraw `amount` from `from`'s stake, returning their total banked + /// credits. Mirrors `unlock_assets`' partial-withdrawal support (#77). + /// + /// # Breaking ABI change + /// + /// `amount` is a **new required third parameter**. Callers of the previous + /// two-argument `unstake(env, from)` — which always withdrew the entire + /// stake — must pass the full stake amount explicitly to preserve that + /// behavior. There is deliberately no `full_unstake` compatibility + /// wrapper: `unlock_assets`, the function this now mirrors, has never had + /// one, and a second funds-custody entrypoint would carry its own + /// permanent auth/pause/reentrancy surface. See the acceptance notes on + /// #77. + /// + /// # Arguments + /// + /// * `from` - Staker withdrawing; must authorize the call. + /// * `amount` - Quantity to withdraw. Must satisfy + /// `0 < amount <= stake.amount`. + /// + /// # Partial withdrawals + /// + /// The stake is checkpointed *before* `amount` is deducted, so credits + /// accrued up to this call are banked against the pre-withdrawal balance. + /// When `amount < stake.amount` the residual stake record is retained via + /// `set_user_stake` and keeps accruing on the remainder; only a withdrawal + /// that zeroes the balance removes it. The user's `DataKey::UserBoost` + /// allocation is never touched here and needs no rewrite: `checkpoint` + /// reads it fresh from persistent storage on every call rather than + /// caching it in `UserStake`, so a surviving remainder keeps the existing + /// boost without going stale. + /// + /// # Returns + /// + /// Total credits banked for `from` after checkpointing. This is the full + /// banked balance, *not* a share prorated to `amount` — unchanged from the + /// previous behavior. + /// + /// # Errors + /// + /// * `PoolError::Paused` - Pool is paused. + /// * `PoolError::NotInitialized` - Pool has not been initialized. + /// * `PoolError::NoActiveStake` - `from` has no stake record. Previously a + /// panic (`expect("no active stake")`); now a typed error. + /// * `PoolError::InvalidAmount` - `amount` was <= 0 or exceeded the stake. + pub fn unstake(env: Env, from: Address, amount: i128) -> Result { from.require_auth(); require_not_paused(&env)?; require_initialized(&env)?; bump_instance(&env); - let mut stake = get_user_stake(&env, &from).expect("no active stake"); + let mut stake = get_user_stake(&env, &from).ok_or(PoolError::NoActiveStake)?; + if amount <= 0 || amount > stake.amount { + return Err(PoolError::InvalidAmount); + } + checkpoint(&env, &from, &mut stake); let total_credits = stake.credits_banked; + stake.amount -= amount; - // Return staked tokens to caller. + // Return the withdrawn tokens to caller. // token::TokenClient::new(&env, &get_stake_token(&env)).transfer( let stake_token = get_stake_token(&env)?; token::TokenClient::new(&env, &stake_token).transfer( &env.current_contract_address(), &from, - &stake.amount, + &amount, ); - remove_user_stake(&env, &from); + if stake.amount == 0 { + remove_user_stake(&env, &from); + } else { + set_user_stake(&env, &from, &stake); + } Ok(total_credits) } diff --git a/soroban/contracts/farming-pool/src/test.rs b/soroban/contracts/farming-pool/src/test.rs index 651b097..269b648 100644 --- a/soroban/contracts/farming-pool/src/test.rs +++ b/soroban/contracts/farming-pool/src/test.rs @@ -703,12 +703,150 @@ fn test_unstake_returns_tokens_and_credits() { 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); + 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()); } +// ── unstake partial withdrawal (#77) ──────────────────────────────────────── +// +// `unstake` now takes an explicit `amount`, mirroring `unlock_assets`' +// long-standing partial-withdrawal support. The tests below pin the two +// halves of that behavior: a withdrawal smaller than the stake must leave a +// live, still-accruing remainder, and a withdrawal equal to the stake must be +// indistinguishable from the old full-withdrawal path. + +#[test] +fn test_unstake_partial_keeps_remaining_stake() { + let t = setup(1, 1); // multiplier 1 => boost plays no part here + let initial_balance = t.token.balance(&t.user); + t.client.stake(&t.user, &1_000); + advance_ledgers(&t.env, 10); + + let credits = t.client.unstake(&t.user, &400); // partial withdrawal + + // The checkpoint runs before `amount` is deducted, so the whole 1_000 is + // credited for the elapsed window: 1000 * 1 * 10. + assert_eq!(credits, 10_000); + + let stake = t + .client + .get_stake(&t.user) + .expect("stake should still exist"); + assert_eq!(stake.amount, 600); + assert_eq!(stake.credits_banked, 10_000); + + // Exactly `amount` moved — not the full staked balance. + assert_eq!(t.token.balance(&t.user), initial_balance - 600); + assert_eq!(t.token.balance(&t.contract_id), 600); + + // The remainder keeps earning: 600 * 1 * 10 on top of the 10_000 banked. + advance_ledgers(&t.env, 10); + assert_eq!(t.client.get_credits(&t.user), 16_000); +} + +#[test] +fn test_unstake_partial_preserves_boost_allocation() { + // A partial unstake must leave `DataKey::UserBoost` intact and keep + // applying it to the remainder. `checkpoint` re-reads the allocation from + // persistent storage on every call rather than caching it in `UserStake`, + // so the surviving remainder cannot go stale — this test fails loudly if + // either half of that stops holding. + let t = setup(2, 1); + t.client.stake(&t.user, &1_000); + t.client.set_boost(&t.user, &50u32); + advance_ledgers(&t.env, 10); + + // Pre-withdrawal accrual at 50% allocation / 2x multiplier: + // boosted = 500, principal = 500, virtual = 1000 => total_stake 1500. + let credits = t.client.unstake(&t.user, &400); + assert_eq!(credits, 15_000); // 1500 * 1 * 10 + + // The allocation itself survives the partial withdrawal. + let config = t + .client + .get_boost_config(&t.user) + .expect("boost allocation should survive a partial unstake"); + assert_eq!(config.allocation_pct, 50); + assert_eq!(config.multiplier, 2); + + let stake = t + .client + .get_stake(&t.user) + .expect("stake should still exist"); + assert_eq!(stake.amount, 600); + + // And it still applies to the remainder. On 600: boosted = 300, + // principal = 300, virtual = 600 => total_stake 900, so 900 * 1 * 10. + // A cleared or stale boost would accrue the unboosted 600 * 1 * 10 = 6_000 + // instead, landing on 21_000 rather than 24_000. + advance_ledgers(&t.env, 10); + assert_eq!(t.client.get_credits(&t.user), 24_000); +} + +#[test] +fn test_unstake_exact_full_amount_removes_stake_record() { + // amount == stake.amount is the boundary between the partial path and the + // old full-withdrawal path: it must take the `remove_user_stake` branch. + let t = setup(1, 1); + let initial_balance = t.token.balance(&t.user); + t.client.stake(&t.user, &1_000); + advance_ledgers(&t.env, 10); + + let credits = t.client.unstake(&t.user, &1_000); + + assert_eq!(credits, 10_000); + assert!(t.client.get_stake(&t.user).is_none()); + assert_eq!(t.client.get_credits(&t.user), 0); + assert_eq!(t.token.balance(&t.user), initial_balance); + assert_eq!(t.token.balance(&t.contract_id), 0); +} + +#[test] +fn test_unstake_rejects_more_than_staked() { + // Mirrors test_unlock_assets_rejects_more_than_locked: one over the + // balance is the first rejected value. + let t = setup(1, 1); + t.client.stake(&t.user, &1_000); + let result = t.client.try_unstake(&t.user, &1_001i128); + assert!(matches!(result, Err(Ok(PoolError::InvalidAmount)))); + + // Rejected before any state or token movement. + assert_eq!(t.client.get_stake(&t.user).unwrap().amount, 1_000); + assert_eq!(t.token.balance(&t.contract_id), 1_000); +} + +#[test] +fn test_unstake_rejects_zero_amount() { + let t = setup(1, 1); + t.client.stake(&t.user, &1_000); + let result = t.client.try_unstake(&t.user, &0i128); + assert!(matches!(result, Err(Ok(PoolError::InvalidAmount)))); + assert_eq!(t.client.get_stake(&t.user).unwrap().amount, 1_000); +} + +#[test] +fn test_unstake_rejects_negative_amount() { + // Without the `amount <= 0` guard this would *inflate* the stake + // (`stake.amount -= -100`), so assert the record is untouched. + let t = setup(1, 1); + t.client.stake(&t.user, &1_000); + let result = t.client.try_unstake(&t.user, &-100i128); + assert!(matches!(result, Err(Ok(PoolError::InvalidAmount)))); + assert_eq!(t.client.get_stake(&t.user).unwrap().amount, 1_000); + assert_eq!(t.token.balance(&t.contract_id), 1_000); +} + +#[test] +fn test_unstake_rejects_when_no_stake() { + // Previously an untyped `expect("no active stake")` panic; now the same + // typed error `emergency_withdraw` already returns. + let t = setup(1, 1); + let result = t.client.try_unstake(&t.user, &100i128); + assert!(matches!(result, Err(Ok(PoolError::NoActiveStake)))); +} + #[test] fn test_additional_stake_checkpoints_credits() { // Stake 1000, earn 10 ledgers (= 10000 credits), then stake 500 more. @@ -1351,7 +1489,7 @@ 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).is_err()); + assert!(t.client.try_unstake(&t.user, &1_000i128).is_err()); } #[test] @@ -1360,7 +1498,7 @@ fn test_unpause_restores_unstake() { t.client.stake(&t.user, &1_000); t.client.pause(); t.client.unpause(); - t.client.unstake(&t.user); + t.client.unstake(&t.user, &1_000i128); assert!(t.client.get_stake(&t.user).is_none()); } @@ -1852,10 +1990,10 @@ fn test_unstake_reentrant_transfer_is_rejected_and_final_state_is_correct() { seed_user_stake(&env, &farming_pool_id, &user, 500i128); let reentrant_args: soroban_sdk::Vec = - soroban_sdk::vec![&env, user.clone().into_val(&env)]; + soroban_sdk::vec![&env, user.clone().into_val(&env), 200i128.into_val(&env)]; token_client.configure_reentrant_call(&Symbol::new(&env, "unstake"), &reentrant_args); - client.unstake(&user); + client.unstake(&user, &500i128); assert!(token_client.reentry_was_rejected()); assert!(client.get_stake(&user).is_none()); @@ -1881,7 +2019,7 @@ fn test_unstake_reverts_entirely_if_stake_token_naively_reenters() { seed_user_stake(&env, &farming_pool_id, &user, 500i128); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - client.unstake(&user); + client.unstake(&user, &500i128); })); assert!( result.is_err(), diff --git a/soroban/contracts/farming-pool/src/types.rs b/soroban/contracts/farming-pool/src/types.rs index 0dc0a89..e255c6e 100644 --- a/soroban/contracts/farming-pool/src/types.rs +++ b/soroban/contracts/farming-pool/src/types.rs @@ -17,6 +17,9 @@ pub enum PoolError { /// Returned by `emergency_withdraw` when the user has no stake or locked position. NoActiveStake = 8, Paused = 9, + /// `amount` was <= 0, or exceeded the caller's withdrawable balance. + /// Returned by `unstake` (see #77). + InvalidAmount = 10, } /// Per-user boost configuration returned by `get_boost_config`.