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
0 commit comments