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
82 changes: 80 additions & 2 deletions src/percolator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// ============================================================================
Expand Down Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Ok(())
}

Expand Down Expand Up @@ -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;
}
}
Comment on lines +1962 to +2027

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unify §8.2 fee calculation across all settlement entrypoints.

This helper now enforces checked arithmetic and MAX_PROTOCOL_FEE_ABS, but the other fee paths in this file — RiskEngine::settle_maintenance_fee, RiskEngine::settle_maintenance_fee_best_effort_for_crank, and the manual settlement in RiskEngine::deposit — still use the old saturating dt * fee_per_slot logic. The same stale account can therefore be rejected here, charged uncapped on normal user ops, or processed differently in the fallback crank path. Please factor the dt / fee_due computation into a shared helper and reuse it everywhere maintenance fees are settled.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/percolator.rs` around lines 1956 - 2021, The fee calculation is
inconsistent: settle_maintenance_fee_internal uses checked arithmetic and
MAX_PROTOCOL_FEE_ABS but other paths (RiskEngine::settle_maintenance_fee,
RiskEngine::settle_maintenance_fee_best_effort_for_crank, and the manual
settlement in RiskEngine::deposit) still use saturating dt * fee_per_slot;
extract a shared helper (e.g., compute_maintenance_fee(dt: u64, fee_per_slot:
u128) -> Result<u128, RiskError>) that performs checked_sub for dt, checked_mul
for fee_due, and validates fee_due <= MAX_PROTOCOL_FEE_ABS using the same error
(RiskError::Overflow), then replace the inline dt*fee_per_slot logic in
settle_maintenance_fee_internal, RiskEngine::settle_maintenance_fee,
RiskEngine::settle_maintenance_fee_best_effort_for_crank, and
RiskEngine::deposit to call this helper and use its returned fee_due.


Ok(())
}

Expand Down
167 changes: 167 additions & 0 deletions tests/unit_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7013,3 +7013,170 @@ fn test_deposit_ghost_account_no_state_leak_on_cap_failure() {
"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"
);
Comment on lines +7167 to +7181

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

u16::MAX is the wrong bound for maintenance-fee accrual safety.

This assertion is too weak: maintenance fee dt is slot delta (u64), not funding dt (u16). The current check can pass even if the cap guard is broken for larger dt.

Stronger cap-bound assertion and test intent
 // 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);
+// Maintenance dt is slot-based (u64). Bound should be derived from the cap itself.
+let dt_cap = MAX_PROTOCOL_FEE_ABS / MAX_MAINTENANCE_FEE_PER_SLOT;
+let max_fee = MAX_MAINTENANCE_FEE_PER_SLOT * dt_cap;
 assert!(
     max_fee <= MAX_PROTOCOL_FEE_ABS,
-    "max_fee_per_slot * max_dt must fit within MAX_PROTOCOL_FEE_ABS"
+    "max_fee_per_slot * dt_cap must fit within MAX_PROTOCOL_FEE_ABS"
 );
+assert!(
+    MAX_MAINTENANCE_FEE_PER_SLOT.saturating_mul(dt_cap + 1) > MAX_PROTOCOL_FEE_ABS,
+    "dt above cap-derived bound must exceed MAX_PROTOCOL_FEE_ABS"
+);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// 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"
);
/// 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);
// Maintenance dt is slot-based (u64). Bound should be derived from the cap itself.
let dt_cap = MAX_PROTOCOL_FEE_ABS / MAX_MAINTENANCE_FEE_PER_SLOT;
let max_fee = MAX_MAINTENANCE_FEE_PER_SLOT * dt_cap;
assert!(
max_fee <= MAX_PROTOCOL_FEE_ABS,
"max_fee_per_slot * dt_cap must fit within MAX_PROTOCOL_FEE_ABS"
);
assert!(
MAX_MAINTENANCE_FEE_PER_SLOT.saturating_mul(dt_cap + 1) > MAX_PROTOCOL_FEE_ABS,
"dt above cap-derived bound must exceed MAX_PROTOCOL_FEE_ABS"
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit_tests.rs` around lines 6868 - 6882, The test uses u16::MAX but
maintenance-fee accrual dt is a slot delta of type u64, so update the assertion
in test_maintenance_fee_constants to multiply MAX_MAINTENANCE_FEE_PER_SLOT by
u64::MAX (cast to u128) instead of u16::MAX; change the computation of max_fee
(currently using u16::MAX) to use (u64::MAX as u128) and keep the assert that
max_fee <= MAX_PROTOCOL_FEE_ABS (update the assertion message if desired) so the
check correctly reflects the real dt type.

}
Loading