diff --git a/soroban/contracts/farming-pool/src/lib.rs b/soroban/contracts/farming-pool/src/lib.rs index 132d0db..2701e2b 100644 --- a/soroban/contracts/farming-pool/src/lib.rs +++ b/soroban/contracts/farming-pool/src/lib.rs @@ -245,11 +245,24 @@ fn is_user_whitelisted(env: &Env, user: &Address) -> bool { /// total_stake = principal_stake + virtual_stake /// /// With no boost (allocation_pct = 0) total_stake == amount. -fn compute_total_stake(amount: i128, allocation_pct: u32, multiplier: u32) -> i128 { - let boosted = amount * allocation_pct as i128 / 100; - let principal = amount - boosted; - let virtual_stake = boosted * multiplier as i128; - principal + virtual_stake +fn compute_total_stake( + amount: i128, + allocation_pct: u32, + multiplier: u32, +) -> Result { + let boosted = amount + .checked_mul(allocation_pct as i128) + .ok_or(PoolError::CreditOverflow)? + / 100; + let principal = amount + .checked_sub(boosted) + .ok_or(PoolError::CreditOverflow)?; + let virtual_stake = boosted + .checked_mul(multiplier as i128) + .ok_or(PoolError::CreditOverflow)?; + principal + .checked_add(virtual_stake) + .ok_or(PoolError::CreditOverflow) } fn compute_credits( @@ -258,32 +271,63 @@ fn compute_credits( multiplier: u32, credit_rate: i128, ledgers_elapsed: u32, -) -> i128 { - compute_total_stake(amount, allocation_pct, multiplier) * credit_rate * ledgers_elapsed as i128 +) -> Result { + let total_stake = compute_total_stake(amount, allocation_pct, multiplier)?; + total_stake + .checked_mul(credit_rate) + .and_then(|v| v.checked_mul(ledgers_elapsed as i128)) + .ok_or(PoolError::CreditOverflow) +} + +/// Credits accrued by a `Position` over `elapsed` ledgers at `credit_rate`. +/// +/// Single source of truth for Position credit accrual, shared by the +/// checkpointing path (`checkpoint_position`) and the preview path +/// (`calculate_credits`). Deliberately separate from `compute_credits`, which +/// applies UserStake boost/multiplier semantics that Positions do not have. +fn compute_position_credits( + amount: i128, + credit_rate: i128, + elapsed: u32, +) -> Result { + amount + .checked_mul(credit_rate) + .and_then(|v| v.checked_mul(elapsed as i128)) + .ok_or(PoolError::CreditOverflow) } -fn checkpoint(env: &Env, user: &Address, stake: &mut UserStake) { +fn checkpoint(env: &Env, user: &Address, stake: &mut UserStake) -> Result<(), PoolError> { let allocation_pct = get_user_boost(env, user).unwrap_or(0); let multiplier = read_global_multiplier(env); let current = env.ledger().sequence(); let elapsed = current.saturating_sub(stake.start_ledger); - stake.credits_banked += compute_credits( + let accrued = compute_credits( stake.amount, allocation_pct, multiplier, stake.credit_rate, elapsed, - ); + )?; + stake.credits_banked = stake + .credits_banked + .checked_add(accrued) + .ok_or(PoolError::CreditOverflow)?; stake.start_ledger = current; stake.credit_rate = read_credit_rate(env); + Ok(()) } -fn checkpoint_position(env: &Env, position: &mut Position) { +fn checkpoint_position(env: &Env, position: &mut Position) -> Result<(), PoolError> { let current = env.ledger().sequence(); let elapsed = current.saturating_sub(position.checkpoint_ledger); - position.total_credits += position.amount * position.credit_rate * elapsed as i128; + let accrued = compute_position_credits(position.amount, position.credit_rate, elapsed)?; + position.total_credits = position + .total_credits + .checked_add(accrued) + .ok_or(PoolError::CreditOverflow)?; position.checkpoint_ledger = current; position.credit_rate = read_credit_rate(env); + Ok(()) } #[contract] @@ -410,7 +454,7 @@ impl FarmingPool { let current = env.ledger().sequence(); let mut position = if let Some(mut existing) = get_position(&env, &user) { - checkpoint_position(&env, &mut existing); + checkpoint_position(&env, &mut existing)?; existing.amount += amount; existing } else { @@ -469,7 +513,7 @@ impl FarmingPool { "minimum lock period not elapsed" ); - checkpoint_position(&env, &mut position); + checkpoint_position(&env, &mut position)?; let total_credits = position.total_credits; position.amount -= amount; @@ -505,7 +549,11 @@ impl FarmingPool { .ledger() .sequence() .saturating_sub(position.checkpoint_ledger); - Ok(position.total_credits + position.amount * position.credit_rate * elapsed as i128) + let accrued = compute_position_credits(position.amount, position.credit_rate, elapsed)?; + position + .total_credits + .checked_add(accrued) + .ok_or(PoolError::CreditOverflow) } pub fn get_user_position(env: Env, user: Address) -> Result, PoolError> { @@ -683,7 +731,7 @@ impl FarmingPool { let current = env.ledger().sequence(); let mut new_stake = if let Some(mut existing) = get_user_stake(&env, &from) { - checkpoint(&env, &from, &mut existing); + checkpoint(&env, &from, &mut existing)?; existing.amount += amount; existing } else { @@ -718,7 +766,7 @@ impl FarmingPool { bump_instance(&env); let mut stake = get_user_stake(&env, &from).expect("no active stake"); - checkpoint(&env, &from, &mut stake); + checkpoint(&env, &from, &mut stake)?; let total_credits = stake.credits_banked; // Return staked tokens to caller. @@ -746,7 +794,7 @@ impl FarmingPool { bump_instance(&env); if let Some(mut stake) = get_user_stake(&env, &user) { - checkpoint(&env, &user, &mut stake); + checkpoint(&env, &user, &mut stake)?; set_user_stake(&env, &user, &stake); } @@ -860,14 +908,17 @@ impl FarmingPool { let allocation_pct = get_user_boost(&env, &user).unwrap_or(0); let multiplier = read_global_multiplier(&env); let elapsed = env.ledger().sequence().saturating_sub(stake.start_ledger); - Ok(stake.credits_banked - + compute_credits( - stake.amount, - allocation_pct, - multiplier, - stake.credit_rate, - elapsed, - )) + let accrued = compute_credits( + stake.amount, + allocation_pct, + multiplier, + stake.credit_rate, + elapsed, + )?; + stake + .credits_banked + .checked_add(accrued) + .ok_or(PoolError::CreditOverflow) } pub fn set_min_stake_amount(env: Env, amount: i128) -> Result<(), PoolError> { diff --git a/soroban/contracts/farming-pool/src/test.rs b/soroban/contracts/farming-pool/src/test.rs index 14d42d9..d8c1d21 100644 --- a/soroban/contracts/farming-pool/src/test.rs +++ b/soroban/contracts/farming-pool/src/test.rs @@ -276,28 +276,28 @@ fn test_transfer_admin_uninitialized_returns_not_initialized() { #[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); + let stake = compute_total_stake(1_000, 0, 5).unwrap(); 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); + let stake = compute_total_stake(1_000, 100, 2).unwrap(); 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); + let stake = compute_total_stake(1_000, 50, 2).unwrap(); 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); + let stake = compute_total_stake(1_000, 25, 3).unwrap(); assert_eq!(stake, 1_500); } @@ -305,7 +305,7 @@ fn test_effective_stake_25pct_allocation_3x() { 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); + let stake = compute_total_stake(1_000, 1, 10).unwrap(); assert_eq!(stake, 1_090); } @@ -1107,6 +1107,189 @@ fn test_calculate_credits_reflects_partial_unlock_checkpoint() { assert_eq!(t.client.calculate_credits(&t.user), 13_000); } +// ── Position credit accrual unit tests ──────────────────────────────────────── + +#[test] +fn test_compute_position_credits_zero_elapsed() { + // No time elapsed → no credits accrued regardless of amount/rate. + assert_eq!(compute_position_credits(1_000, 1, 0).unwrap(), 0); + assert_eq!(compute_position_credits(5_000, 100, 0).unwrap(), 0); +} + +#[test] +fn test_compute_position_credits_positive() { + // amount * credit_rate * elapsed + assert_eq!(compute_position_credits(500, 2, 20).unwrap(), 20_000); +} + +#[test] +fn test_compute_position_credits_combinations() { + let cases = [ + (1_000, 1, 10, 10_000), + (2_000, 3, 5, 30_000), + (750, 4, 100, 300_000), + (1, 1, 1, 1), + (1_000, 1, 1_000_000, 1_000_000_000), + ]; + for (amount, credit_rate, elapsed, expected) in cases { + assert_eq!( + compute_position_credits(amount, credit_rate, elapsed).unwrap(), + expected, + "amount={amount}, credit_rate={credit_rate}, elapsed={elapsed}" + ); + } +} + +// ── credit overflow tests ───────────────────────────────────────────────────── + +#[test] +fn test_compute_total_stake_overflow_returns_credit_overflow() { + // amount * allocation_pct overflows i128. + assert_eq!( + compute_total_stake(i128::MAX, 2, 1), + Err(PoolError::CreditOverflow) + ); + // boosted * multiplier overflows i128. + assert_eq!( + compute_total_stake(i128::MAX / 100, 100, 201), + Err(PoolError::CreditOverflow) + ); +} + +#[test] +fn test_compute_credits_overflow_returns_credit_overflow() { + // total_stake * credit_rate overflows i128. + assert_eq!( + compute_credits(2, 0, 1, i128::MAX, 1), + Err(PoolError::CreditOverflow) + ); + // product * ledgers_elapsed overflows i128. + assert_eq!( + compute_credits(i128::MAX / 2, 0, 1, 2, 2), + Err(PoolError::CreditOverflow) + ); +} + +#[test] +fn test_compute_position_credits_overflow_returns_credit_overflow() { + // amount * credit_rate overflows i128. + assert_eq!( + compute_position_credits(2, i128::MAX, 1), + Err(PoolError::CreditOverflow) + ); +} + +#[test] +fn test_calculate_credits_overflow_returns_credit_overflow() { + // MAX_CREDIT_RATE with a huge user-supplied amount overflows the accrual. + let t = setup(1, 100_000_000); + let huge = i128::MAX / 10; + t.token_sac.mint(&t.user, &huge); + t.client.lock_assets(&t.user, &huge); + advance_ledgers(&t.env, 1); + assert!(matches!( + t.client.try_calculate_credits(&t.user), + Err(Ok(PoolError::CreditOverflow)) + )); +} + +#[test] +fn test_lock_assets_checkpoint_overflow_returns_credit_overflow() { + let t = setup(1, 100_000_000); + let huge = i128::MAX / 10; + t.token_sac.mint(&t.user, &huge); + t.client.lock_assets(&t.user, &huge); + advance_ledgers(&t.env, 1); + // Second lock triggers checkpoint_position, which overflows. + assert!(matches!( + t.client.try_lock_assets(&t.user, &100), + Err(Ok(PoolError::CreditOverflow)) + )); +} + +#[test] +fn test_get_credits_overflow_returns_credit_overflow() { + let t = setup(1, 100_000_000); + let huge = i128::MAX / 10; + t.token_sac.mint(&t.user, &huge); + t.client.stake(&t.user, &huge); + advance_ledgers(&t.env, 1); + assert!(matches!( + t.client.try_get_credits(&t.user), + Err(Ok(PoolError::CreditOverflow)) + )); +} + +#[test] +fn test_unstake_overflow_returns_credit_overflow_and_preserves_stake() { + let t = setup(1, 100_000_000); + let huge = i128::MAX / 10; + t.token_sac.mint(&t.user, &huge); + t.client.stake(&t.user, &huge); + advance_ledgers(&t.env, 1); + assert!(matches!( + t.client.try_unstake(&t.user), + Err(Ok(PoolError::CreditOverflow)) + )); + // Stake is preserved so the user can recover after the admin fixes the rate. + assert!(t.client.get_stake(&t.user).is_some()); +} + +#[test] +fn test_unlock_assets_overflow_returns_credit_overflow_and_preserves_position() { + let t = setup(1, 100_000_000); + let huge = i128::MAX / 10; + t.token_sac.mint(&t.user, &huge); + t.client.lock_assets(&t.user, &huge); + advance_ledgers(&t.env, 1); + assert!(matches!( + t.client.try_unlock_assets(&t.user, &huge), + Err(Ok(PoolError::CreditOverflow)) + )); + assert!(t.client.get_user_position(&t.user).is_some()); +} + +#[test] +fn test_position_banked_vs_previewed_credits_match() { + // The value banked by checkpoint_position and previewed by calculate_credits + // must come from the same calculation. + let t = setup(1, 1); + t.client.lock_assets(&t.user, &1_000); + advance_ledgers(&t.env, 10); + + // Preview before checkpointing. + let previewed = t.client.calculate_credits(&t.user); + assert_eq!(previewed, 10_000); // 1000 * 1 * 10 + + // Checkpoint via a second lock, banking the same accrued credits. + t.client.lock_assets(&t.user, &100); + let pos = t + .client + .get_user_position(&t.user) + .expect("position should exist"); + assert_eq!(pos.total_credits, previewed); + + // Nothing further accrues since the checkpoint. + assert_eq!(t.client.calculate_credits(&t.user), previewed); +} + +#[test] +fn test_position_multi_checkpoint_accrual() { + // Each checkpoint banks credits via the shared formula; the total is the sum. + // Amounts must stay at/above the default min_stake_amount (100) to pass the + // lock/min-stake gate. + let t = setup(1, 2); + t.client.lock_assets(&t.user, &1_000); + advance_ledgers(&t.env, 5); + t.client.lock_assets(&t.user, &100); // banks 1000 * 2 * 5 = 10_000 + advance_ledgers(&t.env, 5); + t.client.lock_assets(&t.user, &100); // banks 1100 * 2 * 5 = 11_000 + advance_ledgers(&t.env, 5); + t.client.unlock_assets(&t.user, &100); // banks 1200 * 2 * 5 = 12_000 + + assert_eq!(t.client.calculate_credits(&t.user), 33_000); +} + // ── get_user_position tests ─────────────────────────────────────────────────── #[test] diff --git a/soroban/contracts/farming-pool/src/types.rs b/soroban/contracts/farming-pool/src/types.rs index 0dc0a89..580691b 100644 --- a/soroban/contracts/farming-pool/src/types.rs +++ b/soroban/contracts/farming-pool/src/types.rs @@ -17,6 +17,8 @@ pub enum PoolError { /// Returned by `emergency_withdraw` when the user has no stake or locked position. NoActiveStake = 8, Paused = 9, + /// Returned when credit accrual would overflow `i128`; see #62. + CreditOverflow = 10, } /// Per-user boost configuration returned by `get_boost_config`.