Skip to content
Merged
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
79 changes: 72 additions & 7 deletions src/percolator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ pub const POS_SCALE: u128 = 1_000_000;
/// Combined with MAX_ORACLE_PRICE, guarantees mark_pnl multiply won't overflow i128
pub const MAX_POSITION_ABS: u128 = 100_000_000_000_000_000_000;

/// Maximum vault TVL (spec §2.2).
/// Caps total token balance to prevent overflow in downstream accounting.
/// 10^30 ≈ 1 trillion tokens at 10^18 decimal precision — well above any
/// realistic deployment while still fitting comfortably within u128 arithmetic.
pub const MAX_VAULT_TVL: u128 = 1_000_000_000_000_000_000_000_000_000_000; // 10^30

// ============================================================================
// BPF-Safe 128-bit Types (see src/i128.rs)
// ============================================================================
Expand Down Expand Up @@ -2270,12 +2276,25 @@ impl RiskEngine {
return Err(RiskError::InsufficientBalance);
}

// --- GHOST ACCOUNT FIX (upstream d94d064a) ---
// Vault capacity check BEFORE slot allocation.
// Prevents ghost accounts: if vault cap is exceeded, no slot is consumed.
let new_vault = self
.vault
.get()
.checked_add(fee_payment)
.ok_or(RiskError::Overflow)?;
if new_vault > MAX_VAULT_TVL {
return Err(RiskError::Overflow);
}

// Bug #4 fix: Compute excess payment to credit to user capital
let excess = fee_payment.saturating_sub(required_fee);

// Pay fee to insurance (fee tokens are deposited into vault)
// Account for FULL fee_payment in vault, not just required_fee
self.vault += fee_payment;
// Account for FULL fee_payment in vault, not just required_fee.
// Uses pre-validated new_vault from capacity check above.
self.vault = U128::new(new_vault);
self.insurance_fund.balance += required_fee;
self.insurance_fund.fee_revenue += required_fee;

Expand Down Expand Up @@ -2335,12 +2354,24 @@ impl RiskEngine {
return Err(RiskError::InsufficientBalance);
}

// --- GHOST ACCOUNT FIX (upstream d94d064a) ---
// Vault capacity check BEFORE slot allocation.
let new_vault = self
.vault
.get()
.checked_add(fee_payment)
.ok_or(RiskError::Overflow)?;
if new_vault > MAX_VAULT_TVL {
return Err(RiskError::Overflow);
}

// Bug #4 fix: Compute excess payment to credit to LP capital
let excess = fee_payment.saturating_sub(required_fee);

// Pay fee to insurance (fee tokens are deposited into vault)
// Account for FULL fee_payment in vault, not just required_fee
self.vault += fee_payment;
// Account for FULL fee_payment in vault, not just required_fee.
// Uses pre-validated new_vault from capacity check above.
self.vault = U128::new(new_vault);
self.insurance_fund.balance += required_fee;
self.insurance_fund.fee_revenue += required_fee;

Expand Down Expand Up @@ -2625,8 +2656,19 @@ impl RiskEngine {
}
self.current_slot = now_slot;

// --- GHOST ACCOUNT FIX (upstream d94d064a) ---
// Vault capacity check before mutation.
let new_vault = self
.vault
.get()
.checked_add(amount)
.ok_or(RiskError::Overflow)?;
if new_vault > MAX_VAULT_TVL {
return Err(RiskError::Overflow);
}

// Wrapper transferred tokens into vault
self.vault += amount;
self.vault = U128::new(new_vault);

// Pre-fund: insurance receives the amount now.
// When credits are later spent during fee settlement, no further
Expand Down Expand Up @@ -4657,6 +4699,28 @@ impl RiskEngine {
return Err(RiskError::AccountNotFound);
}

// --- GHOST ACCOUNT FIX (upstream d94d064a) ---
// Vault capacity check BEFORE any state mutation.
// If this fails, no account state is touched — prevents ghost accounts
// where a slot is allocated but the deposit is rejected.
let new_vault = self
.vault
.get()
.checked_add(amount)
.ok_or(RiskError::Overflow)?;
if new_vault > MAX_VAULT_TVL {
return Err(RiskError::Overflow);
}

// Minimum initial deposit check for accounts with zero capital.
// Prevents dust accounts that are expensive to GC but hold no real value.
// new_account_fee doubles as the minimum deposit floor (spec §2.2).
let min_deposit = self.params.new_account_fee.get();
if min_deposit > 0 && self.accounts[idx as usize].capital.get() == 0 && amount < min_deposit
{
return Err(RiskError::InsufficientBalance);
}

let account = &mut self.accounts[idx as usize];
let mut deposit_remaining = amount;

Expand Down Expand Up @@ -4690,8 +4754,9 @@ impl RiskEngine {
.saturating_add(u128_to_i128_clamped(pay));
}

// Vault gets full deposit (tokens received)
self.vault = U128::new(add_u128(self.vault.get(), amount));
// Vault gets full deposit (tokens received).
// Uses pre-validated new_vault from the capacity check above.
self.vault = U128::new(new_vault);

// Capital gets remainder after fees (via set_capital to maintain c_tot)
let new_cap = add_u128(self.accounts[idx as usize].capital.get(), deposit_remaining);
Expand Down
160 changes: 160 additions & 0 deletions tests/unit_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6853,3 +6853,163 @@ fn test_settle_side_effects_zero_basis_noop() {
let result = engine.settle_side_effects(idx as usize);
assert!(result.is_ok(), "zero basis must be a no-op");
}

// ============================================================================
// PERC-8462: Deposit ghost account fix — vault capacity + min initial deposit
// ============================================================================

#[test]
fn test_deposit_vault_capacity_rejects_overflow() {
let mut engine = *Box::new(RiskEngine::new(default_params()));
let idx = engine.add_user(0).unwrap();

// Artificially set vault near MAX_VAULT_TVL
engine.vault = percolator::U128::new(percolator::MAX_VAULT_TVL - 100);

// Deposit that fits within cap succeeds
let result = engine.deposit(idx, 100, 1);
assert!(result.is_ok(), "deposit within cap must succeed");

// Vault is now exactly at MAX_VAULT_TVL; any further deposit must fail
let result = engine.deposit(idx, 1, 2);
assert!(result.is_err(), "deposit exceeding vault cap must fail");
}

#[test]
fn test_deposit_vault_capacity_exact_boundary() {
let mut engine = *Box::new(RiskEngine::new(default_params()));
let idx = engine.add_user(0).unwrap();

// Set vault so that deposit brings it exactly to MAX_VAULT_TVL
engine.vault = percolator::U128::new(percolator::MAX_VAULT_TVL - 50_000);
let result = engine.deposit(idx, 50_000, 1);
assert!(
result.is_ok(),
"deposit to exactly MAX_VAULT_TVL must succeed"
);
assert_eq!(engine.vault.get(), percolator::MAX_VAULT_TVL);
}

#[test]
fn test_deposit_min_initial_deposit_rejects_dust() {
let mut params = default_params();
params.new_account_fee = percolator::U128::new(1_000); // min deposit = 1000
let mut engine = *Box::new(RiskEngine::new(params));
// add_user with exact fee — capital starts at 0
let idx = engine.add_user(1_000).unwrap();
assert_eq!(engine.accounts[idx as usize].capital.get(), 0);

// Dust deposit (< min_initial_deposit) on zero-capital account must fail
let result = engine.deposit(idx, 999, 1);
assert!(
result.is_err(),
"dust deposit on zero-capital account must fail"
);

// Deposit exactly at min threshold succeeds
let result = engine.deposit(idx, 1_000, 2);
assert!(
result.is_ok(),
"deposit at min_initial_deposit threshold must succeed"
);
}

#[test]
fn test_deposit_min_initial_deposit_allows_subsequent_dust() {
let mut params = default_params();
params.new_account_fee = percolator::U128::new(1_000);
let mut engine = *Box::new(RiskEngine::new(params));
let idx = engine.add_user(1_000).unwrap();

// First deposit meets minimum
engine.deposit(idx, 5_000, 1).unwrap();
assert!(engine.accounts[idx as usize].capital.get() > 0);

// Subsequent small deposits are fine (account already has capital)
let result = engine.deposit(idx, 1, 2);
assert!(
result.is_ok(),
"small deposit on funded account must succeed"
);
}

#[test]
fn test_add_user_vault_capacity_rejects_overflow() {
let mut engine = *Box::new(RiskEngine::new(default_params()));

// Set vault near MAX_VAULT_TVL
engine.vault = percolator::U128::new(percolator::MAX_VAULT_TVL - 10);

// add_user with fee_payment > remaining cap must fail
let result = engine.add_user(11);
assert!(result.is_err(), "add_user exceeding vault cap must fail");

// add_user with fee_payment within cap succeeds
let result = engine.add_user(10);
assert!(result.is_ok(), "add_user within vault cap must succeed");
}

#[test]
fn test_add_lp_vault_capacity_rejects_overflow() {
let mut engine = *Box::new(RiskEngine::new(default_params()));

// Set vault near MAX_VAULT_TVL
engine.vault = percolator::U128::new(percolator::MAX_VAULT_TVL - 10);

// add_lp with fee_payment > remaining cap must fail
let result = engine.add_lp([0; 32], [0; 32], 11);
assert!(result.is_err(), "add_lp exceeding vault cap must fail");

// add_lp with fee_payment within cap succeeds
let result = engine.add_lp([0; 32], [0; 32], 10);
assert!(result.is_ok(), "add_lp within vault cap must succeed");
}

#[test]
fn test_deposit_fee_credits_vault_capacity_rejects_overflow() {
let mut engine = *Box::new(RiskEngine::new(default_params()));
let idx = engine.add_user(0).unwrap();
engine.deposit(idx, 1_000, 0).unwrap();

// Set vault near MAX_VAULT_TVL
engine.vault = percolator::U128::new(percolator::MAX_VAULT_TVL - 100);

// deposit_fee_credits within cap succeeds
let result = engine.deposit_fee_credits(idx, 100, 1);
assert!(result.is_ok(), "fee credits within cap must succeed");

// Exceeding cap fails
let result = engine.deposit_fee_credits(idx, 1, 2);
assert!(result.is_err(), "fee credits exceeding vault cap must fail");
}

#[test]
fn test_deposit_ghost_account_no_state_leak_on_cap_failure() {
let mut engine = *Box::new(RiskEngine::new(default_params()));
let idx = engine.add_user(0).unwrap();
engine.deposit(idx, 10_000, 0).unwrap();

// Record state before failed deposit
let vault_before = engine.vault.get();
let capital_before = engine.accounts[idx as usize].capital.get();
let c_tot_before = engine.c_tot.get();

// Set vault so next deposit will exceed cap
engine.vault = percolator::U128::new(percolator::MAX_VAULT_TVL);
let vault_at_cap = engine.vault.get();

let result = engine.deposit(idx, 1, 1);
assert!(result.is_err());

// Verify NO state was mutated on failure
assert_eq!(
engine.vault.get(),
vault_at_cap,
"vault must not change on failed deposit"
);
assert_eq!(
engine.accounts[idx as usize].capital.get(),
capital_before,
"capital must not change on failed deposit"
);
}
Loading