From f441d4dfd1936040b37c14ffb525831e88485705 Mon Sep 17 00:00:00 2001 From: MimiTechSolutions Date: Mon, 17 Aug 2026 07:58:38 +0000 Subject: [PATCH 1/2] refactor(farming-pool): share Position credit accrual via compute_position_credits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the amount * credit_rate * elapsed formula into a single compute_position_credits helper used by both checkpoint_position and calculate_credits, eliminating the duplicated calculation. Deliberately kept separate from compute_credits since Positions have no boost or multiplier semantics. Add unit coverage for the shared accrual path, adapted to the pool's min_stake_amount gate. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- soroban/contracts/farming-pool/src/lib.rs | 16 ++++- soroban/contracts/farming-pool/src/test.rs | 74 ++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/soroban/contracts/farming-pool/src/lib.rs b/soroban/contracts/farming-pool/src/lib.rs index 132d0db..987d3d6 100644 --- a/soroban/contracts/farming-pool/src/lib.rs +++ b/soroban/contracts/farming-pool/src/lib.rs @@ -262,6 +262,16 @@ fn compute_credits( compute_total_stake(amount, allocation_pct, multiplier) * credit_rate * ledgers_elapsed as i128 } +/// 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) -> i128 { + amount * credit_rate * elapsed as i128 +} + fn checkpoint(env: &Env, user: &Address, stake: &mut UserStake) { let allocation_pct = get_user_boost(env, user).unwrap_or(0); let multiplier = read_global_multiplier(env); @@ -281,7 +291,8 @@ fn checkpoint(env: &Env, user: &Address, stake: &mut UserStake) { fn checkpoint_position(env: &Env, position: &mut Position) { let current = env.ledger().sequence(); let elapsed = current.saturating_sub(position.checkpoint_ledger); - position.total_credits += position.amount * position.credit_rate * elapsed as i128; + position.total_credits += + compute_position_credits(position.amount, position.credit_rate, elapsed); position.checkpoint_ledger = current; position.credit_rate = read_credit_rate(env); } @@ -505,7 +516,8 @@ impl FarmingPool { .ledger() .sequence() .saturating_sub(position.checkpoint_ledger); - Ok(position.total_credits + position.amount * position.credit_rate * elapsed as i128) + Ok(position.total_credits + + compute_position_credits(position.amount, position.credit_rate, elapsed)) } pub fn get_user_position(env: Env, user: Address) -> Result, PoolError> { diff --git a/soroban/contracts/farming-pool/src/test.rs b/soroban/contracts/farming-pool/src/test.rs index 14d42d9..b253bf0 100644 --- a/soroban/contracts/farming-pool/src/test.rs +++ b/soroban/contracts/farming-pool/src/test.rs @@ -1107,6 +1107,80 @@ 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), 0); + assert_eq!(compute_position_credits(5_000, 100, 0), 0); +} + +#[test] +fn test_compute_position_credits_positive() { + // amount * credit_rate * elapsed + assert_eq!(compute_position_credits(500, 2, 20), 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), + expected, + "amount={amount}, credit_rate={credit_rate}, elapsed={elapsed}" + ); + } +} + +#[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] From eaff3276d1d49d829e1decc6becc3b6a97b1ed26 Mon Sep 17 00:00:00 2001 From: MimiTechSolutions Date: Mon, 17 Aug 2026 08:05:46 +0000 Subject: [PATCH 2/2] feat(farming-pool): surface typed CreditOverflow instead of trapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the unchecked i128 multiplication chain in the credit-accrual path with checked_mul/checked_add and propagate a new typed PoolError::CreditOverflow (10) instead of trapping the whole invocation on overflow. Covers compute_total_stake, compute_credits, and the shared compute_position_credits helper (from #63), propagated through checkpoint, checkpoint_position, get_credits, calculate_credits, and their callers so an overflowing user receives a recoverable typed error rather than a permanent host trap. Complements the #89 input ceilings as defense-in-depth for user-supplied amounts. Adds unit and integration coverage for overflow at each multiplication step and verifies unstake/unlock_assets preserve the user's stake and position so funds are recoverable once the admin corrects the rate. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- soroban/contracts/farming-pool/src/lib.rs | 99 ++++++++++----- soroban/contracts/farming-pool/src/test.rs | 127 ++++++++++++++++++-- soroban/contracts/farming-pool/src/types.rs | 2 + 3 files changed, 189 insertions(+), 39 deletions(-) diff --git a/soroban/contracts/farming-pool/src/lib.rs b/soroban/contracts/farming-pool/src/lib.rs index 987d3d6..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,8 +271,12 @@ 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`. @@ -268,33 +285,49 @@ fn compute_credits( /// 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) -> i128 { - amount * credit_rate * elapsed as i128 +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 += - compute_position_credits(position.amount, position.credit_rate, elapsed); + 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] @@ -421,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 { @@ -480,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; @@ -516,8 +549,11 @@ impl FarmingPool { .ledger() .sequence() .saturating_sub(position.checkpoint_ledger); - Ok(position.total_credits - + compute_position_credits(position.amount, position.credit_rate, elapsed)) + 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> { @@ -695,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 { @@ -730,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. @@ -758,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); } @@ -872,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 b253bf0..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); } @@ -1112,14 +1112,14 @@ fn test_calculate_credits_reflects_partial_unlock_checkpoint() { #[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), 0); - assert_eq!(compute_position_credits(5_000, 100, 0), 0); + 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), 20_000); + assert_eq!(compute_position_credits(500, 2, 20).unwrap(), 20_000); } #[test] @@ -1133,13 +1133,122 @@ fn test_compute_position_credits_combinations() { ]; for (amount, credit_rate, elapsed, expected) in cases { assert_eq!( - compute_position_credits(amount, credit_rate, elapsed), + 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 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`.