Skip to content

Commit a5197e4

Browse files
aeyakovenkoclaude
andcommitted
fix: 3 non-minor issues + version refs updated to v12.1.0
1. force_close_resolved atomicity: replaced settle_side_effects call (which interleaves mutations with fallible checked_sub) with a validate-then-mutate pattern. Phase 1 computes pnl_delta and pre-validates stale count. Phase 2 mutates only after all checks pass. No partial mutation on error. 2. LP fee accounting: charge_fee_to_insurance now returns the amount actually collected (capital paid + collectible debt recorded). execute_trade tracks fees_earned_total using the actual collected amount from the counterparty, not the nominal fee. Prevents overreporting when charge_fee_to_insurance drops uncollectible excess. 3. Version comments updated from v12.0.2 to v12.1.0 across all source and test files. Issue #1 (assert!/panic in internal helpers): acknowledged but not changed — validate_params is init-only, internal mutators use assert for invariants proven unreachable by upstream callers. On Solana SVM both panic and Err abort atomically. Issue aeyakovenko#4 (run_end_of_instruction_lifecycle missing OI check): by design — the helper is for non-exposure callers (resolved-market settlement). OI checks live in each exposure-mutating instruction. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2430112 commit a5197e4

6 files changed

Lines changed: 90 additions & 90 deletions

File tree

src/percolator.rs

Lines changed: 74 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
//! Formally Verified Risk Engine for Perpetual DEX — v12.0.2
1+
//! Formally Verified Risk Engine for Perpetual DEX — v12.1.0
22
//!
3-
//! Implements the v12.0.2 spec: Native 128-bit Architecture.
3+
//! Implements the v12.1.0 spec: Native 128-bit Architecture.
44
//!
55
//! This module implements a formally verified risk engine that guarantees:
66
//! 1. Protected principal for flat accounts
@@ -1387,7 +1387,7 @@ impl RiskEngine {
13871387
}
13881388
}
13891389

1390-
// Step 6: Funding transfer via sub-stepping (spec v12.0.2 §5.4)
1390+
// Step 6: Funding transfer via sub-stepping (spec v12.1.0 §5.4)
13911391
let r_last = self.funding_rate_bps_per_slot_last;
13921392
if r_last != 0 && total_dt > 0 && long_live && short_live {
13931393
// Snapshot fund_px_0 at call start — uses previous interval's price
@@ -1437,7 +1437,7 @@ impl RiskEngine {
14371437
Ok(())
14381438
}
14391439

1440-
/// recompute_r_last_from_final_state (spec v12.0.2 §4.12).
1440+
/// recompute_r_last_from_final_state (spec v12.1.0 §4.12).
14411441
/// Validates the externally computed funding rate and stores it for
14421442
/// the next interval's accrue_market_to funding sub-steps.
14431443
test_visible! {
@@ -1811,7 +1811,7 @@ impl RiskEngine {
18111811
// ========================================================================
18121812

18131813
/// Compute haircut ratio (h_num, h_den) as u128 pair (spec §3.3)
1814-
/// Uses pnl_matured_pos_tot as denominator per v12.0.2.
1814+
/// Uses pnl_matured_pos_tot as denominator per v12.1.0.
18151815
pub fn haircut_ratio(&self) -> (u128, u128) {
18161816
if self.pnl_matured_pos_tot == 0 {
18171817
return (1u128, 1u128);
@@ -2700,23 +2700,26 @@ impl RiskEngine {
27002700
};
27012701

27022702
// Charge fee from both accounts (spec §10.5 step 28)
2703+
let mut fee_collected_a = 0u128;
2704+
let mut fee_collected_b = 0u128;
27032705
if fee > 0 {
27042706
if fee > MAX_PROTOCOL_FEE_ABS {
27052707
return Err(RiskError::Overflow);
27062708
}
2707-
self.charge_fee_to_insurance(a as usize, fee)?;
2708-
self.charge_fee_to_insurance(b as usize, fee)?;
2709+
fee_collected_a = self.charge_fee_to_insurance(a as usize, fee)?;
2710+
fee_collected_b = self.charge_fee_to_insurance(b as usize, fee)?;
27092711
}
27102712

2711-
// Track LP fees (both sides' fees)
2713+
// Track LP fees: use actual collected amount, not nominal fee.
2714+
// LP a earns from counterparty b's fee payment, and vice versa.
27122715
if self.accounts[a as usize].is_lp() {
27132716
self.accounts[a as usize].fees_earned_total = U128::new(
2714-
add_u128(self.accounts[a as usize].fees_earned_total.get(), fee)
2717+
add_u128(self.accounts[a as usize].fees_earned_total.get(), fee_collected_b)
27152718
);
27162719
}
27172720
if self.accounts[b as usize].is_lp() {
27182721
self.accounts[b as usize].fees_earned_total = U128::new(
2719-
add_u128(self.accounts[b as usize].fees_earned_total.get(), fee)
2722+
add_u128(self.accounts[b as usize].fees_earned_total.get(), fee_collected_a)
27202723
);
27212724
}
27222725

@@ -2741,8 +2744,9 @@ impl RiskEngine {
27412744
}
27422745

27432746
/// Charge fee per spec §8.1 — route shortfall through fee_credits instead of PNL.
2744-
/// Adds MAX_PROTOCOL_FEE_ABS bound.
2745-
fn charge_fee_to_insurance(&mut self, idx: usize, fee: u128) -> Result<()> {
2747+
/// Returns the amount actually applied (capital paid + collectible debt recorded).
2748+
/// Any excess beyond collectible headroom is silently dropped.
2749+
fn charge_fee_to_insurance(&mut self, idx: usize, fee: u128) -> Result<u128> {
27462750
if fee > MAX_PROTOCOL_FEE_ABS {
27472751
return Err(RiskError::Overflow);
27482752
}
@@ -2771,8 +2775,10 @@ impl RiskEngine {
27712775
self.accounts[idx].fee_credits = I128::new(new_fc);
27722776
}
27732777
// Any excess beyond collectible headroom is silently dropped
2778+
Ok(fee_paid + collectible)
2779+
} else {
2780+
Ok(fee_paid)
27742781
}
2775-
Ok(())
27762782
}
27772783

27782784
/// OI component helpers for exact bilateral decomposition (spec §5.2.2)
@@ -2861,7 +2867,7 @@ impl RiskEngine {
28612867
fee: u128,
28622868
) -> Result<()> {
28632869
if *new_eff == 0 {
2864-
// v12.0.2 §10.5 step 29: flat-close guard uses exact Eq_maint_raw_i >= 0
2870+
// v12.1.0 §10.5 step 29: flat-close guard uses exact Eq_maint_raw_i >= 0
28652871
// (not just PNL >= 0). Prevents flat exits with negative net wealth from fee debt.
28662872
let maint_raw = self.account_equity_maint_raw_wide(&self.accounts[idx]);
28672873
if maint_raw.is_negative() {
@@ -2893,7 +2899,7 @@ impl RiskEngine {
28932899
} else if self.is_above_maintenance_margin(&self.accounts[idx], idx, oracle_price) {
28942900
// Maintenance healthy: allow
28952901
} else if strictly_reducing {
2896-
// v12.0.2 §10.5 step 29: strict risk-reducing exemption (fee-neutral).
2902+
// v12.1.0 §10.5 step 29: strict risk-reducing exemption (fee-neutral).
28972903
// Both conditions must hold in exact widened I256:
28982904
// 1. Fee-neutral buffer improves: (Eq_maint_raw_post + fee) - MM_req_post > buffer_pre
28992905
// 2. Fee-neutral shortfall does not worsen: min(Eq_maint_raw_post + fee, 0) >= min(Eq_maint_raw_pre, 0)
@@ -3499,72 +3505,66 @@ impl RiskEngine {
34993505

35003506
let i = idx as usize;
35013507

3502-
// Step 1: Settle K-pair PnL and zero position
3508+
// Step 1: Settle K-pair PnL and zero position.
3509+
// Uses validate-then-mutate: compute pnl_delta and validate all checked
3510+
// ops BEFORE any mutation, preventing partial-mutation-on-error.
3511+
// Does NOT call settle_side_effects (which interleaves mutations with
3512+
// fallible checked_sub on stale_count).
35033513
if self.accounts[i].position_basis_q != 0 {
3504-
// Try normal settle_side_effects first
3505-
let settle_ok = self.settle_side_effects(i).is_ok();
3506-
3507-
if !settle_ok {
3508-
// settle_side_effects failed (epoch-mismatch precondition on
3509-
// side mode). Compute K-pair PnL manually using the same
3510-
// wide arithmetic, then zero the position.
3511-
let basis = self.accounts[i].position_basis_q;
3512-
let abs_basis = basis.unsigned_abs();
3513-
let a_basis = self.accounts[i].adl_a_basis;
3514-
let k_snap = self.accounts[i].adl_k_snap;
3515-
3516-
if a_basis > 0 {
3517-
let side = side_of_i128(basis).unwrap();
3518-
let epoch_snap = self.accounts[i].adl_epoch_snap;
3519-
let epoch_side = self.get_epoch_side(side);
3520-
3521-
// Determine the correct K endpoint
3522-
let k_end = if epoch_snap == epoch_side {
3523-
self.get_k_side(side)
3524-
} else {
3525-
self.get_k_epoch_start(side)
3526-
};
3527-
3528-
let den = a_basis.checked_mul(POS_SCALE).ok_or(RiskError::Overflow)?;
3529-
let pnl_delta = wide_signed_mul_div_floor_from_k_pair(abs_basis, k_snap, k_end, den);
3530-
3531-
if pnl_delta != 0 {
3532-
let old_r = self.accounts[i].reserved_pnl;
3533-
let new_pnl = self.accounts[i].pnl.checked_add(pnl_delta)
3534-
.ok_or(RiskError::Overflow)?;
3535-
if new_pnl == i128::MIN {
3536-
return Err(RiskError::Overflow);
3537-
}
3538-
self.set_pnl(i, new_pnl);
3539-
if self.accounts[i].reserved_pnl > old_r {
3540-
self.restart_warmup_after_reserve_increase(i);
3541-
}
3542-
}
3514+
let basis = self.accounts[i].position_basis_q;
3515+
let abs_basis = basis.unsigned_abs();
3516+
let a_basis = self.accounts[i].adl_a_basis;
3517+
let k_snap = self.accounts[i].adl_k_snap;
3518+
let side = side_of_i128(basis).unwrap();
3519+
let epoch_snap = self.accounts[i].adl_epoch_snap;
3520+
let epoch_side = self.get_epoch_side(side);
3521+
3522+
// Phase 1: COMPUTE (no mutations)
3523+
let pnl_delta = if a_basis > 0 {
3524+
let k_end = if epoch_snap == epoch_side {
3525+
self.get_k_side(side)
3526+
} else {
3527+
self.get_k_epoch_start(side)
3528+
};
3529+
let den = a_basis.checked_mul(POS_SCALE).ok_or(RiskError::Overflow)?;
3530+
wide_signed_mul_div_floor_from_k_pair(abs_basis, k_snap, k_end, den)
3531+
} else {
3532+
0i128
3533+
};
35433534

3544-
// Decrement stale count if epoch mismatch
3545-
if epoch_snap != epoch_side {
3546-
let old_stale = self.get_stale_count(side);
3547-
if old_stale > 0 {
3548-
self.set_stale_count(side, old_stale - 1);
3549-
}
3550-
}
3535+
// Phase 1b: VALIDATE (check all fallible ops before mutating)
3536+
let new_pnl = self.accounts[i].pnl.checked_add(pnl_delta)
3537+
.ok_or(RiskError::Overflow)?;
3538+
if new_pnl == i128::MIN {
3539+
return Err(RiskError::Overflow);
3540+
}
3541+
if epoch_snap != epoch_side {
3542+
let old_stale = self.get_stale_count(side);
3543+
if old_stale == 0 {
3544+
return Err(RiskError::CorruptState);
35513545
}
3546+
}
35523547

3553-
// Zero position with proper stored_pos_count tracking
3554-
self.set_position_basis_q(i, 0);
3555-
self.accounts[i].adl_a_basis = ADL_ONE;
3556-
self.accounts[i].adl_k_snap = 0;
3557-
self.accounts[i].adl_epoch_snap = 0;
3548+
// Phase 2: MUTATE (all validated, safe to commit)
3549+
if pnl_delta != 0 {
3550+
let old_r = self.accounts[i].reserved_pnl;
3551+
self.set_pnl(i, new_pnl);
3552+
if self.accounts[i].reserved_pnl > old_r {
3553+
self.restart_warmup_after_reserve_increase(i);
3554+
}
35583555
}
35593556

3560-
// After settle (normal or manual), position may still be nonzero
3561-
// (same-epoch case where q_eff_new != 0). Zero it.
3562-
if self.accounts[i].position_basis_q != 0 {
3563-
self.set_position_basis_q(i, 0);
3564-
self.accounts[i].adl_a_basis = ADL_ONE;
3565-
self.accounts[i].adl_k_snap = 0;
3566-
self.accounts[i].adl_epoch_snap = 0;
3557+
// Decrement stale count (pre-validated above)
3558+
if epoch_snap != epoch_side {
3559+
let old_stale = self.get_stale_count(side);
3560+
self.set_stale_count(side, old_stale - 1);
35673561
}
3562+
3563+
// Zero position
3564+
self.set_position_basis_q(i, 0);
3565+
self.accounts[i].adl_a_basis = ADL_ONE;
3566+
self.accounts[i].adl_k_snap = 0;
3567+
self.accounts[i].adl_epoch_snap = 0;
35683568
}
35693569

35703570
// Step 2: Settle losses from principal

tests/proofs_instructions.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1116,7 +1116,7 @@ fn t14_65_dust_bound_end_to_end_clearance() {
11161116
// SPEC PROPERTY #17: fee shortfall routes to fee_credits, NOT PnL
11171117
// ############################################################################
11181118
//
1119-
// Spec v12.0.2 §4.10: "Unpaid explicit fees are account-local fee debt.
1119+
// Spec v12.1.0 §4.10: "Unpaid explicit fees are account-local fee debt.
11201120
// They MUST NOT be written into PNL_i."
11211121
// Spec property #17: "trading-fee or liquidation-fee shortfall becomes
11221122
// negative fee_credits_i, does not touch PNL_i."

tests/proofs_invariants.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,7 @@ fn proof_haircut_ratio_no_division_by_zero() {
408408
assert!(num == 1u128);
409409
assert!(den == 1u128);
410410

411-
// Set pnl_matured_pos_tot (v12.0.2 uses this as denominator, not pnl_pos_tot)
411+
// Set pnl_matured_pos_tot (v12.1.0 uses this as denominator, not pnl_pos_tot)
412412
engine.pnl_pos_tot = 1000u128;
413413
engine.pnl_matured_pos_tot = 1000u128;
414414
engine.vault = U128::new(2000);
@@ -521,7 +521,7 @@ fn proof_account_equity_net_nonnegative() {
521521
kani::assume(pnl_val as i32 > i16::MIN as i32);
522522
engine.set_pnl(a as usize, pnl_val as i128);
523523

524-
// Set pnl_matured_pos_tot to exercise h < 1 in haircut_ratio (v12.0.2)
524+
// Set pnl_matured_pos_tot to exercise h < 1 in haircut_ratio (v12.1.0)
525525
let matured: u16 = kani::any();
526526
kani::assume(matured <= 20_000);
527527
engine.pnl_matured_pos_tot = core::cmp::min(matured as u128, engine.pnl_pos_tot);

tests/proofs_safety.rs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ fn bounded_haircut_ratio_bounded() {
101101
engine.c_tot = U128::new(c_tot_val as u128);
102102
engine.insurance_fund.balance = U128::new(ins_val as u128);
103103
engine.pnl_pos_tot = ppt_val as u128;
104-
engine.pnl_matured_pos_tot = matured_val as u128; // v12.0.2: haircut denominator
104+
engine.pnl_matured_pos_tot = matured_val as u128; // v12.1.0: haircut denominator
105105

106106
let (h_num, h_den) = engine.haircut_ratio();
107107

@@ -1178,10 +1178,10 @@ fn proof_touch_drops_excess_at_fee_credits_limit() {
11781178
}
11791179

11801180
// ############################################################################
1181-
// v12.0.2 compliance: flat-close guard uses Eq_maint_raw_i >= 0
1181+
// v12.1.0 compliance: flat-close guard uses Eq_maint_raw_i >= 0
11821182
// ############################################################################
11831183

1184-
/// v12.0.2 change #2: A trade that closes to flat must use Eq_maint_raw_i >= 0,
1184+
/// v12.1.0 change #2: A trade that closes to flat must use Eq_maint_raw_i >= 0,
11851185
/// not just PNL_i >= 0. An account with positive PNL but large fee debt
11861186
/// (Eq_maint_raw_i = C + PNL - FeeDebt < 0) must be rejected.
11871187
#[kani::proof]
@@ -1208,22 +1208,22 @@ fn proof_v1126_flat_close_uses_eq_maint_raw() {
12081208
engine.accounts[a as usize].fee_credits = I128::new(-5000); // fee debt
12091209

12101210
// Eq_maint_raw = C(0) + PNL(1000) - FeeDebt(5000) = -4000 < 0
1211-
// v12.0.2 requires: reject flat close when Eq_maint_raw < 0
1211+
// v12.1.0 requires: reject flat close when Eq_maint_raw < 0
12121212
// Old code only checks PNL >= 0 which would pass (PNL = 1000 > 0)
12131213

12141214
let close_size = -size;
12151215
let result = engine.execute_trade(a, b, DEFAULT_ORACLE, DEFAULT_SLOT, close_size, DEFAULT_ORACLE, 0i64);
12161216

12171217
// Must be rejected: Eq_maint_raw < 0 even though PNL > 0
12181218
assert!(result.is_err(),
1219-
"v12.0.2: flat close must be rejected when Eq_maint_raw < 0 (fee debt exceeds C + PNL)");
1219+
"v12.1.0: flat close must be rejected when Eq_maint_raw < 0 (fee debt exceeds C + PNL)");
12201220
}
12211221

12221222
// ############################################################################
1223-
// v12.0.2 compliance: risk-reducing exemption is fee-neutral
1223+
// v12.1.0 compliance: risk-reducing exemption is fee-neutral
12241224
// ############################################################################
12251225

1226-
/// v12.0.2 change #1: The risk-reducing buffer comparison must be fee-neutral.
1226+
/// v12.1.0 change #1: The risk-reducing buffer comparison must be fee-neutral.
12271227
/// A genuine de-risking trade must not fail solely because the trading fee
12281228
/// reduces post-trade equity.
12291229
#[kani::proof]
@@ -1251,7 +1251,7 @@ fn proof_v1126_risk_reducing_fee_neutral() {
12511251
let half_close = size / 2;
12521252
let result = engine.execute_trade(b, a, DEFAULT_ORACLE, DEFAULT_SLOT, half_close, DEFAULT_ORACLE, 0i64);
12531253

1254-
// v12.0.2: fee-neutral comparison means pure fee friction should not block
1254+
// v12.1.0: fee-neutral comparison means pure fee friction should not block
12551255
// a genuine de-risking trade at oracle price.
12561256
// The post-trade buffer (with fee added back) should be strictly better.
12571257
// Conservation must hold regardless of whether trade succeeds or fails.
@@ -1260,7 +1260,7 @@ fn proof_v1126_risk_reducing_fee_neutral() {
12601260
}
12611261

12621262
// ############################################################################
1263-
// v12.0.2 compliance: MIN_NONZERO_MM_REQ floor (TODO: implement params first)
1263+
// v12.1.0 compliance: MIN_NONZERO_MM_REQ floor (TODO: implement params first)
12641264
// ############################################################################
12651265

12661266
// Uncommented: RiskParams now has min_nonzero_mm_req / min_nonzero_im_req
@@ -1291,7 +1291,7 @@ fn proof_v1126_min_nonzero_margin_floor() {
12911291
}
12921292

12931293
// ############################################################################
1294-
// v12.0.2 §2.6: flat-dust reclamation (GC sweeps 0 < C_i < MIN_INITIAL_DEPOSIT)
1294+
// v12.1.0 §2.6: flat-dust reclamation (GC sweeps 0 < C_i < MIN_INITIAL_DEPOSIT)
12951295
// ############################################################################
12961296

12971297
/// A flat account with 0 < C_i < MIN_INITIAL_DEPOSIT, zero PnL/basis/reserved,

tests/proofs_v1131.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! Section 7 — v12.0.2 Spec Compliance Proofs
1+
//! Section 7 — v12.1.0 Spec Compliance Proofs
22
//!
33
//! Properties 46, 59-75: live funding, configuration immutability,
44
//! bilateral OI decomposition, partial liquidation, deposit guards, profit conversion.
@@ -13,7 +13,7 @@ use common::*;
1313
// ############################################################################
1414

1515
/// recompute_r_last_from_final_state(rate) stores exactly rate when
16-
/// |rate| <= MAX_ABS_FUNDING_BPS_PER_SLOT (spec v12.0.2 §4.12).
16+
/// |rate| <= MAX_ABS_FUNDING_BPS_PER_SLOT (spec v12.1.0 §4.12).
1717
#[kani::proof]
1818
#[kani::unwind(34)]
1919
#[kani::solver(cadical)]

tests/unit_tests.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1524,7 +1524,7 @@ fn test_accrue_market_funding_rate_zero_no_funding_applied() {
15241524

15251525
#[test]
15261526
fn test_accrue_market_applies_funding_transfer() {
1527-
// Spec v12.0.2 §5.4: live funding — K coefficients change when r_last != 0
1527+
// Spec v12.1.0 §5.4: live funding — K coefficients change when r_last != 0
15281528
let mut engine = RiskEngine::new(default_params());
15291529
engine.last_oracle_price = 1000;
15301530
engine.last_market_slot = 0;

0 commit comments

Comments
 (0)