Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 43 additions & 9 deletions soroban/contracts/farming-pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -616,11 +616,24 @@ fn set_whitelisted_users_list(env: &Env, users: &Vec<Address>) {
/// 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<i128, PoolError> {
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(
Expand All @@ -629,8 +642,29 @@ 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<i128, PoolError> {
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<i128, PoolError> {
amount
.checked_mul(credit_rate)
.and_then(|v| v.checked_mul(elapsed as i128))
.ok_or(PoolError::CreditOverflow)
}

fn compute_stake_accrual(env: &Env, user: &Address, stake: &UserStake, current: u32) -> i128 {
Expand Down Expand Up @@ -1531,7 +1565,7 @@ impl FarmingPool {
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);
checkpoint(&env, &from, &mut existing)?;
existing.amount += amount;
existing
} else {
Expand Down Expand Up @@ -1588,7 +1622,7 @@ impl FarmingPool {

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);
checkpoint(&env, &from, &mut stake)?;
let total_credits = stake.credits_banked;
if total_credits > 0 {
subtract_total_banked_credits(&env, total_credits);
Expand Down
193 changes: 188 additions & 5 deletions soroban/contracts/farming-pool/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,36 +420,36 @@ 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);
}

#[test]
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);
}

Expand Down Expand Up @@ -1698,6 +1698,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]
Expand Down
Loading