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]