From 32008a53d4c927f9bfdd974c96e6ed88d952a1dd Mon Sep 17 00:00:00 2001 From: dcccrypto Date: Sat, 4 Apr 2026 19:02:23 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20PERC-8462=20=E2=80=94=20vault=20capacity?= =?UTF-8?q?=20check=20+=20min=20initial=20deposit=20(ghost=20account=20fix?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream d94d064a: deposit(), add_user(), add_lp(), and deposit_fee_credits() now validate vault capacity BEFORE any state mutation. Prevents ghost accounts where a slot is allocated but the deposit fails due to vault cap overflow. Changes: - Add MAX_VAULT_TVL constant (10^30) — caps total token balance - deposit(): checked_add + cap check before mutations; min_initial_deposit guard (uses new_account_fee) for zero-capital accounts prevents dust - add_user()/add_lp(): vault cap check before alloc_slot() - deposit_fee_credits(): vault cap check before vault mutation - Vault assignments use pre-validated values (no redundant arithmetic) - 8 new unit tests covering cap boundary, dust rejection, state leak prevention --- src/percolator.rs | 79 ++++++++++++++++++++-- tests/unit_tests.rs | 160 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+), 7 deletions(-) diff --git a/src/percolator.rs b/src/percolator.rs index b08ab76d5..51e5a5c0e 100644 --- a/src/percolator.rs +++ b/src/percolator.rs @@ -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) // ============================================================================ @@ -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; @@ -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; @@ -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 @@ -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; @@ -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); diff --git a/tests/unit_tests.rs b/tests/unit_tests.rs index 3f6e6ffd4..012864627 100644 --- a/tests/unit_tests.rs +++ b/tests/unit_tests.rs @@ -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" + ); +}