From fd4b82e24b67c2d4003e268822719c0cfd01fee2 Mon Sep 17 00:00:00 2001 From: dcccrypto Date: Sat, 4 Apr 2026 18:24:38 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20PERC-8461=20=E2=80=94=20enable=20re?= =?UTF-8?q?curring=20maintenance=20fees=20per=20spec=20=C2=A78.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace settle_maintenance_fee_internal stub with full implementation: - Compute fee_due = maintenance_fee_per_slot × dt with checked arithmetic - Validate fee_due ≤ MAX_PROTOCOL_FEE_ABS (prevents unbounded accumulation) - Stamp last_fee_slot BEFORE charging (CEI: no re-charge on retry) - Deduct from fee_credits; if negative, pay from capital into insurance - Skip entirely when fee_per_slot == 0 (backward compatible) Add constants: - MAX_MAINTENANCE_FEE_PER_SLOT (10^12): admin param cap - MAX_PROTOCOL_FEE_ABS (10^18): per-invocation fee cap Add Params::validate() check for maintenance_fee_per_slot ≤ cap. 8 new tests covering: basic accrual, zero-dt noop, force_close path, params validation, zero-rate noop, fee_credits buffer, partial payment, constant sanity. --- src/percolator.rs | 82 ++++++++++++++++++++++- tests/unit_tests.rs | 155 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+), 2 deletions(-) diff --git a/src/percolator.rs b/src/percolator.rs index 51e5a5c0e..4d07e23f6 100644 --- a/src/percolator.rs +++ b/src/percolator.rs @@ -102,6 +102,16 @@ pub const MAX_POSITION_ABS: u128 = 100_000_000_000_000_000_000; /// 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 +/// Maximum allowed maintenance_fee_per_slot in Params (spec §8.2). +/// 10^12 per slot ≈ 1 USDC/slot at 6-decimal precision. +/// Validated in Params::validate() — admin cannot set higher. +pub const MAX_MAINTENANCE_FEE_PER_SLOT: u128 = 1_000_000_000_000; + +/// Maximum fee that a single settle_maintenance_fee_internal call may charge (spec §8.2). +/// Prevents a stale account from accumulating unbounded fee debt in one settlement. +/// 10^18 ≈ 1 billion USDC at 6 decimals — generous cap that still prevents u128 overflow. +pub const MAX_PROTOCOL_FEE_ABS: u128 = 1_000_000_000_000_000_000; + // ============================================================================ // BPF-Safe 128-bit Types (see src/i128.rs) // ============================================================================ @@ -595,6 +605,11 @@ impl RiskParams { // Insurance floor (spec §1.4: 0 <= I_floor <= MAX_ORACLE_PRICE * MAX_POSITION_ABS) // No separate MAX_VAULT_TVL constant in this fork; nonzero values are permissive. // Structural validity is enforced: insurance_floor must fit within U128. + + // Maintenance fee per slot must not exceed cap (spec §8.2) + if self.maintenance_fee_per_slot.get() > MAX_MAINTENANCE_FEE_PER_SLOT { + return Err(RiskError::Overflow); + } Ok(()) } @@ -1944,10 +1959,73 @@ impl RiskEngine { } } - /// settle_maintenance_fee_internal (spec §8.2): update last_fee_slot for account. - #[allow(dead_code)] + /// settle_maintenance_fee_internal (spec §8.2): compute and charge recurring maintenance fees. + /// + /// Algorithm (upstream e357d431): + /// 1. Compute dt = now_slot - last_fee_slot (checked to prevent underflow) + /// 2. If dt == 0, no-op + /// 3. fee_due = maintenance_fee_per_slot * dt (checked_mul prevents overflow) + /// 4. Validate fee_due <= MAX_PROTOCOL_FEE_ABS (rejects absurd accumulations) + /// 5. Stamp last_fee_slot BEFORE charging (prevents re-charge on retry/failure) + /// 6. Deduct from fee_credits; if negative, pay from capital into insurance + /// + /// Used by force_close_resolved and keeper_crank paths where margin checks + /// are inappropriate (the caller handles position closure/liquidation). fn settle_maintenance_fee_internal(&mut self, idx: usize, now_slot: u64) -> Result<()> { + let fee_per_slot = self.params.maintenance_fee_per_slot.get(); + if fee_per_slot == 0 { + // No maintenance fee configured — just advance the slot marker + self.accounts[idx].last_fee_slot = now_slot; + return Ok(()); + } + + let last_slot = self.accounts[idx].last_fee_slot; + // Checked subtraction: now_slot must be >= last_fee_slot + let dt = now_slot.checked_sub(last_slot).ok_or(RiskError::Overflow)?; + if dt == 0 { + return Ok(()); + } + + // Compute fee_due with checked arithmetic (prevents u128 overflow) + let fee_due = fee_per_slot + .checked_mul(dt as u128) + .ok_or(RiskError::Overflow)?; + + // Cap: reject absurd fee accumulation (e.g. stale account with huge dt) + if fee_due > MAX_PROTOCOL_FEE_ABS { + return Err(RiskError::Overflow); + } + + // CRITICAL: stamp last_fee_slot BEFORE charging. + // If the charge below fails or the transaction is retried, we will NOT + // re-compute the same fee window. This is the upstream CEI pattern. self.accounts[idx].last_fee_slot = now_slot; + + // Deduct from fee_credits (coupon buffer) + let fc = self.accounts[idx].fee_credits.get(); + let new_fc = fc.checked_sub(fee_due as i128).ok_or(RiskError::Overflow)?; + self.accounts[idx].fee_credits = I128::new(new_fc); + + // If fee_credits went negative, pay debt from capital into insurance + if new_fc < 0 { + let debt = neg_i128_to_u128(new_fc); + let cap = self.accounts[idx].capital.get(); + let pay = core::cmp::min(debt, cap); + if pay > 0 { + self.set_capital(idx, cap - pay); + let pay_i128 = core::cmp::min(pay, i128::MAX as u128) as i128; + self.accounts[idx].fee_credits = I128::new( + self.accounts[idx] + .fee_credits + .get() + .checked_add(pay_i128) + .ok_or(RiskError::Overflow)?, + ); + self.insurance_fund.balance += pay; + self.insurance_fund.fee_revenue += pay; + } + } + Ok(()) } diff --git a/tests/unit_tests.rs b/tests/unit_tests.rs index 012864627..d019c5ac3 100644 --- a/tests/unit_tests.rs +++ b/tests/unit_tests.rs @@ -7011,5 +7011,160 @@ fn test_deposit_ghost_account_no_state_leak_on_cap_failure() { engine.accounts[idx as usize].capital.get(), capital_before, "capital must not change on failed deposit" + +// PERC-8461: Recurring Maintenance Fees (spec §8.2) +// ============================================================================== + +/// Helper: create params with a nonzero maintenance_fee_per_slot. +fn params_with_maintenance_fee(fee_per_slot: u128) -> RiskParams { + let mut p = default_params(); + p.maintenance_fee_per_slot = U128::new(fee_per_slot); + p +} + +/// Basic fee accrual: after N slots, fee_due = fee_per_slot * N is charged. +#[test] +fn test_maintenance_fee_basic_accrual() { + let fee_per_slot = 10u128; + let mut engine = Box::new(RiskEngine::new(params_with_maintenance_fee(fee_per_slot))); + let idx = engine.add_user(0).unwrap(); + engine.deposit(idx, 100_000, 0).unwrap(); + set_insurance(&mut engine, 1_000); + engine.recompute_aggregates(); + assert_conserved(&engine); + + // Advance 100 slots via the public settle_maintenance_fee path + let dt = 100u64; + let now_slot = dt; + let paid = engine.settle_maintenance_fee(idx, now_slot, DEFAULT_ORACLE).unwrap(); + + // fee_due = 10 * 100 = 1000 + // fee_credits starts at 0, goes to -1000, then capital pays 1000 into insurance + assert_eq!(paid, fee_per_slot * dt as u128); + assert_eq!(engine.accounts[idx as usize].last_fee_slot, now_slot); + // Capital reduced by 1000 + assert_eq!(engine.accounts[idx as usize].capital.get(), 100_000 - 1000); + assert_conserved(&engine); +} + +/// Zero dt is a no-op — no fee charged. +#[test] +fn test_maintenance_fee_zero_dt_noop() { + let mut engine = Box::new(RiskEngine::new(params_with_maintenance_fee(10))); + let idx = engine.add_user(0).unwrap(); + engine.deposit(idx, 100_000, 0).unwrap(); + set_insurance(&mut engine, 1_000); + + // Set last_fee_slot = 50, then call with now_slot = 50 → dt = 0 + engine.accounts[idx as usize].last_fee_slot = 50; + let paid = engine.settle_maintenance_fee(idx, 50, DEFAULT_ORACLE).unwrap(); + assert_eq!(paid, 0); + assert_eq!(engine.accounts[idx as usize].capital.get(), 100_000); +} + +/// Fee charges through force_close_resolved path (internal fn). +#[test] +fn test_maintenance_fee_via_force_close_resolved() { + let fee_per_slot = 5u128; + let mut engine = Box::new(RiskEngine::new(params_with_maintenance_fee(fee_per_slot))); + let user = engine.add_user(0).unwrap(); + engine.deposit(user, 10_000, 0).unwrap(); + set_insurance(&mut engine, 500); + engine.recompute_aggregates(); + assert_conserved(&engine); + + // Advance current_slot so force_close_resolved will compute dt > 0 + engine.current_slot = 200; + // last_fee_slot defaults to current_slot at deposit time (0) + // So dt = 200, fee_due = 5 * 200 = 1000 + + let capital_returned = engine.force_close_resolved(user).unwrap(); + + // Capital was 10_000, fee charged = 1000, returned = 10_000 - 1000 = 9_000 + assert_eq!(capital_returned, 9_000); + assert!(!engine.is_used(user as usize)); +} + +/// Validate Params rejects maintenance_fee_per_slot > MAX_MAINTENANCE_FEE_PER_SLOT. +#[test] +fn test_maintenance_fee_params_validation() { + use percolator::MAX_MAINTENANCE_FEE_PER_SLOT; + + // At the limit — should be accepted + let mut p = params_with_maintenance_fee(MAX_MAINTENANCE_FEE_PER_SLOT); + assert!(p.validate().is_ok(), "fee at cap must be accepted"); + + // Above the limit — rejected + p.maintenance_fee_per_slot = U128::new(MAX_MAINTENANCE_FEE_PER_SLOT + 1); + assert!(p.validate().is_err(), "fee above cap must be rejected"); +} + +/// Fee with zero fee_per_slot is no-op even with large dt. +#[test] +fn test_maintenance_fee_zero_rate_noop() { + let mut engine = Box::new(RiskEngine::new(params_with_maintenance_fee(0))); + let idx = engine.add_user(0).unwrap(); + engine.deposit(idx, 100_000, 0).unwrap(); + set_insurance(&mut engine, 1_000); + + let paid = engine.settle_maintenance_fee(idx, 1_000_000, DEFAULT_ORACLE).unwrap(); + assert_eq!(paid, 0); + assert_eq!(engine.accounts[idx as usize].capital.get(), 100_000); +} + +/// Fee deducted from fee_credits first, then capital if credits go negative. +#[test] +fn test_maintenance_fee_credits_buffer() { + let fee_per_slot = 10u128; + let mut engine = Box::new(RiskEngine::new(params_with_maintenance_fee(fee_per_slot))); + let idx = engine.add_user(0).unwrap(); + engine.deposit(idx, 100_000, 0).unwrap(); + set_insurance(&mut engine, 1_000); + + // Give account 500 fee credits (coupon) + engine.accounts[idx as usize].fee_credits = I128::new(500); + engine.recompute_aggregates(); + assert_conserved(&engine); + + // 100 slots → fee_due = 1000 + // fee_credits: 500 - 1000 = -500 → pay 500 from capital + let paid = engine.settle_maintenance_fee(idx, 100, DEFAULT_ORACLE).unwrap(); + assert_eq!(paid, 500); // only the capital portion + assert_eq!(engine.accounts[idx as usize].capital.get(), 100_000 - 500); +} + +/// Fee with insufficient capital: pays what it can, remainder stays as debt. +#[test] +fn test_maintenance_fee_partial_payment() { + let fee_per_slot = 100u128; + let mut engine = Box::new(RiskEngine::new(params_with_maintenance_fee(fee_per_slot))); + let idx = engine.add_user(0).unwrap(); + engine.deposit(idx, 500, 0).unwrap(); // small capital + set_insurance(&mut engine, 1_000); + engine.recompute_aggregates(); + assert_conserved(&engine); + + // 100 slots → fee_due = 10_000, but capital is only 500 + let paid = engine.settle_maintenance_fee(idx, 100, DEFAULT_ORACLE).unwrap(); + assert_eq!(paid, 500); // all capital consumed + assert_eq!(engine.accounts[idx as usize].capital.get(), 0); + // Remaining debt stays in fee_credits (negative) + assert!(engine.accounts[idx as usize].fee_credits.get() < 0); +} + +/// MAX_PROTOCOL_FEE_ABS and MAX_MAINTENANCE_FEE_PER_SLOT constants are sane. +#[test] +fn test_maintenance_fee_constants() { + use percolator::{MAX_MAINTENANCE_FEE_PER_SLOT, MAX_PROTOCOL_FEE_ABS}; + + assert_eq!(MAX_MAINTENANCE_FEE_PER_SLOT, 1_000_000_000_000); + assert_eq!(MAX_PROTOCOL_FEE_ABS, 1_000_000_000_000_000_000); + + // MAX_MAINTENANCE_FEE_PER_SLOT * u16::MAX should not exceed MAX_PROTOCOL_FEE_ABS + // (i.e., even at max rate for max funding dt, the fee is within cap) + let max_fee = MAX_MAINTENANCE_FEE_PER_SLOT * (u16::MAX as u128); + assert!( + max_fee <= MAX_PROTOCOL_FEE_ABS, + "max_fee_per_slot * max_dt must fit within MAX_PROTOCOL_FEE_ABS" ); } From a2081706729329bb6356db4b77660466260c0c6c Mon Sep 17 00:00:00 2001 From: dcccrypto Date: Sat, 4 Apr 2026 18:52:46 +0100 Subject: [PATCH 2/2] =?UTF-8?q?style:=20cargo=20fmt=20=E2=80=94=20fix=20me?= =?UTF-8?q?thod=20chain=20line=20breaks=20in=20maintenance=20fee=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit_tests.rs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/unit_tests.rs b/tests/unit_tests.rs index d019c5ac3..da0591833 100644 --- a/tests/unit_tests.rs +++ b/tests/unit_tests.rs @@ -7011,6 +7011,8 @@ fn test_deposit_ghost_account_no_state_leak_on_cap_failure() { engine.accounts[idx as usize].capital.get(), capital_before, "capital must not change on failed deposit" + ); +} // PERC-8461: Recurring Maintenance Fees (spec §8.2) // ============================================================================== @@ -7036,7 +7038,9 @@ fn test_maintenance_fee_basic_accrual() { // Advance 100 slots via the public settle_maintenance_fee path let dt = 100u64; let now_slot = dt; - let paid = engine.settle_maintenance_fee(idx, now_slot, DEFAULT_ORACLE).unwrap(); + let paid = engine + .settle_maintenance_fee(idx, now_slot, DEFAULT_ORACLE) + .unwrap(); // fee_due = 10 * 100 = 1000 // fee_credits starts at 0, goes to -1000, then capital pays 1000 into insurance @@ -7057,7 +7061,9 @@ fn test_maintenance_fee_zero_dt_noop() { // Set last_fee_slot = 50, then call with now_slot = 50 → dt = 0 engine.accounts[idx as usize].last_fee_slot = 50; - let paid = engine.settle_maintenance_fee(idx, 50, DEFAULT_ORACLE).unwrap(); + let paid = engine + .settle_maintenance_fee(idx, 50, DEFAULT_ORACLE) + .unwrap(); assert_eq!(paid, 0); assert_eq!(engine.accounts[idx as usize].capital.get(), 100_000); } @@ -7107,7 +7113,9 @@ fn test_maintenance_fee_zero_rate_noop() { engine.deposit(idx, 100_000, 0).unwrap(); set_insurance(&mut engine, 1_000); - let paid = engine.settle_maintenance_fee(idx, 1_000_000, DEFAULT_ORACLE).unwrap(); + let paid = engine + .settle_maintenance_fee(idx, 1_000_000, DEFAULT_ORACLE) + .unwrap(); assert_eq!(paid, 0); assert_eq!(engine.accounts[idx as usize].capital.get(), 100_000); } @@ -7128,7 +7136,9 @@ fn test_maintenance_fee_credits_buffer() { // 100 slots → fee_due = 1000 // fee_credits: 500 - 1000 = -500 → pay 500 from capital - let paid = engine.settle_maintenance_fee(idx, 100, DEFAULT_ORACLE).unwrap(); + let paid = engine + .settle_maintenance_fee(idx, 100, DEFAULT_ORACLE) + .unwrap(); assert_eq!(paid, 500); // only the capital portion assert_eq!(engine.accounts[idx as usize].capital.get(), 100_000 - 500); } @@ -7145,7 +7155,9 @@ fn test_maintenance_fee_partial_payment() { assert_conserved(&engine); // 100 slots → fee_due = 10_000, but capital is only 500 - let paid = engine.settle_maintenance_fee(idx, 100, DEFAULT_ORACLE).unwrap(); + let paid = engine + .settle_maintenance_fee(idx, 100, DEFAULT_ORACLE) + .unwrap(); assert_eq!(paid, 500); // all capital consumed assert_eq!(engine.accounts[idx as usize].capital.get(), 0); // Remaining debt stays in fee_credits (negative)