Skip to main content

percolator_sdk/
percolator.rs

1//! Formally Verified Risk Engine for Perpetual DEX
2//!
3//! ⚠️ EDUCATIONAL USE ONLY - NOT PRODUCTION READY ⚠️
4//!
5//! This is an experimental research project for educational purposes only.
6//! DO NOT use with real funds. Not independently audited. Not production ready.
7//!
8//! This module implements a formally verified risk engine that guarantees:
9//! 1. User funds are safe against oracle manipulation attacks (within time window T)
10//! 2. PNL warmup prevents instant withdrawal of manipulated profits
11//! 3. ADL haircuts apply to unwrapped PNL first, protecting user principal
12//! 4. Conservation of funds across all operations
13//! 5. User isolation - one user's actions don't affect others
14//!
15//! All data structures are laid out in a single contiguous memory chunk,
16//! suitable for a single Solana account.
17
18
19#![forbid(unsafe_code)]
20
21#[cfg(kani)]
22extern crate kani;
23
24// ============================================================================
25// Constants
26// ============================================================================
27
28// MAX_ACCOUNTS is feature-configured, not target-configured.
29// This ensures x86 and SBF builds use the same sizes for a given feature set.
30#[cfg(kani)]
31pub const MAX_ACCOUNTS: usize = 4; // Small for fast formal verification (1 bitmap word, 4 bits)
32
33#[cfg(all(feature = "test", not(kani)))]
34pub const MAX_ACCOUNTS: usize = 64; // Small for tests
35
36#[cfg(all(not(kani), not(feature = "test")))]
37pub const MAX_ACCOUNTS: usize = 4096; // Production
38
39// Derived constants - all use size_of, no hardcoded values
40pub const BITMAP_WORDS: usize = (MAX_ACCOUNTS + 63) / 64;
41pub const MAX_ROUNDING_SLACK: u128 = MAX_ACCOUNTS as u128;
42/// Mask for wrapping indices (MAX_ACCOUNTS must be power of 2)
43const ACCOUNT_IDX_MASK: usize = MAX_ACCOUNTS - 1;
44
45/// Maximum number of dust accounts to close per crank call.
46/// Limits compute usage while still making progress on cleanup.
47pub const GC_CLOSE_BUDGET: u32 = 32;
48
49/// Number of occupied accounts to process per crank call.
50/// When the system has fewer than this many accounts, one crank covers everything.
51pub const ACCOUNTS_PER_CRANK: u16 = 256;
52
53/// Hard liquidation budget per crank call (caps total work)
54/// Set to 120 to keep worst-case crank CU under ~50% of Solana limit
55pub const LIQ_BUDGET_PER_CRANK: u16 = 120;
56
57/// Max number of force-realize closes per crank call.
58/// Hard CU bound in force-realize mode. Liquidations are skipped when active.
59pub const FORCE_REALIZE_BUDGET_PER_CRANK: u16 = 32;
60
61/// Maximum oracle price (prevents overflow in mark_pnl calculations)
62/// 10^15 allows prices up to $1B with 6 decimal places
63pub const MAX_ORACLE_PRICE: u64 = 1_000_000_000_000_000;
64
65/// Maximum absolute position size (prevents overflow in mark_pnl calculations)
66/// 10^20 allows positions up to 100 billion units
67/// Combined with MAX_ORACLE_PRICE, guarantees mark_pnl multiply won't overflow i128
68pub const MAX_POSITION_ABS: u128 = 100_000_000_000_000_000_000;
69
70// ============================================================================
71// BPF-Safe 128-bit Types (see src/i128.rs)
72// ============================================================================
73pub mod i128;
74pub use i128::{I128, U128};
75
76// ============================================================================
77// Core Data Structures
78// ============================================================================
79
80#[repr(u8)]
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82pub enum AccountKind {
83    User = 0,
84    LP = 1,
85}
86
87/// Unified account - can be user or LP
88///
89/// LPs are distinguished by having kind = LP and matcher_program/context set.
90/// Users have kind = User and matcher arrays zeroed.
91///
92/// This unification ensures LPs receive the same risk management protections as users:
93/// - PNL warmup
94/// - ADL (Auto-Deleveraging)
95/// - Liquidations
96#[repr(C)]
97#[derive(Clone, Copy, Debug, PartialEq, Eq)]
98pub struct Account {
99    /// Unique account ID (monotonically increasing, never recycled)
100    /// Note: Field order matches on-chain slab layout (account_id at offset 0)
101    pub account_id: u64,
102
103    // ========================================
104    // Capital & PNL (universal)
105    // ========================================
106    /// Deposited capital (user principal or LP capital)
107    /// NEVER reduced by ADL/socialization (Invariant I1)
108    pub capital: U128,
109
110    /// Account kind (User or LP)
111    /// Note: Field is at offset 24 in on-chain layout, after capital
112    pub kind: AccountKind,
113
114    /// Realized PNL from trading (can be positive or negative)
115    pub pnl: I128,
116
117    /// PNL reserved for pending withdrawals
118    /// Note: u64 to match on-chain slab layout (8 bytes, not 16)
119    pub reserved_pnl: u64,
120
121    // ========================================
122    // Warmup (embedded, no separate struct)
123    // ========================================
124    /// Slot when warmup started
125    pub warmup_started_at_slot: u64,
126
127    /// Linear vesting rate per slot
128    pub warmup_slope_per_step: U128,
129
130    // ========================================
131    // Position (universal)
132    // ========================================
133    /// Current position size (+ long, - short)
134    pub position_size: I128,
135
136    /// Last oracle mark price at which this account's position was settled (variation margin).
137    /// NOT an average trade entry price.
138    pub entry_price: u64,
139
140    // ========================================
141    // Funding (universal)
142    // ========================================
143    /// Funding index snapshot (quote per base, 1e6 scale)
144    pub funding_index: I128,
145
146    // ========================================
147    // LP-specific (only meaningful for LP kind)
148    // ========================================
149    /// Matching engine program ID (zero for user accounts)
150    pub matcher_program: [u8; 32],
151
152    /// Matching engine context account (zero for user accounts)
153    pub matcher_context: [u8; 32],
154
155    // ========================================
156    // Owner & Maintenance Fees (wrapper-related)
157    // ========================================
158    /// Owner pubkey (32 bytes, signature checks done by wrapper)
159    pub owner: [u8; 32],
160
161    /// Fee credits in capital units (can go negative if fees owed)
162    pub fee_credits: I128,
163
164    /// Last slot when maintenance fees were settled for this account
165    pub last_fee_slot: u64,
166
167}
168
169impl Account {
170    /// Check if this account is an LP
171    pub fn is_lp(&self) -> bool {
172        matches!(self.kind, AccountKind::LP)
173    }
174
175    /// Check if this account is a regular user
176    pub fn is_user(&self) -> bool {
177        matches!(self.kind, AccountKind::User)
178    }
179}
180
181/// Helper to create empty account
182fn empty_account() -> Account {
183    Account {
184        account_id: 0,
185        capital: U128::ZERO,
186        kind: AccountKind::User,
187        pnl: I128::ZERO,
188        reserved_pnl: 0,
189        warmup_started_at_slot: 0,
190        warmup_slope_per_step: U128::ZERO,
191        position_size: I128::ZERO,
192        entry_price: 0,
193        funding_index: I128::ZERO,
194        matcher_program: [0; 32],
195        matcher_context: [0; 32],
196        owner: [0; 32],
197        fee_credits: I128::ZERO,
198        last_fee_slot: 0,
199    }
200}
201
202/// Insurance fund state
203#[repr(C)]
204#[derive(Clone, Copy, Debug, PartialEq, Eq)]
205pub struct InsuranceFund {
206    /// Insurance fund balance
207    pub balance: U128,
208
209    /// Accumulated fees from trades
210    pub fee_revenue: U128,
211}
212
213/// Outcome from oracle_close_position_core helper
214#[derive(Clone, Copy, Debug, PartialEq, Eq)]
215pub struct ClosedOutcome {
216    /// Absolute position size that was closed
217    pub abs_pos: u128,
218    /// Mark PnL from closing at oracle price
219    pub mark_pnl: i128,
220    /// Capital before settlement
221    pub cap_before: u128,
222    /// Capital after settlement
223    pub cap_after: u128,
224    /// Whether a position was actually closed
225    pub position_was_closed: bool,
226}
227
228/// Risk engine parameters
229#[repr(C)]
230#[derive(Clone, Copy, Debug, PartialEq, Eq)]
231pub struct RiskParams {
232    /// Warmup period in slots (time T)
233    pub warmup_period_slots: u64,
234
235    /// Maintenance margin ratio in basis points (e.g., 500 = 5%)
236    pub maintenance_margin_bps: u64,
237
238    /// Initial margin ratio in basis points
239    pub initial_margin_bps: u64,
240
241    /// Trading fee in basis points
242    pub trading_fee_bps: u64,
243
244    /// Maximum number of accounts
245    pub max_accounts: u64,
246
247    /// Flat account creation fee (absolute amount in capital units)
248    pub new_account_fee: U128,
249
250    /// Insurance fund threshold for entering risk-reduction-only mode
251    /// If insurance fund balance drops below this, risk-reduction mode activates
252    pub risk_reduction_threshold: U128,
253
254    // ========================================
255    // Maintenance Fee Parameters
256    // ========================================
257    /// Maintenance fee per account per slot (in capital units)
258    /// Engine is purely slot-native; any per-day conversion is wrapper/UI responsibility
259    pub maintenance_fee_per_slot: U128,
260
261    /// Maximum allowed staleness before crank is required (in slots)
262    /// Set to u64::MAX to disable crank freshness check
263    pub max_crank_staleness_slots: u64,
264
265    /// Liquidation fee in basis points (e.g., 50 = 0.50%)
266    /// Paid from liquidated account's capital into insurance fund
267    pub liquidation_fee_bps: u64,
268
269    /// Absolute cap on liquidation fee (in capital units)
270    /// Prevents whales paying enormous fees
271    pub liquidation_fee_cap: U128,
272
273    // ========================================
274    // Partial Liquidation Parameters
275    // ========================================
276    /// Buffer above maintenance margin (in basis points) to target after partial liquidation.
277    /// E.g., if maintenance is 500 bps (5%) and buffer is 100 bps (1%), we target 6% margin.
278    /// This prevents immediate re-liquidation from small price movements.
279    pub liquidation_buffer_bps: u64,
280
281    /// Minimum absolute position size after partial liquidation.
282    /// If remaining position would be below this threshold, full liquidation occurs.
283    /// Prevents dust positions that are uneconomical to maintain or re-liquidate.
284    /// Denominated in base units (same scale as position_size.abs()).
285    pub min_liquidation_abs: U128,
286}
287
288/// Main risk engine state - fixed slab with bitmap
289#[repr(C)]
290#[derive(Clone, Debug, PartialEq, Eq)]
291pub struct RiskEngine {
292    /// Total vault balance (all deposited funds)
293    pub vault: U128,
294
295    /// Insurance fund
296    pub insurance_fund: InsuranceFund,
297
298    /// Risk parameters
299    pub params: RiskParams,
300
301    /// Current slot (for warmup calculations)
302    pub current_slot: u64,
303
304    /// Global funding index (quote per 1 base, scaled by 1e6)
305    pub funding_index_qpb_e6: I128,
306
307    /// Last slot when funding was accrued
308    pub last_funding_slot: u64,
309
310    /// Funding rate (bps per slot) in effect starting at last_funding_slot.
311    /// This is the rate used for the interval [last_funding_slot, next_accrual).
312    /// Anti-retroactivity: state changes at slot t can only affect funding for slots >= t.
313    pub funding_rate_bps_per_slot_last: i64,
314
315    // ========================================
316    // Keeper Crank Tracking
317    // ========================================
318    /// Last slot when keeper crank was executed
319    pub last_crank_slot: u64,
320
321    /// Maximum allowed staleness before crank is required (in slots)
322    pub max_crank_staleness_slots: u64,
323
324    // ========================================
325    // Open Interest Tracking (O(1))
326    // ========================================
327    /// Total open interest = sum of abs(position_size) across all accounts
328    /// This measures total risk exposure in the system.
329    pub total_open_interest: U128,
330
331    // ========================================
332    // O(1) Aggregates (spec §2.2, §4)
333    // ========================================
334    /// Sum of all account capital: C_tot = Σ C_i
335    /// Maintained incrementally via set_capital() helper.
336    pub c_tot: U128,
337
338    /// Sum of all positive PnL: PNL_pos_tot = Σ max(PNL_i, 0)
339    /// Maintained incrementally via set_pnl() helper.
340    pub pnl_pos_tot: U128,
341
342    // ========================================
343    // Crank Cursors (bounded scan support)
344    // ========================================
345    /// Cursor for liquidation scan (wraps around MAX_ACCOUNTS)
346    pub liq_cursor: u16,
347
348    /// Cursor for garbage collection scan (wraps around MAX_ACCOUNTS)
349    pub gc_cursor: u16,
350
351    /// Slot when the current full sweep started (step 0 was executed)
352    pub last_full_sweep_start_slot: u64,
353
354    /// Slot when the last full sweep completed
355    pub last_full_sweep_completed_slot: u64,
356
357    /// Cursor: index where the next crank will start scanning
358    pub crank_cursor: u16,
359
360    /// Index where the current sweep started (for completion detection)
361    pub sweep_start_idx: u16,
362
363    // ========================================
364    // Lifetime Counters (telemetry)
365    // ========================================
366    /// Total number of liquidations performed (lifetime)
367    pub lifetime_liquidations: u64,
368
369    /// Total number of force-realize closes performed (lifetime)
370    pub lifetime_force_realize_closes: u64,
371
372    // ========================================
373    // LP Aggregates (O(1) maintained for funding/threshold)
374    // ========================================
375    /// Net LP position: sum of position_size across all LP accounts
376    /// Updated incrementally in execute_trade and close paths
377    pub net_lp_pos: I128,
378
379    /// Sum of abs(position_size) across all LP accounts
380    /// Updated incrementally in execute_trade and close paths
381    pub lp_sum_abs: U128,
382
383    /// Max abs(position_size) across all LP accounts (monotone upper bound)
384    /// Only increases; reset via bounded sweep at sweep completion
385    pub lp_max_abs: U128,
386
387    /// In-progress max abs for current sweep (reset at sweep start, committed at completion)
388    pub lp_max_abs_sweep: U128,
389
390    // ========================================
391    // Slab Management
392    // ========================================
393    /// Occupancy bitmap (4096 bits = 64 u64 words)
394    pub used: [u64; BITMAP_WORDS],
395
396    /// Number of used accounts (O(1) counter, fixes H2: fee bypass TOCTOU)
397    pub num_used_accounts: u16,
398
399    /// Next account ID to assign (monotonically increasing, never recycled)
400    pub next_account_id: u64,
401
402    /// Freelist head (u16::MAX = none)
403    pub free_head: u16,
404
405
406    /// Freelist next pointers
407    pub next_free: [u16; MAX_ACCOUNTS],
408
409    /// Account slab (4096 accounts)
410    pub accounts: [Account; MAX_ACCOUNTS],
411}
412
413// ============================================================================
414// Error Types
415// ============================================================================
416
417#[derive(Clone, Copy, Debug, PartialEq, Eq)]
418pub enum RiskError {
419    /// Insufficient balance for operation
420    InsufficientBalance,
421
422    /// Account would become undercollateralized
423    Undercollateralized,
424
425    /// Unauthorized operation
426    Unauthorized,
427
428    /// Invalid matching engine
429    InvalidMatchingEngine,
430
431    /// PNL not yet warmed up
432    PnlNotWarmedUp,
433
434    /// Arithmetic overflow
435    Overflow,
436
437    /// Account not found
438    AccountNotFound,
439
440    /// Account is not an LP account
441    NotAnLPAccount,
442
443    /// Position size mismatch
444    PositionSizeMismatch,
445
446    /// Account kind mismatch
447    AccountKindMismatch,
448}
449
450pub type Result<T> = core::result::Result<T, RiskError>;
451
452/// Outcome of a keeper crank operation
453#[derive(Clone, Copy, Debug, PartialEq, Eq)]
454pub struct CrankOutcome {
455    /// Whether the crank successfully advanced last_crank_slot
456    pub advanced: bool,
457    /// Slots forgiven for caller's maintenance (50% discount via time forgiveness)
458    pub slots_forgiven: u64,
459    /// Whether caller's maintenance fee settle succeeded (false if undercollateralized)
460    pub caller_settle_ok: bool,
461    /// Whether force-realize mode is active (insurance at/below threshold)
462    pub force_realize_needed: bool,
463    /// Whether panic_settle_all should be called (system in stress)
464    pub panic_needed: bool,
465    /// Number of accounts liquidated during this crank
466    pub num_liquidations: u32,
467    /// Number of liquidation errors (triggers risk_reduction_only)
468    pub num_liq_errors: u16,
469    /// Number of dust accounts garbage collected during this crank
470    pub num_gc_closed: u32,
471    /// Number of positions force-closed during this crank (when force_realize_needed)
472    pub force_realize_closed: u16,
473    /// Number of force-realize errors during this crank
474    pub force_realize_errors: u16,
475    /// Index where this crank stopped (next crank continues from here)
476    pub last_cursor: u16,
477    /// Whether this crank completed a full sweep of all accounts
478    pub sweep_complete: bool,
479}
480
481// ============================================================================
482// Math Helpers (Saturating Arithmetic for Safety)
483// ============================================================================
484
485#[inline]
486fn add_u128(a: u128, b: u128) -> u128 {
487    a.saturating_add(b)
488}
489
490#[inline]
491fn sub_u128(a: u128, b: u128) -> u128 {
492    a.saturating_sub(b)
493}
494
495#[inline]
496fn mul_u128(a: u128, b: u128) -> u128 {
497    a.saturating_mul(b)
498}
499
500#[inline]
501fn div_u128(a: u128, b: u128) -> Result<u128> {
502    if b == 0 {
503        Err(RiskError::Overflow) // Division by zero
504    } else {
505        Ok(a / b)
506    }
507}
508
509#[inline]
510fn clamp_pos_i128(val: i128) -> u128 {
511    if val > 0 {
512        val as u128
513    } else {
514        0
515    }
516}
517
518#[allow(dead_code)]
519#[inline]
520fn clamp_neg_i128(val: i128) -> u128 {
521    if val < 0 {
522        neg_i128_to_u128(val)
523    } else {
524        0
525    }
526}
527
528/// Saturating absolute value for i128 (handles i128::MIN without overflow)
529#[inline]
530fn saturating_abs_i128(val: i128) -> i128 {
531    if val == i128::MIN {
532        i128::MAX
533    } else {
534        val.abs()
535    }
536}
537
538/// Safely convert negative i128 to u128 (handles i128::MIN without overflow)
539///
540/// For i128::MIN, -i128::MIN would overflow because i128::MAX + 1 cannot be represented.
541/// We handle this by returning (i128::MAX as u128) + 1 = 170141183460469231731687303715884105728.
542#[inline]
543fn neg_i128_to_u128(val: i128) -> u128 {
544    debug_assert!(val < 0, "neg_i128_to_u128 called with non-negative value");
545    if val == i128::MIN {
546        (i128::MAX as u128) + 1
547    } else {
548        (-val) as u128
549    }
550}
551
552/// Safely convert u128 to i128 with clamping (handles values > i128::MAX)
553///
554/// If x > i128::MAX, the cast would wrap to a negative value.
555/// We clamp to i128::MAX instead to preserve correctness of margin checks.
556#[inline]
557fn u128_to_i128_clamped(x: u128) -> i128 {
558    if x > i128::MAX as u128 {
559        i128::MAX
560    } else {
561        x as i128
562    }
563}
564
565// ============================================================================
566// Matching Engine Trait
567// ============================================================================
568
569/// Result of a successful trade execution from the matching engine
570#[derive(Clone, Copy, Debug, PartialEq, Eq)]
571pub struct TradeExecution {
572    /// Actual execution price (may differ from oracle/requested price)
573    pub price: u64,
574    /// Actual executed size (may be partial fill)
575    pub size: i128,
576}
577
578/// Trait for pluggable matching engines
579///
580/// Implementers can provide custom order matching logic via CPI.
581/// The matching engine is responsible for validating and executing trades
582/// according to its own rules (CLOB, AMM, RFQ, etc).
583pub trait MatchingEngine {
584    /// Execute a trade between LP and user
585    ///
586    /// # Arguments
587    /// * `lp_program` - The LP's matching engine program ID
588    /// * `lp_context` - The LP's matching engine context account
589    /// * `lp_account_id` - Unique ID of the LP account (never recycled)
590    /// * `oracle_price` - Current oracle price for reference
591    /// * `size` - Requested position size (positive = long, negative = short)
592    ///
593    /// # Returns
594    /// * `Ok(TradeExecution)` with actual executed price and size
595    /// * `Err(RiskError)` if the trade is rejected
596    ///
597    /// # Safety
598    /// The matching engine MUST verify user authorization before approving trades.
599    /// The risk engine will check solvency after the trade executes.
600    fn execute_match(
601        &self,
602        lp_program: &[u8; 32],
603        lp_context: &[u8; 32],
604        lp_account_id: u64,
605        oracle_price: u64,
606        size: i128,
607    ) -> Result<TradeExecution>;
608}
609
610/// No-op matching engine (for testing)
611/// Returns the requested price and size as-is
612pub struct NoOpMatcher;
613
614impl MatchingEngine for NoOpMatcher {
615    fn execute_match(
616        &self,
617        _lp_program: &[u8; 32],
618        _lp_context: &[u8; 32],
619        _lp_account_id: u64,
620        oracle_price: u64,
621        size: i128,
622    ) -> Result<TradeExecution> {
623        // Return requested price/size unchanged (no actual matching logic)
624        Ok(TradeExecution {
625            price: oracle_price,
626            size,
627        })
628    }
629}
630
631// ============================================================================
632// Core Implementation
633// ============================================================================
634
635impl RiskEngine {
636    /// Create a new risk engine (stack-allocates the full struct - avoid in BPF!)
637    ///
638    /// WARNING: This allocates ~6MB on the stack at MAX_ACCOUNTS=4096.
639    /// For Solana BPF programs, use `init_in_place` instead.
640    pub fn new(params: RiskParams) -> Self {
641        let mut engine = Self {
642            vault: U128::ZERO,
643            insurance_fund: InsuranceFund {
644                balance: U128::ZERO,
645                fee_revenue: U128::ZERO,
646            },
647            params,
648            current_slot: 0,
649            funding_index_qpb_e6: I128::ZERO,
650            last_funding_slot: 0,
651            funding_rate_bps_per_slot_last: 0,
652            last_crank_slot: 0,
653            max_crank_staleness_slots: params.max_crank_staleness_slots,
654            total_open_interest: U128::ZERO,
655            c_tot: U128::ZERO,
656            pnl_pos_tot: U128::ZERO,
657            liq_cursor: 0,
658            gc_cursor: 0,
659            last_full_sweep_start_slot: 0,
660            last_full_sweep_completed_slot: 0,
661            crank_cursor: 0,
662            sweep_start_idx: 0,
663            lifetime_liquidations: 0,
664            lifetime_force_realize_closes: 0,
665            net_lp_pos: I128::ZERO,
666            lp_sum_abs: U128::ZERO,
667            lp_max_abs: U128::ZERO,
668            lp_max_abs_sweep: U128::ZERO,
669            used: [0; BITMAP_WORDS],
670            num_used_accounts: 0,
671            next_account_id: 0,
672            free_head: 0,
673            next_free: [0; MAX_ACCOUNTS],
674            accounts: [empty_account(); MAX_ACCOUNTS],
675        };
676
677        // Initialize freelist: 0 -> 1 -> 2 -> ... -> 4095 -> NONE
678        for i in 0..MAX_ACCOUNTS - 1 {
679            engine.next_free[i] = (i + 1) as u16;
680        }
681        engine.next_free[MAX_ACCOUNTS - 1] = u16::MAX; // Sentinel
682
683        engine
684    }
685
686    /// Initialize a RiskEngine in place (zero-copy friendly).
687    ///
688    /// PREREQUISITE: The memory backing `self` MUST be zeroed before calling.
689    /// This method only sets non-zero fields to avoid touching the entire ~6MB struct.
690    ///
691    /// This is the correct way to initialize RiskEngine in Solana BPF programs
692    /// where stack space is limited to 4KB.
693    pub fn init_in_place(&mut self, params: RiskParams) {
694        // Set params (non-zero field)
695        self.params = params;
696        self.max_crank_staleness_slots = params.max_crank_staleness_slots;
697
698        // Initialize freelist: 0 -> 1 -> 2 -> ... -> MAX_ACCOUNTS-1 -> NONE
699        // All other fields are zero which is correct for:
700        // - vault, insurance_fund, current_slot, funding_index, etc. = 0
701        // - used bitmap = all zeros (no accounts in use)
702        // - accounts = all zeros (equivalent to empty_account())
703        // - free_head = 0 (first free slot is 0)
704        for i in 0..MAX_ACCOUNTS - 1 {
705            self.next_free[i] = (i + 1) as u16;
706        }
707        self.next_free[MAX_ACCOUNTS - 1] = u16::MAX; // Sentinel
708    }
709
710    // ========================================
711    // Bitmap Helpers
712    // ========================================
713
714    pub fn is_used(&self, idx: usize) -> bool {
715        if idx >= MAX_ACCOUNTS {
716            return false;
717        }
718        let w = idx >> 6;
719        let b = idx & 63;
720        ((self.used[w] >> b) & 1) == 1
721    }
722
723    fn set_used(&mut self, idx: usize) {
724        let w = idx >> 6;
725        let b = idx & 63;
726        self.used[w] |= 1u64 << b;
727    }
728
729    fn clear_used(&mut self, idx: usize) {
730        let w = idx >> 6;
731        let b = idx & 63;
732        self.used[w] &= !(1u64 << b);
733    }
734
735    fn for_each_used_mut<F: FnMut(usize, &mut Account)>(&mut self, mut f: F) {
736        for (block, word) in self.used.iter().copied().enumerate() {
737            let mut w = word;
738            while w != 0 {
739                let bit = w.trailing_zeros() as usize;
740                let idx = block * 64 + bit;
741                w &= w - 1; // Clear lowest bit
742                if idx >= MAX_ACCOUNTS {
743                    continue; // Guard against stray high bits in bitmap
744                }
745                f(idx, &mut self.accounts[idx]);
746            }
747        }
748    }
749
750    fn for_each_used<F: FnMut(usize, &Account)>(&self, mut f: F) {
751        for (block, word) in self.used.iter().copied().enumerate() {
752            let mut w = word;
753            while w != 0 {
754                let bit = w.trailing_zeros() as usize;
755                let idx = block * 64 + bit;
756                w &= w - 1; // Clear lowest bit
757                if idx >= MAX_ACCOUNTS {
758                    continue; // Guard against stray high bits in bitmap
759                }
760                f(idx, &self.accounts[idx]);
761            }
762        }
763    }
764
765    // ========================================
766    // O(1) Aggregate Helpers (spec §4)
767    // ========================================
768
769    /// Mandatory helper: set account PnL and maintain pnl_pos_tot aggregate (spec §4.2).
770    /// All code paths that modify PnL MUST call this.
771    #[inline]
772    pub fn set_pnl(&mut self, idx: usize, new_pnl: i128) {
773        let old = self.accounts[idx].pnl.get();
774        let old_pos = if old > 0 { old as u128 } else { 0 };
775        let new_pos = if new_pnl > 0 { new_pnl as u128 } else { 0 };
776        self.pnl_pos_tot = U128::new(
777            self.pnl_pos_tot
778                .get()
779                .saturating_add(new_pos)
780                .saturating_sub(old_pos),
781        );
782        self.accounts[idx].pnl = I128::new(new_pnl);
783    }
784
785    /// Helper: set account capital and maintain c_tot aggregate (spec §4.1).
786    #[inline]
787    pub fn set_capital(&mut self, idx: usize, new_capital: u128) {
788        let old = self.accounts[idx].capital.get();
789        if new_capital >= old {
790            self.c_tot = U128::new(self.c_tot.get().saturating_add(new_capital - old));
791        } else {
792            self.c_tot = U128::new(self.c_tot.get().saturating_sub(old - new_capital));
793        }
794        self.accounts[idx].capital = U128::new(new_capital);
795    }
796
797    /// Recompute c_tot and pnl_pos_tot from account data. For test use after direct state mutation.
798    pub fn recompute_aggregates(&mut self) {
799        let mut c_tot = 0u128;
800        let mut pnl_pos_tot = 0u128;
801        self.for_each_used(|_idx, account| {
802            c_tot = c_tot.saturating_add(account.capital.get());
803            let pnl = account.pnl.get();
804            if pnl > 0 {
805                pnl_pos_tot = pnl_pos_tot.saturating_add(pnl as u128);
806            }
807        });
808        self.c_tot = U128::new(c_tot);
809        self.pnl_pos_tot = U128::new(pnl_pos_tot);
810    }
811
812    /// Compute haircut ratio (h_num, h_den) per spec §3.2.
813    /// h = min(Residual, PNL_pos_tot) / PNL_pos_tot where Residual = max(0, V - C_tot - I).
814    /// Returns (1, 1) when PNL_pos_tot == 0.
815    #[inline]
816    pub fn haircut_ratio(&self) -> (u128, u128) {
817        let pnl_pos_tot = self.pnl_pos_tot.get();
818        if pnl_pos_tot == 0 {
819            return (1, 1);
820        }
821        let residual = self
822            .vault
823            .get()
824            .saturating_sub(self.c_tot.get())
825            .saturating_sub(self.insurance_fund.balance.get());
826        let h_num = core::cmp::min(residual, pnl_pos_tot);
827        (h_num, pnl_pos_tot)
828    }
829
830    /// Compute effective positive PnL after haircut for a given account PnL (spec §3.3).
831    /// PNL_eff_pos_i = floor(max(PNL_i, 0) * h_num / h_den)
832    #[inline]
833    pub fn effective_pos_pnl(&self, pnl: i128) -> u128 {
834        if pnl <= 0 {
835            return 0;
836        }
837        let pos_pnl = pnl as u128;
838        let (h_num, h_den) = self.haircut_ratio();
839        if h_den == 0 {
840            return pos_pnl;
841        }
842        // floor(pos_pnl * h_num / h_den)
843        mul_u128(pos_pnl, h_num) / h_den
844    }
845
846    /// Compute effective realized equity per spec §3.3.
847    /// Eq_real_i = max(0, C_i + min(PNL_i, 0) + PNL_eff_pos_i)
848    #[inline]
849    pub fn effective_equity(&self, account: &Account) -> u128 {
850        let cap_i = u128_to_i128_clamped(account.capital.get());
851        let neg_pnl = core::cmp::min(account.pnl.get(), 0);
852        let eff_pos = self.effective_pos_pnl(account.pnl.get());
853        let eq_i = cap_i
854            .saturating_add(neg_pnl)
855            .saturating_add(u128_to_i128_clamped(eff_pos));
856        if eq_i > 0 {
857            eq_i as u128
858        } else {
859            0
860        }
861    }
862
863    // ========================================
864    // Account Allocation
865    // ========================================
866
867    fn alloc_slot(&mut self) -> Result<u16> {
868        if self.free_head == u16::MAX {
869            return Err(RiskError::Overflow); // Slab full
870        }
871        let idx = self.free_head;
872        self.free_head = self.next_free[idx as usize];
873        self.set_used(idx as usize);
874        // Increment O(1) counter atomically (fixes H2: TOCTOU fee bypass)
875        self.num_used_accounts = self.num_used_accounts.saturating_add(1);
876        Ok(idx)
877    }
878
879    /// Count used accounts
880    fn count_used(&self) -> u64 {
881        let mut count = 0u64;
882        self.for_each_used(|_, _| {
883            count += 1;
884        });
885        count
886    }
887
888    // ========================================
889    // Account Management
890    // ========================================
891
892    /// Add a new user account
893    pub fn add_user(&mut self, fee_payment: u128) -> Result<u16> {
894        // Use O(1) counter instead of O(N) count_used() (fixes H2: TOCTOU fee bypass)
895        let used_count = self.num_used_accounts as u64;
896        if used_count >= self.params.max_accounts {
897            return Err(RiskError::Overflow);
898        }
899
900        // Flat fee (no scaling)
901        let required_fee = self.params.new_account_fee.get();
902        if fee_payment < required_fee {
903            return Err(RiskError::InsufficientBalance);
904        }
905
906        // Bug #4 fix: Compute excess payment to credit to user capital
907        let excess = fee_payment.saturating_sub(required_fee);
908
909        // Pay fee to insurance (fee tokens are deposited into vault)
910        // Account for FULL fee_payment in vault, not just required_fee
911        self.vault = self.vault + fee_payment;
912        self.insurance_fund.balance = self.insurance_fund.balance + required_fee;
913        self.insurance_fund.fee_revenue = self.insurance_fund.fee_revenue + required_fee;
914
915        // Allocate slot and assign unique ID
916        let idx = self.alloc_slot()?;
917        let account_id = self.next_account_id;
918        self.next_account_id = self.next_account_id.saturating_add(1);
919
920        // Initialize account with excess credited to capital
921        self.accounts[idx as usize] = Account {
922            kind: AccountKind::User,
923            account_id,
924            capital: U128::new(excess), // Bug #4 fix: excess goes to user capital
925            pnl: I128::ZERO,
926            reserved_pnl: 0,
927            warmup_started_at_slot: self.current_slot,
928            warmup_slope_per_step: U128::ZERO,
929            position_size: I128::ZERO,
930            entry_price: 0,
931            funding_index: self.funding_index_qpb_e6,
932            matcher_program: [0; 32],
933            matcher_context: [0; 32],
934            owner: [0; 32],
935            fee_credits: I128::ZERO,
936            last_fee_slot: self.current_slot,
937        };
938
939        // Maintain c_tot aggregate (account was created with capital = excess)
940        if excess > 0 {
941            self.c_tot = U128::new(self.c_tot.get().saturating_add(excess));
942        }
943
944        Ok(idx)
945    }
946
947    /// Add a new LP account
948    pub fn add_lp(
949        &mut self,
950        matching_engine_program: [u8; 32],
951        matching_engine_context: [u8; 32],
952        fee_payment: u128,
953    ) -> Result<u16> {
954        // Use O(1) counter instead of O(N) count_used() (fixes H2: TOCTOU fee bypass)
955        let used_count = self.num_used_accounts as u64;
956        if used_count >= self.params.max_accounts {
957            return Err(RiskError::Overflow);
958        }
959
960        // Flat fee (no scaling)
961        let required_fee = self.params.new_account_fee.get();
962        if fee_payment < required_fee {
963            return Err(RiskError::InsufficientBalance);
964        }
965
966        // Bug #4 fix: Compute excess payment to credit to LP capital
967        let excess = fee_payment.saturating_sub(required_fee);
968
969        // Pay fee to insurance (fee tokens are deposited into vault)
970        // Account for FULL fee_payment in vault, not just required_fee
971        self.vault = self.vault + fee_payment;
972        self.insurance_fund.balance = self.insurance_fund.balance + required_fee;
973        self.insurance_fund.fee_revenue = self.insurance_fund.fee_revenue + required_fee;
974
975        // Allocate slot and assign unique ID
976        let idx = self.alloc_slot()?;
977        let account_id = self.next_account_id;
978        self.next_account_id = self.next_account_id.saturating_add(1);
979
980        // Initialize account with excess credited to capital
981        self.accounts[idx as usize] = Account {
982            kind: AccountKind::LP,
983            account_id,
984            capital: U128::new(excess), // Bug #4 fix: excess goes to LP capital
985            pnl: I128::ZERO,
986            reserved_pnl: 0,
987            warmup_started_at_slot: self.current_slot,
988            warmup_slope_per_step: U128::ZERO,
989            position_size: I128::ZERO,
990            entry_price: 0,
991            funding_index: self.funding_index_qpb_e6,
992            matcher_program: matching_engine_program,
993            matcher_context: matching_engine_context,
994            owner: [0; 32],
995            fee_credits: I128::ZERO,
996            last_fee_slot: self.current_slot,
997        };
998
999        // Maintain c_tot aggregate (account was created with capital = excess)
1000        if excess > 0 {
1001            self.c_tot = U128::new(self.c_tot.get().saturating_add(excess));
1002        }
1003
1004        Ok(idx)
1005    }
1006
1007    // ========================================
1008    // Maintenance Fees
1009    // ========================================
1010
1011    /// Settle maintenance fees for an account.
1012    ///
1013    /// Returns the fee amount due (for keeper rebate calculation).
1014    ///
1015    /// Algorithm:
1016    /// 1. Compute dt = now_slot - account.last_fee_slot
1017    /// 2. If dt == 0, return 0 (no-op)
1018    /// 3. Compute due = fee_per_slot * dt
1019    /// 4. Deduct from fee_credits; if negative, pay from capital to insurance
1020    /// 5. If position exists and below maintenance after fee, return Err
1021    pub fn settle_maintenance_fee(
1022        &mut self,
1023        idx: u16,
1024        now_slot: u64,
1025        oracle_price: u64,
1026    ) -> Result<u128> {
1027        if idx as usize >= MAX_ACCOUNTS || !self.is_used(idx as usize) {
1028            return Err(RiskError::Unauthorized);
1029        }
1030
1031        // Calculate elapsed time
1032        let dt = now_slot.saturating_sub(self.accounts[idx as usize].last_fee_slot);
1033        if dt == 0 {
1034            return Ok(0);
1035        }
1036
1037        // Calculate fee due (engine is purely slot-native)
1038        let due = self
1039            .params
1040            .maintenance_fee_per_slot
1041            .get()
1042            .saturating_mul(dt as u128);
1043
1044        // Update last_fee_slot
1045        self.accounts[idx as usize].last_fee_slot = now_slot;
1046
1047        // Deduct from fee_credits (coupon: no insurance booking here —
1048        // insurance was already paid when credits were granted)
1049        self.accounts[idx as usize].fee_credits =
1050            self.accounts[idx as usize].fee_credits.saturating_sub(due as i128);
1051
1052        // If fee_credits is negative, pay from capital using set_capital helper (spec §4.1)
1053        let mut paid_from_capital = 0u128;
1054        if self.accounts[idx as usize].fee_credits.is_negative() {
1055            let owed = neg_i128_to_u128(self.accounts[idx as usize].fee_credits.get());
1056            let current_cap = self.accounts[idx as usize].capital.get();
1057            let pay = core::cmp::min(owed, current_cap);
1058
1059            // Use set_capital helper to maintain c_tot aggregate (spec §4.1)
1060            self.set_capital(idx as usize, current_cap.saturating_sub(pay));
1061            self.insurance_fund.balance = self.insurance_fund.balance + pay;
1062            self.insurance_fund.fee_revenue = self.insurance_fund.fee_revenue + pay;
1063
1064            // Credit back what was paid
1065            self.accounts[idx as usize].fee_credits =
1066                self.accounts[idx as usize].fee_credits.saturating_add(pay as i128);
1067            paid_from_capital = pay;
1068        }
1069
1070        // Check maintenance margin if account has a position (MTM check)
1071        if !self.accounts[idx as usize].position_size.is_zero() {
1072            let account_ref = &self.accounts[idx as usize];
1073            if !self.is_above_maintenance_margin_mtm(account_ref, oracle_price) {
1074                return Err(RiskError::Undercollateralized);
1075            }
1076        }
1077
1078        Ok(paid_from_capital) // Return actual amount paid into insurance
1079    }
1080
1081    /// Best-effort maintenance settle for crank paths.
1082    /// - Always advances last_fee_slot
1083    /// - Charges fees into insurance if possible
1084    /// - NEVER fails due to margin checks
1085    /// - Still returns Unauthorized if idx invalid
1086    fn settle_maintenance_fee_best_effort_for_crank(
1087        &mut self,
1088        idx: u16,
1089        now_slot: u64,
1090    ) -> Result<u128> {
1091        if idx as usize >= MAX_ACCOUNTS || !self.is_used(idx as usize) {
1092            return Err(RiskError::Unauthorized);
1093        }
1094
1095        let dt = now_slot.saturating_sub(self.accounts[idx as usize].last_fee_slot);
1096        if dt == 0 {
1097            return Ok(0);
1098        }
1099
1100        let due = self
1101            .params
1102            .maintenance_fee_per_slot
1103            .get()
1104            .saturating_mul(dt as u128);
1105
1106        // Advance slot marker regardless
1107        self.accounts[idx as usize].last_fee_slot = now_slot;
1108
1109        // Deduct from fee_credits (coupon: no insurance booking here —
1110        // insurance was already paid when credits were granted)
1111        self.accounts[idx as usize].fee_credits =
1112            self.accounts[idx as usize].fee_credits.saturating_sub(due as i128);
1113
1114        // If negative, pay what we can from capital using set_capital helper (spec §4.1)
1115        let mut paid_from_capital = 0u128;
1116        if self.accounts[idx as usize].fee_credits.is_negative() {
1117            let owed = neg_i128_to_u128(self.accounts[idx as usize].fee_credits.get());
1118            let current_cap = self.accounts[idx as usize].capital.get();
1119            let pay = core::cmp::min(owed, current_cap);
1120
1121            // Use set_capital helper to maintain c_tot aggregate (spec §4.1)
1122            self.set_capital(idx as usize, current_cap.saturating_sub(pay));
1123            self.insurance_fund.balance = self.insurance_fund.balance + pay;
1124            self.insurance_fund.fee_revenue = self.insurance_fund.fee_revenue + pay;
1125
1126            self.accounts[idx as usize].fee_credits =
1127                self.accounts[idx as usize].fee_credits.saturating_add(pay as i128);
1128            paid_from_capital = pay;
1129        }
1130
1131        Ok(paid_from_capital) // Return actual amount paid into insurance
1132    }
1133
1134    /// Best-effort warmup settlement for crank: settles any warmed positive PnL to capital.
1135    /// Silently ignores errors (e.g., account not found) since crank must not stall on
1136    /// individual account issues. Used to drain abandoned accounts' positive PnL over time.
1137    fn settle_warmup_to_capital_for_crank(&mut self, idx: u16) {
1138        // Ignore errors: crank is best-effort and must continue processing other accounts
1139        let _ = self.settle_warmup_to_capital(idx);
1140    }
1141
1142    /// Pay down existing fee debt (negative fee_credits) using available capital.
1143    /// Does not advance last_fee_slot or charge new fees — just sweeps capital
1144    /// that became available (e.g. after warmup settlement) into insurance.
1145    /// Uses set_capital helper to maintain c_tot aggregate (spec §4.1).
1146    fn pay_fee_debt_from_capital(&mut self, idx: u16) {
1147        if self.accounts[idx as usize].fee_credits.is_negative()
1148            && !self.accounts[idx as usize].capital.is_zero()
1149        {
1150            let owed = neg_i128_to_u128(self.accounts[idx as usize].fee_credits.get());
1151            let current_cap = self.accounts[idx as usize].capital.get();
1152            let pay = core::cmp::min(owed, current_cap);
1153            if pay > 0 {
1154                // Use set_capital helper to maintain c_tot aggregate (spec §4.1)
1155                self.set_capital(idx as usize, current_cap.saturating_sub(pay));
1156                self.insurance_fund.balance = self.insurance_fund.balance + pay;
1157                self.insurance_fund.fee_revenue = self.insurance_fund.fee_revenue + pay;
1158                self.accounts[idx as usize].fee_credits =
1159                    self.accounts[idx as usize].fee_credits.saturating_add(pay as i128);
1160            }
1161        }
1162    }
1163
1164    /// Touch account for force-realize paths: settles funding, mark, and fees but
1165    /// uses best-effort fee settle that can't stall on margin checks.
1166    fn touch_account_for_force_realize(
1167        &mut self,
1168        idx: u16,
1169        now_slot: u64,
1170        oracle_price: u64,
1171    ) -> Result<()> {
1172        // Funding settle is required for correct pnl
1173        self.touch_account(idx)?;
1174        // Mark-to-market settlement (variation margin)
1175        self.settle_mark_to_oracle(idx, oracle_price)?;
1176        // Best-effort fees; never fails due to maintenance margin
1177        let _ = self.settle_maintenance_fee_best_effort_for_crank(idx, now_slot)?;
1178        Ok(())
1179    }
1180
1181    /// Touch account for liquidation paths: settles funding, mark, and fees but
1182    /// uses best-effort fee settle since we're about to liquidate anyway.
1183    fn touch_account_for_liquidation(
1184        &mut self,
1185        idx: u16,
1186        now_slot: u64,
1187        oracle_price: u64,
1188    ) -> Result<()> {
1189        // Funding settle is required for correct pnl
1190        self.touch_account(idx)?;
1191        // Best-effort mark-to-market (saturating — never wedges on extreme PnL)
1192        self.settle_mark_to_oracle_best_effort(idx, oracle_price)?;
1193        // Best-effort fees; margin check would just block the liquidation we need to do
1194        let _ = self.settle_maintenance_fee_best_effort_for_crank(idx, now_slot)?;
1195        Ok(())
1196    }
1197
1198    /// Set owner pubkey for an account
1199    pub fn set_owner(&mut self, idx: u16, owner: [u8; 32]) -> Result<()> {
1200        if idx as usize >= MAX_ACCOUNTS || !self.is_used(idx as usize) {
1201            return Err(RiskError::Unauthorized);
1202        }
1203        self.accounts[idx as usize].owner = owner;
1204        Ok(())
1205    }
1206
1207    /// Pre-fund fee credits for an account.
1208    ///
1209    /// The wrapper must have already transferred `amount` tokens into the vault.
1210    /// This pre-pays future maintenance fees: vault increases, insurance receives
1211    /// the amount as revenue (since credits are a coupon — spending them later
1212    /// does NOT re-book into insurance), and the account's fee_credits balance
1213    /// increases by `amount`.
1214    pub fn deposit_fee_credits(&mut self, idx: u16, amount: u128, now_slot: u64) -> Result<()> {
1215        if idx as usize >= MAX_ACCOUNTS || !self.is_used(idx as usize) {
1216            return Err(RiskError::Unauthorized);
1217        }
1218        self.current_slot = now_slot;
1219
1220        // Wrapper transferred tokens into vault
1221        self.vault = self.vault + amount;
1222
1223        // Pre-fund: insurance receives the amount now.
1224        // When credits are later spent during fee settlement, no further
1225        // insurance booking occurs (coupon semantics).
1226        self.insurance_fund.balance = self.insurance_fund.balance + amount;
1227        self.insurance_fund.fee_revenue = self.insurance_fund.fee_revenue + amount;
1228
1229        // Credit the account
1230        self.accounts[idx as usize].fee_credits = self.accounts[idx as usize]
1231            .fee_credits
1232            .saturating_add(amount as i128);
1233
1234        Ok(())
1235    }
1236
1237    /// Add fee credits without vault/insurance accounting.
1238    /// Only for tests and Kani proofs — production code must use deposit_fee_credits.
1239    #[cfg(any(test, feature = "test", kani))]
1240    pub fn add_fee_credits(&mut self, idx: u16, amount: u128) -> Result<()> {
1241        if idx as usize >= MAX_ACCOUNTS || !self.is_used(idx as usize) {
1242            return Err(RiskError::Unauthorized);
1243        }
1244        self.accounts[idx as usize].fee_credits = self.accounts[idx as usize]
1245            .fee_credits
1246            .saturating_add(amount as i128);
1247        Ok(())
1248    }
1249
1250    /// Set the risk reduction threshold (admin function).
1251    /// This controls when risk-reduction-only mode is triggered.
1252    #[inline]
1253    pub fn set_risk_reduction_threshold(&mut self, new_threshold: u128) {
1254        self.params.risk_reduction_threshold = U128::new(new_threshold);
1255    }
1256
1257    /// Get the current risk reduction threshold.
1258    #[inline]
1259    pub fn risk_reduction_threshold(&self) -> u128 {
1260        self.params.risk_reduction_threshold.get()
1261    }
1262
1263    /// Close an account and return its capital to the caller.
1264    ///
1265    /// Requirements:
1266    /// - Account must exist
1267    /// - Position must be zero (no open positions)
1268    /// - fee_credits >= 0 (no outstanding fees owed)
1269    /// - pnl must be 0 after settlement (positive pnl must be warmed up first)
1270    ///
1271    /// Returns Err(PnlNotWarmedUp) if pnl > 0 (user must wait for warmup).
1272    /// Returns Err(Undercollateralized) if pnl < 0 (shouldn't happen after settlement).
1273    /// Returns the capital amount on success.
1274    pub fn close_account(&mut self, idx: u16, now_slot: u64, oracle_price: u64) -> Result<u128> {
1275        // Update current_slot so warmup/bookkeeping progresses consistently
1276        self.current_slot = now_slot;
1277
1278        if idx as usize >= MAX_ACCOUNTS || !self.is_used(idx as usize) {
1279            return Err(RiskError::AccountNotFound);
1280        }
1281
1282        // Full settlement: funding + maintenance fees + warmup
1283        // This converts warmed pnl to capital and realizes negative pnl
1284        self.touch_account_full(idx, now_slot, oracle_price)?;
1285
1286        // Position must be zero
1287        if !self.accounts[idx as usize].position_size.is_zero() {
1288            return Err(RiskError::Undercollateralized); // Has open position
1289        }
1290
1291        // Forgive any remaining fee debt (Finding C: fee debt traps).
1292        // pay_fee_debt_from_capital (via touch_account_full above) already paid
1293        // what it could. Any remainder is uncollectable — forgive and proceed.
1294        if self.accounts[idx as usize].fee_credits.is_negative() {
1295            self.accounts[idx as usize].fee_credits = I128::ZERO;
1296        }
1297
1298        let account = &self.accounts[idx as usize];
1299
1300        // PnL must be zero to close. This enforces:
1301        // 1. Users can't bypass warmup by closing with positive unwarmed pnl
1302        // 2. Conservation is maintained (forfeiting pnl would create unbounded slack)
1303        // 3. Negative pnl after full settlement implies insolvency
1304        if account.pnl.is_positive() {
1305            return Err(RiskError::PnlNotWarmedUp);
1306        }
1307        if account.pnl.is_negative() {
1308            return Err(RiskError::Undercollateralized);
1309        }
1310
1311        let capital = account.capital;
1312
1313        // Deduct from vault
1314        if capital > self.vault {
1315            return Err(RiskError::InsufficientBalance);
1316        }
1317        self.vault = self.vault - capital;
1318
1319        // Decrement c_tot before freeing slot (free_slot zeroes account but doesn't update c_tot)
1320        self.set_capital(idx as usize, 0);
1321
1322        // Free the slot
1323        self.free_slot(idx);
1324
1325        Ok(capital.get())
1326    }
1327
1328    /// Free an account slot (internal helper).
1329    /// Clears the account, bitmap, and returns slot to freelist.
1330    /// Caller must ensure the account is safe to free (no capital, no positive pnl, etc).
1331    fn free_slot(&mut self, idx: u16) {
1332        self.accounts[idx as usize] = empty_account();
1333        self.clear_used(idx as usize);
1334        self.next_free[idx as usize] = self.free_head;
1335        self.free_head = idx;
1336        self.num_used_accounts = self.num_used_accounts.saturating_sub(1);
1337    }
1338
1339    /// Garbage collect dust accounts.
1340    ///
1341    /// A "dust account" is a slot that can never pay out anything:
1342    /// - position_size == 0
1343    /// - capital == 0
1344    /// - reserved_pnl == 0
1345    /// - pnl <= 0
1346    ///
1347    /// Any remaining negative PnL is socialized via ADL waterfall before freeing.
1348    /// No token transfers occur - this is purely internal bookkeeping cleanup.
1349    ///
1350    /// Called at end of keeper_crank after liquidation/settlement has already run.
1351    ///
1352    /// Returns the number of accounts closed.
1353    pub fn garbage_collect_dust(&mut self) -> u32 {
1354        // Collect dust candidates: accounts with zero position, capital, reserved, and non-positive pnl
1355        let mut to_free: [u16; GC_CLOSE_BUDGET as usize] = [0; GC_CLOSE_BUDGET as usize];
1356        let mut num_to_free = 0usize;
1357
1358        // Scan up to ACCOUNTS_PER_CRANK slots, capped to MAX_ACCOUNTS
1359        let max_scan = (ACCOUNTS_PER_CRANK as usize).min(MAX_ACCOUNTS);
1360        let start = self.gc_cursor as usize;
1361
1362        for offset in 0..max_scan {
1363            // Budget check
1364            if num_to_free >= GC_CLOSE_BUDGET as usize {
1365                break;
1366            }
1367
1368            let idx = (start + offset) & ACCOUNT_IDX_MASK;
1369
1370            // Check if slot is used via bitmap
1371            let block = idx >> 6;
1372            let bit = idx & 63;
1373            if (self.used[block] & (1u64 << bit)) == 0 {
1374                continue;
1375            }
1376
1377            // NEVER garbage collect LP accounts - they are essential for market operation
1378            if self.accounts[idx].is_lp() {
1379                continue;
1380            }
1381
1382            // Best-effort fee settle so accounts with tiny capital get drained in THIS sweep.
1383            let _ = self.settle_maintenance_fee_best_effort_for_crank(idx as u16, self.current_slot);
1384
1385            // Dust predicate: must have zero position, capital, reserved, and non-positive pnl
1386            {
1387                let account = &self.accounts[idx];
1388                if !account.position_size.is_zero() {
1389                    continue;
1390                }
1391                if !account.capital.is_zero() {
1392                    continue;
1393                }
1394                if account.reserved_pnl != 0 {
1395                    continue;
1396                }
1397                if account.pnl.is_positive() {
1398                    continue;
1399                }
1400            }
1401
1402            // If flat, funding is irrelevant — snap to global so dust can be collected.
1403            // Position size is already confirmed zero above, so no unsettled funding value.
1404            if self.accounts[idx].funding_index != self.funding_index_qpb_e6 {
1405                self.accounts[idx].funding_index = self.funding_index_qpb_e6;
1406            }
1407
1408            // Write off negative pnl (spec §6.1: unpayable loss just reduces Residual)
1409            if self.accounts[idx].pnl.is_negative() {
1410                self.set_pnl(idx, 0);
1411            }
1412
1413            // Queue for freeing
1414            to_free[num_to_free] = idx as u16;
1415            num_to_free += 1;
1416        }
1417
1418        // Update cursor for next call
1419        self.gc_cursor = ((start + max_scan) & ACCOUNT_IDX_MASK) as u16;
1420
1421        // Free all collected dust accounts
1422        for i in 0..num_to_free {
1423            self.free_slot(to_free[i]);
1424        }
1425
1426        num_to_free as u32
1427    }
1428
1429    // ========================================
1430    // Keeper Crank
1431    // ========================================
1432
1433    /// Check if a fresh crank is required before state-changing operations.
1434    /// Returns Err if the crank is stale (too old).
1435    pub fn require_fresh_crank(&self, now_slot: u64) -> Result<()> {
1436        if now_slot.saturating_sub(self.last_crank_slot) > self.max_crank_staleness_slots {
1437            return Err(RiskError::Unauthorized); // NeedsCrank
1438        }
1439        Ok(())
1440    }
1441
1442    /// Check if a full sweep started recently.
1443    /// For risk-increasing ops, we require a sweep to have STARTED recently.
1444    /// The priority-liquidation phase runs every crank, so once a sweep starts,
1445    /// the worst accounts are immediately addressed.
1446    pub fn require_recent_full_sweep(&self, now_slot: u64) -> Result<()> {
1447        if now_slot.saturating_sub(self.last_full_sweep_start_slot) > self.max_crank_staleness_slots
1448        {
1449            return Err(RiskError::Unauthorized); // SweepStale
1450        }
1451        Ok(())
1452    }
1453
1454
1455    /// Check if force-realize mode is active (insurance at or below threshold).
1456    /// When active, keeper_crank will run windowed force-realize steps.
1457    #[inline]
1458    fn force_realize_active(&self) -> bool {
1459        self.insurance_fund.balance <= self.params.risk_reduction_threshold
1460    }
1461
1462    /// Keeper crank entrypoint - advances global state and performs maintenance.
1463    ///
1464    /// Returns CrankOutcome with flags indicating what happened.
1465    ///
1466    /// Behavior:
1467    /// 1. Accrue funding
1468    /// 2. Advance last_crank_slot if now_slot > last_crank_slot
1469    /// 3. Settle maintenance fees for caller (50% discount)
1470    /// 4. Process up to ACCOUNTS_PER_CRANK occupied accounts:
1471    ///    - Liquidation (if not in force-realize mode)
1472    ///    - Force-realize (if insurance at/below threshold)
1473    ///    - Socialization (haircut profits to cover losses)
1474    ///    - LP max tracking
1475    /// 5. Detect and finalize full sweep completion
1476    ///
1477    /// This is the single permissionless "do-the-right-thing" entrypoint.
1478    /// - Always attempts caller's maintenance settle with 50% discount (best-effort)
1479    /// - Only advances last_crank_slot when now_slot > last_crank_slot
1480    /// - Returns last_cursor: the index where this crank stopped
1481    /// - Returns sweep_complete: true if this crank completed a full sweep
1482    ///
1483    /// When the system has fewer than ACCOUNTS_PER_CRANK accounts, one crank
1484    /// covers all accounts and completes a full sweep.
1485    pub fn keeper_crank(
1486        &mut self,
1487        caller_idx: u16,
1488        now_slot: u64,
1489        oracle_price: u64,
1490        funding_rate_bps_per_slot: i64,
1491        allow_panic: bool,
1492    ) -> Result<CrankOutcome> {
1493        // Validate oracle price bounds (prevents overflow in mark_pnl calculations)
1494        if oracle_price == 0 || oracle_price > MAX_ORACLE_PRICE {
1495            return Err(RiskError::Overflow);
1496        }
1497
1498        // Update current_slot so warmup/bookkeeping progresses consistently
1499        self.current_slot = now_slot;
1500
1501        // Detect if this is the start of a new sweep
1502        let starting_new_sweep = self.crank_cursor == self.sweep_start_idx;
1503        if starting_new_sweep {
1504            self.last_full_sweep_start_slot = now_slot;
1505            // Reset in-progress lp_max_abs for fresh sweep
1506            self.lp_max_abs_sweep = U128::ZERO;
1507        }
1508
1509        // Accrue funding first using the STORED rate (anti-retroactivity).
1510        // This ensures funding charged for the elapsed interval uses the rate that was
1511        // in effect at the start of the interval, NOT the new rate computed from current state.
1512        self.accrue_funding(now_slot, oracle_price)?;
1513
1514        // Now set the new rate for the NEXT interval (anti-retroactivity).
1515        // The funding_rate_bps_per_slot parameter becomes the rate for [now_slot, next_accrual).
1516        self.set_funding_rate_for_next_interval(funding_rate_bps_per_slot);
1517
1518        // Check if we're advancing the global crank slot
1519        let advanced = now_slot > self.last_crank_slot;
1520        if advanced {
1521            self.last_crank_slot = now_slot;
1522        }
1523
1524        // Always attempt caller's maintenance settle (best-effort, no timestamp games)
1525        let (slots_forgiven, caller_settle_ok) = if (caller_idx as usize) < MAX_ACCOUNTS
1526            && self.is_used(caller_idx as usize)
1527        {
1528            let last_fee = self.accounts[caller_idx as usize].last_fee_slot;
1529            let dt = now_slot.saturating_sub(last_fee);
1530            let forgive = dt / 2;
1531
1532            if forgive > 0 && dt > 0 {
1533                self.accounts[caller_idx as usize].last_fee_slot = last_fee.saturating_add(forgive);
1534            }
1535            let settle_result =
1536                self.settle_maintenance_fee_best_effort_for_crank(caller_idx, now_slot);
1537            (forgive, settle_result.is_ok())
1538        } else {
1539            (0, true)
1540        };
1541
1542        // Detect conditions for informational flags (before processing)
1543        let force_realize_active = self.force_realize_active();
1544
1545        // Process up to ACCOUNTS_PER_CRANK occupied accounts
1546        let mut num_liquidations: u32 = 0;
1547        let mut num_liq_errors: u16 = 0;
1548        let mut force_realize_closed: u16 = 0;
1549        let mut force_realize_errors: u16 = 0;
1550        let mut sweep_complete = false;
1551        let mut accounts_processed: u16 = 0;
1552        let mut liq_budget = LIQ_BUDGET_PER_CRANK;
1553        let mut force_realize_budget = FORCE_REALIZE_BUDGET_PER_CRANK;
1554
1555        let start_cursor = self.crank_cursor;
1556
1557        // Iterate through index space looking for occupied accounts
1558        let mut idx = self.crank_cursor as usize;
1559        let mut slots_scanned: usize = 0;
1560
1561        while accounts_processed < ACCOUNTS_PER_CRANK && slots_scanned < MAX_ACCOUNTS {
1562            slots_scanned += 1;
1563
1564            // Check if slot is used
1565            let block = idx >> 6;
1566            let bit = idx & 63;
1567            let is_occupied = (self.used[block] & (1u64 << bit)) != 0;
1568
1569            if is_occupied {
1570                accounts_processed += 1;
1571
1572                // Always settle maintenance fees for every visited account.
1573                // This drains idle accounts over time so they eventually become dust.
1574                let _ = self.settle_maintenance_fee_best_effort_for_crank(idx as u16, now_slot);
1575                // Touch account and settle warmup to drain abandoned positive PnL
1576                let _ = self.touch_account(idx as u16);
1577                self.settle_warmup_to_capital_for_crank(idx as u16);
1578
1579                // === Liquidation (if not in force-realize mode) ===
1580                if !force_realize_active && liq_budget > 0 {
1581                    if !self.accounts[idx].position_size.is_zero() {
1582                        match self.liquidate_at_oracle(idx as u16, now_slot, oracle_price) {
1583                            Ok(true) => {
1584                                num_liquidations += 1;
1585                                liq_budget = liq_budget.saturating_sub(1);
1586                            }
1587                            Ok(false) => {}
1588                            Err(_) => {
1589                                num_liq_errors += 1;
1590                            }
1591                        }
1592                    }
1593
1594                    // Force-close negative equity or dust positions
1595                    if !self.accounts[idx].position_size.is_zero() {
1596                        let equity =
1597                            self.account_equity_mtm_at_oracle(&self.accounts[idx], oracle_price);
1598                        let abs_pos = self.accounts[idx].position_size.unsigned_abs();
1599                        let is_dust = abs_pos < self.params.min_liquidation_abs.get();
1600
1601                        if equity == 0 || is_dust {
1602                            // Force close: settle mark, close position, write off loss
1603                            let _ = self.touch_account_for_liquidation(idx as u16, now_slot, oracle_price);
1604                            let _ = self.oracle_close_position_core(idx as u16, oracle_price);
1605                            self.lifetime_force_realize_closes =
1606                                self.lifetime_force_realize_closes.saturating_add(1);
1607                        }
1608                    }
1609                }
1610
1611                // === Force-realize (when insurance at/below threshold) ===
1612                if force_realize_active && force_realize_budget > 0 {
1613                    if !self.accounts[idx].position_size.is_zero() {
1614                        if self
1615                            .touch_account_for_force_realize(idx as u16, now_slot, oracle_price)
1616                            .is_ok()
1617                        {
1618                            if self.oracle_close_position_core(idx as u16, oracle_price).is_ok() {
1619                                force_realize_closed += 1;
1620                                force_realize_budget = force_realize_budget.saturating_sub(1);
1621                                self.lifetime_force_realize_closes =
1622                                    self.lifetime_force_realize_closes.saturating_add(1);
1623                            } else {
1624                                force_realize_errors += 1;
1625                            }
1626                        } else {
1627                            force_realize_errors += 1;
1628                        }
1629                    }
1630                }
1631
1632                // === LP max tracking ===
1633                if self.accounts[idx].is_lp() {
1634                    let abs_pos = self.accounts[idx].position_size.unsigned_abs();
1635                    self.lp_max_abs_sweep = self.lp_max_abs_sweep.max(U128::new(abs_pos));
1636                }
1637            }
1638
1639            // Advance to next index (with wrap)
1640            idx = (idx + 1) & ACCOUNT_IDX_MASK;
1641
1642            // Check for sweep completion: we've wrapped around to sweep_start_idx
1643            // (and we've actually processed some slots, not just starting)
1644            if idx == self.sweep_start_idx as usize && slots_scanned > 0 {
1645                sweep_complete = true;
1646                break;
1647            }
1648        }
1649
1650        // Update cursor for next crank
1651        self.crank_cursor = idx as u16;
1652
1653        // If sweep complete, finalize
1654        if sweep_complete {
1655            self.last_full_sweep_completed_slot = now_slot;
1656            self.lp_max_abs = self.lp_max_abs_sweep;
1657            self.sweep_start_idx = self.crank_cursor;
1658        }
1659
1660        // Garbage collect dust accounts
1661        let num_gc_closed = self.garbage_collect_dust();
1662
1663        // Detect conditions for informational flags
1664        let force_realize_needed = self.force_realize_active();
1665        let panic_needed = false; // No longer needed with haircut ratio
1666
1667        Ok(CrankOutcome {
1668            advanced,
1669            slots_forgiven,
1670            caller_settle_ok,
1671            force_realize_needed,
1672            panic_needed,
1673            num_liquidations,
1674            num_liq_errors,
1675            num_gc_closed,
1676            force_realize_closed,
1677            force_realize_errors,
1678            last_cursor: self.crank_cursor,
1679            sweep_complete,
1680        })
1681    }
1682
1683    // ========================================
1684    // Liquidation
1685    // ========================================
1686
1687    /// Compute mark PnL for a position at oracle price (pure helper, no side effects).
1688    /// Returns the PnL from closing the position at oracle price.
1689    /// - Longs: profit when oracle > entry
1690    /// - Shorts: profit when entry > oracle
1691    pub fn mark_pnl_for_position(pos: i128, entry: u64, oracle: u64) -> Result<i128> {
1692        if pos == 0 {
1693            return Ok(0);
1694        }
1695
1696        let abs_pos = saturating_abs_i128(pos) as u128;
1697
1698        let diff: i128 = if pos > 0 {
1699            // Long: profit when oracle > entry
1700            (oracle as i128).saturating_sub(entry as i128)
1701        } else {
1702            // Short: profit when entry > oracle
1703            (entry as i128).saturating_sub(oracle as i128)
1704        };
1705
1706        // mark_pnl = diff * abs_pos / 1_000_000
1707        diff.checked_mul(abs_pos as i128)
1708            .ok_or(RiskError::Overflow)?
1709            .checked_div(1_000_000)
1710            .ok_or(RiskError::Overflow)
1711    }
1712
1713    /// Compute how much position to close for liquidation (closed-form, single-pass).
1714    ///
1715    /// Returns (close_abs, is_full_close) where:
1716    /// - close_abs = absolute position size to close
1717    /// - is_full_close = true if this is a full position close (including dust kill-switch)
1718    ///
1719    /// ## Algorithm:
1720    /// 1. Compute target_bps = maintenance_margin_bps + liquidation_buffer_bps
1721    /// 2. Compute max safe remaining position: abs_pos_safe_max = floor(E_mtm * 10_000 * 1_000_000 / (P * target_bps))
1722    /// 3. close_abs = abs_pos - abs_pos_safe_max
1723    /// 4. If remaining position < min_liquidation_abs, do full close (dust kill-switch)
1724    ///
1725    /// Uses MTM equity (capital + realized_pnl + mark_pnl) for correct risk calculation.
1726    /// This is deterministic, requires no iteration, and guarantees single-pass liquidation.
1727    pub fn compute_liquidation_close_amount(
1728        &self,
1729        account: &Account,
1730        oracle_price: u64,
1731    ) -> (u128, bool) {
1732        let abs_pos = saturating_abs_i128(account.position_size.get()) as u128;
1733        if abs_pos == 0 {
1734            return (0, false);
1735        }
1736
1737        // MTM equity at oracle price (fail-safe: overflow returns 0 = full liquidation)
1738        let equity = self.account_equity_mtm_at_oracle(account, oracle_price);
1739
1740        // Target margin = maintenance + buffer (in basis points)
1741        let target_bps = self
1742            .params
1743            .maintenance_margin_bps
1744            .saturating_add(self.params.liquidation_buffer_bps);
1745
1746        // Maximum safe remaining position (floor-safe calculation)
1747        // abs_pos_safe_max = floor(equity * 10_000 * 1_000_000 / (oracle_price * target_bps))
1748        // Rearranged to avoid intermediate overflow:
1749        // abs_pos_safe_max = floor(equity * 10_000_000_000 / (oracle_price * target_bps))
1750        let numerator = mul_u128(equity, 10_000_000_000);
1751        let denominator = mul_u128(oracle_price as u128, target_bps as u128);
1752
1753        let mut abs_pos_safe_max = if denominator == 0 {
1754            0 // Edge case: full liquidation if no denominator
1755        } else {
1756            numerator / denominator
1757        };
1758
1759        // Clamp to current position (can't have safe max > actual position)
1760        abs_pos_safe_max = core::cmp::min(abs_pos_safe_max, abs_pos);
1761
1762        // Conservative rounding guard: subtract 1 unit to ensure we close slightly more
1763        // than mathematically required. This guarantees post-liquidation account is
1764        // strictly on the safe side of the inequality despite integer truncation.
1765        if abs_pos_safe_max > 0 {
1766            abs_pos_safe_max -= 1;
1767        }
1768
1769        // Required close amount
1770        let close_abs = abs_pos.saturating_sub(abs_pos_safe_max);
1771
1772        // Dust kill-switch: if remaining position would be below min, do full close
1773        let remaining = abs_pos.saturating_sub(close_abs);
1774        if remaining < self.params.min_liquidation_abs.get() {
1775            return (abs_pos, true); // Full close
1776        }
1777
1778        (close_abs, close_abs == abs_pos)
1779    }
1780
1781    /// Core helper for closing a SLICE of a position at oracle price (partial liquidation).
1782    ///
1783    /// Similar to oracle_close_position_core but:
1784    /// - Only closes `close_abs` units of position (not the entire position)
1785    /// - Computes proportional mark_pnl for the closed slice
1786    /// - Entry price remains unchanged (correct for same-direction partial reduction)
1787    ///
1788    /// ## PnL Routing (same invariant as full close):
1789    /// - mark_pnl > 0 (profit) → backed by haircut ratio h (no ADL needed)
1790    /// - mark_pnl <= 0 (loss) → realized via settle_warmup_to_capital (capital path)
1791    /// - Residual negative PnL (capital exhausted) → written off via set_pnl(i, 0) (spec §6.1)
1792    ///
1793    /// ASSUMES: Caller has already called touch_account_full() on this account.
1794    fn oracle_close_position_slice_core(
1795        &mut self,
1796        idx: u16,
1797        oracle_price: u64,
1798        close_abs: u128,
1799    ) -> Result<ClosedOutcome> {
1800        let pos = self.accounts[idx as usize].position_size.get();
1801        let current_abs_pos = saturating_abs_i128(pos) as u128;
1802
1803        if close_abs == 0 || current_abs_pos == 0 {
1804            return Ok(ClosedOutcome {
1805                abs_pos: 0,
1806                mark_pnl: 0,
1807                cap_before: self.accounts[idx as usize].capital.get(),
1808                cap_after: self.accounts[idx as usize].capital.get(),
1809                position_was_closed: false,
1810            });
1811        }
1812
1813        if close_abs >= current_abs_pos {
1814            return self.oracle_close_position_core(idx, oracle_price);
1815        }
1816
1817        let entry = self.accounts[idx as usize].entry_price;
1818        let cap_before = self.accounts[idx as usize].capital.get();
1819
1820        let diff: i128 = if pos > 0 {
1821            (oracle_price as i128).saturating_sub(entry as i128)
1822        } else {
1823            (entry as i128).saturating_sub(oracle_price as i128)
1824        };
1825
1826        let mark_pnl = match diff
1827            .checked_mul(close_abs as i128)
1828            .and_then(|v| v.checked_div(1_000_000))
1829        {
1830            Some(pnl) => pnl,
1831            None => -u128_to_i128_clamped(cap_before),
1832        };
1833
1834        // Apply mark PnL via set_pnl (maintains pnl_pos_tot aggregate)
1835        let new_pnl = self.accounts[idx as usize].pnl.get().saturating_add(mark_pnl);
1836        self.set_pnl(idx as usize, new_pnl);
1837
1838        // Update position
1839        let new_abs_pos = current_abs_pos.saturating_sub(close_abs);
1840        self.accounts[idx as usize].position_size = if pos > 0 {
1841            I128::new(new_abs_pos as i128)
1842        } else {
1843            I128::new(-(new_abs_pos as i128))
1844        };
1845
1846        // Update OI
1847        self.total_open_interest = self.total_open_interest - close_abs;
1848
1849        // Update LP aggregates if LP
1850        if self.accounts[idx as usize].is_lp() {
1851            let new_pos = self.accounts[idx as usize].position_size.get();
1852            self.net_lp_pos = self.net_lp_pos - pos + new_pos;
1853            self.lp_sum_abs = self.lp_sum_abs - close_abs;
1854        }
1855
1856        // Settle warmup (loss settlement + profit conversion per spec §6)
1857        self.settle_warmup_to_capital(idx)?;
1858
1859        // Write off residual negative PnL (capital exhausted) per spec §6.1
1860        if self.accounts[idx as usize].pnl.is_negative() {
1861            self.set_pnl(idx as usize, 0);
1862        }
1863
1864        let cap_after = self.accounts[idx as usize].capital.get();
1865
1866        Ok(ClosedOutcome {
1867            abs_pos: close_abs,
1868            mark_pnl,
1869            cap_before,
1870            cap_after,
1871            position_was_closed: true,
1872        })
1873    }
1874
1875    /// Core helper for oracle-price full position close (spec §6).
1876    ///
1877    /// Applies mark PnL, closes position, settles warmup, writes off unpayable loss.
1878    /// No ADL needed — undercollateralization is reflected via haircut ratio h.
1879    ///
1880    /// ASSUMES: Caller has already called touch_account_full() on this account.
1881    fn oracle_close_position_core(&mut self, idx: u16, oracle_price: u64) -> Result<ClosedOutcome> {
1882        if self.accounts[idx as usize].position_size.is_zero() {
1883            return Ok(ClosedOutcome {
1884                abs_pos: 0,
1885                mark_pnl: 0,
1886                cap_before: self.accounts[idx as usize].capital.get(),
1887                cap_after: self.accounts[idx as usize].capital.get(),
1888                position_was_closed: false,
1889            });
1890        }
1891
1892        let pos = self.accounts[idx as usize].position_size.get();
1893        let abs_pos = saturating_abs_i128(pos) as u128;
1894        let entry = self.accounts[idx as usize].entry_price;
1895        let cap_before = self.accounts[idx as usize].capital.get();
1896
1897        let mark_pnl = match Self::mark_pnl_for_position(pos, entry, oracle_price) {
1898            Ok(pnl) => pnl,
1899            Err(_) => -u128_to_i128_clamped(cap_before),
1900        };
1901
1902        // Apply mark PnL via set_pnl (maintains pnl_pos_tot aggregate)
1903        let new_pnl = self.accounts[idx as usize].pnl.get().saturating_add(mark_pnl);
1904        self.set_pnl(idx as usize, new_pnl);
1905
1906        // Close position
1907        self.accounts[idx as usize].position_size = I128::ZERO;
1908        self.accounts[idx as usize].entry_price = oracle_price;
1909
1910        // Update OI
1911        self.total_open_interest = self.total_open_interest - abs_pos;
1912
1913        // Update LP aggregates if LP
1914        if self.accounts[idx as usize].is_lp() {
1915            self.net_lp_pos = self.net_lp_pos - pos;
1916            self.lp_sum_abs = self.lp_sum_abs - abs_pos;
1917        }
1918
1919        // Settle warmup (loss settlement + profit conversion per spec §6)
1920        self.settle_warmup_to_capital(idx)?;
1921
1922        // Write off residual negative PnL (capital exhausted) per spec §6.1
1923        if self.accounts[idx as usize].pnl.is_negative() {
1924            self.set_pnl(idx as usize, 0);
1925        }
1926
1927        let cap_after = self.accounts[idx as usize].capital.get();
1928
1929        Ok(ClosedOutcome {
1930            abs_pos,
1931            mark_pnl,
1932            cap_before,
1933            cap_after,
1934            position_was_closed: true,
1935        })
1936    }
1937
1938    /// Liquidate a single account at oracle price if below maintenance margin.
1939    ///
1940    /// Returns Ok(true) if liquidation occurred, Ok(false) if not needed/possible.
1941    /// Per spec: close position, settle losses, write off unpayable PnL, charge fee.
1942    /// No ADL — haircut ratio h reflects any undercollateralization.
1943    pub fn liquidate_at_oracle(
1944        &mut self,
1945        idx: u16,
1946        now_slot: u64,
1947        oracle_price: u64,
1948    ) -> Result<bool> {
1949        self.current_slot = now_slot;
1950
1951        if (idx as usize) >= MAX_ACCOUNTS || !self.is_used(idx as usize) {
1952            return Ok(false);
1953        }
1954
1955        if oracle_price == 0 || oracle_price > MAX_ORACLE_PRICE {
1956            return Err(RiskError::Overflow);
1957        }
1958
1959        if self.accounts[idx as usize].position_size.is_zero() {
1960            return Ok(false);
1961        }
1962
1963        // Settle funding + mark-to-market + best-effort fees
1964        self.touch_account_for_liquidation(idx, now_slot, oracle_price)?;
1965
1966        let account = &self.accounts[idx as usize];
1967        if self.is_above_maintenance_margin_mtm(account, oracle_price) {
1968            return Ok(false);
1969        }
1970
1971        let (close_abs, is_full_close) =
1972            self.compute_liquidation_close_amount(account, oracle_price);
1973
1974        if close_abs == 0 {
1975            return Ok(false);
1976        }
1977
1978        // Close position (no ADL — losses written off in close helper)
1979        let mut outcome = if is_full_close {
1980            self.oracle_close_position_core(idx, oracle_price)?
1981        } else {
1982            match self.oracle_close_position_slice_core(idx, oracle_price, close_abs) {
1983                Ok(r) => r,
1984                Err(RiskError::Overflow) => {
1985                    self.oracle_close_position_core(idx, oracle_price)?
1986                }
1987                Err(e) => return Err(e),
1988            }
1989        };
1990
1991        if !outcome.position_was_closed {
1992            return Ok(false);
1993        }
1994
1995        // Safety check: if position remains and still below target, full close
1996        if !self.accounts[idx as usize].position_size.is_zero() {
1997            let target_bps = self
1998                .params
1999                .maintenance_margin_bps
2000                .saturating_add(self.params.liquidation_buffer_bps);
2001            if !self.is_above_margin_bps_mtm(&self.accounts[idx as usize], oracle_price, target_bps)
2002            {
2003                let fallback = self.oracle_close_position_core(idx, oracle_price)?;
2004                if fallback.position_was_closed {
2005                    outcome.abs_pos = outcome.abs_pos.saturating_add(fallback.abs_pos);
2006                }
2007            }
2008        }
2009
2010        // Charge liquidation fee (from remaining capital → insurance)
2011        // Use ceiling division for consistency with trade fees
2012        let notional = mul_u128(outcome.abs_pos, oracle_price as u128) / 1_000_000;
2013        let fee_raw = if notional > 0 && self.params.liquidation_fee_bps > 0 {
2014            (mul_u128(notional, self.params.liquidation_fee_bps as u128) + 9999) / 10_000
2015        } else {
2016            0
2017        };
2018        let fee = core::cmp::min(fee_raw, self.params.liquidation_fee_cap.get());
2019        let account_capital = self.accounts[idx as usize].capital.get();
2020        let pay = core::cmp::min(fee, account_capital);
2021
2022        self.set_capital(idx as usize, account_capital.saturating_sub(pay));
2023        self.insurance_fund.balance = self.insurance_fund.balance.saturating_add_u128(U128::new(pay));
2024        self.insurance_fund.fee_revenue = self.insurance_fund.fee_revenue.saturating_add_u128(U128::new(pay));
2025
2026        self.lifetime_liquidations = self.lifetime_liquidations.saturating_add(1);
2027
2028        Ok(true)
2029    }
2030
2031    // ========================================
2032    // Warmup
2033    // ========================================
2034
2035    /// Calculate withdrawable PNL for an account after warmup
2036    pub fn withdrawable_pnl(&self, account: &Account) -> u128 {
2037        // Only positive PNL can be withdrawn
2038        let positive_pnl = clamp_pos_i128(account.pnl.get());
2039
2040        // Available = positive PNL - reserved
2041        let available_pnl = sub_u128(positive_pnl, account.reserved_pnl as u128);
2042
2043        let effective_slot = self.current_slot;
2044
2045        // Calculate elapsed slots
2046        let elapsed_slots = effective_slot.saturating_sub(account.warmup_started_at_slot);
2047
2048        // Calculate warmed up cap: slope * elapsed_slots
2049        let warmed_up_cap = mul_u128(account.warmup_slope_per_step.get(), elapsed_slots as u128);
2050
2051        // Return minimum of available and warmed up
2052        core::cmp::min(available_pnl, warmed_up_cap)
2053    }
2054
2055    /// Update warmup slope for an account
2056    /// NOTE: No warmup rate cap (removed for simplicity)
2057    pub fn update_warmup_slope(&mut self, idx: u16) -> Result<()> {
2058        if !self.is_used(idx as usize) {
2059            return Err(RiskError::AccountNotFound);
2060        }
2061
2062        let account = &mut self.accounts[idx as usize];
2063
2064        // Calculate available gross PnL: AvailGross_i = max(PNL_i, 0) - R_i (spec §5)
2065        let positive_pnl = clamp_pos_i128(account.pnl.get());
2066        let avail_gross = sub_u128(positive_pnl, account.reserved_pnl as u128);
2067
2068        // Calculate slope: avail_gross / warmup_period
2069        // Ensure slope >= 1 when avail_gross > 0 to prevent "zero forever" bug
2070        let slope = if self.params.warmup_period_slots > 0 {
2071            let base = avail_gross / (self.params.warmup_period_slots as u128);
2072            if avail_gross > 0 {
2073                core::cmp::max(1, base)
2074            } else {
2075                0
2076            }
2077        } else {
2078            avail_gross // Instant warmup if period is 0
2079        };
2080
2081        // Verify slope >= 1 when available PnL exists
2082        #[cfg(any(test, kani))]
2083        debug_assert!(
2084            slope >= 1 || avail_gross == 0,
2085            "Warmup slope bug: slope {} with avail_gross {}",
2086            slope,
2087            avail_gross
2088        );
2089
2090        // Update slope
2091        account.warmup_slope_per_step = U128::new(slope);
2092
2093        account.warmup_started_at_slot = self.current_slot;
2094
2095        Ok(())
2096    }
2097
2098    // ========================================
2099    // Funding
2100    // ========================================
2101
2102    /// Accrue funding globally in O(1) using the stored rate (anti-retroactivity).
2103    ///
2104    /// This uses `funding_rate_bps_per_slot_last` - the rate in effect since `last_funding_slot`.
2105    /// The rate for the NEXT interval is set separately via `set_funding_rate_for_next_interval`.
2106    ///
2107    /// Anti-retroactivity guarantee: state changes at slot t can only affect funding for slots >= t.
2108    pub fn accrue_funding(&mut self, now_slot: u64, oracle_price: u64) -> Result<()> {
2109        let dt = now_slot.saturating_sub(self.last_funding_slot);
2110        if dt == 0 {
2111            return Ok(());
2112        }
2113
2114        // Input validation to prevent overflow
2115        if oracle_price == 0 || oracle_price > MAX_ORACLE_PRICE {
2116            return Err(RiskError::Overflow);
2117        }
2118
2119        // Use the STORED rate (anti-retroactivity: rate was set at start of interval)
2120        let funding_rate = self.funding_rate_bps_per_slot_last;
2121
2122        // Cap funding rate at 10000 bps (100%) per slot as sanity bound
2123        // Real-world funding rates should be much smaller (typically < 1 bps/slot)
2124        if funding_rate.abs() > 10_000 {
2125            return Err(RiskError::Overflow);
2126        }
2127
2128        if dt > 31_536_000 {
2129            return Err(RiskError::Overflow);
2130        }
2131
2132        // Use checked math to prevent silent overflow
2133        let price = oracle_price as i128;
2134        let rate = funding_rate as i128;
2135        let dt_i = dt as i128;
2136
2137        // ΔF = price × rate × dt / 10,000
2138        let delta = price
2139            .checked_mul(rate)
2140            .ok_or(RiskError::Overflow)?
2141            .checked_mul(dt_i)
2142            .ok_or(RiskError::Overflow)?
2143            .checked_div(10_000)
2144            .ok_or(RiskError::Overflow)?;
2145
2146        self.funding_index_qpb_e6 = self
2147            .funding_index_qpb_e6
2148            .checked_add(delta)
2149            .ok_or(RiskError::Overflow)?;
2150
2151        self.last_funding_slot = now_slot;
2152        Ok(())
2153    }
2154
2155    /// Set the funding rate for the NEXT interval (anti-retroactivity).
2156    ///
2157    /// MUST be called AFTER `accrue_funding()` to ensure the old rate is applied to
2158    /// the elapsed interval before storing the new rate.
2159    ///
2160    /// This implements the "rate-change rule" from the spec: state changes at slot t
2161    /// can only affect funding for slots >= t.
2162    pub fn set_funding_rate_for_next_interval(&mut self, new_rate_bps_per_slot: i64) {
2163        self.funding_rate_bps_per_slot_last = new_rate_bps_per_slot;
2164    }
2165
2166    /// Convenience: Set rate then accrue in one call.
2167    ///
2168    /// This sets the rate for the interval being accrued, then accrues.
2169    /// For proper anti-retroactivity in production, the rate should be set at the
2170    /// START of an interval via `set_funding_rate_for_next_interval`, then accrued later.
2171    pub fn accrue_funding_with_rate(
2172        &mut self,
2173        now_slot: u64,
2174        oracle_price: u64,
2175        funding_rate_bps_per_slot: i64,
2176    ) -> Result<()> {
2177        self.set_funding_rate_for_next_interval(funding_rate_bps_per_slot);
2178        self.accrue_funding(now_slot, oracle_price)
2179    }
2180
2181    /// Settle funding for an account (lazy update).
2182    /// Uses set_pnl helper to maintain pnl_pos_tot aggregate (spec §4.2).
2183    fn settle_account_funding(&mut self, idx: usize) -> Result<()> {
2184        let global_fi = self.funding_index_qpb_e6;
2185        let account = &self.accounts[idx];
2186        let delta_f = global_fi
2187            .get()
2188            .checked_sub(account.funding_index.get())
2189            .ok_or(RiskError::Overflow)?;
2190
2191        if delta_f != 0 && !account.position_size.is_zero() {
2192            // payment = position × ΔF / 1e6
2193            // Round UP for positive payments (account pays), truncate for negative (account receives)
2194            // This ensures vault always has at least what's owed (one-sided conservation slack).
2195            let raw = account
2196                .position_size
2197                .get()
2198                .checked_mul(delta_f)
2199                .ok_or(RiskError::Overflow)?;
2200
2201            let payment = if raw > 0 {
2202                // Account is paying: round UP to ensure vault gets at least theoretical amount
2203                raw.checked_add(999_999)
2204                    .ok_or(RiskError::Overflow)?
2205                    .checked_div(1_000_000)
2206                    .ok_or(RiskError::Overflow)?
2207            } else {
2208                // Account is receiving: truncate towards zero to give at most theoretical amount
2209                raw.checked_div(1_000_000).ok_or(RiskError::Overflow)?
2210            };
2211
2212            // Longs pay when funding positive: pnl -= payment
2213            // Use set_pnl helper to maintain pnl_pos_tot aggregate (spec §4.2)
2214            let new_pnl = self.accounts[idx]
2215                .pnl
2216                .get()
2217                .checked_sub(payment)
2218                .ok_or(RiskError::Overflow)?;
2219            self.set_pnl(idx, new_pnl);
2220        }
2221
2222        self.accounts[idx].funding_index = global_fi;
2223        Ok(())
2224    }
2225
2226    /// Touch an account (settle funding before operations)
2227    pub fn touch_account(&mut self, idx: u16) -> Result<()> {
2228        if !self.is_used(idx as usize) {
2229            return Err(RiskError::AccountNotFound);
2230        }
2231
2232        self.settle_account_funding(idx as usize)
2233    }
2234
2235    /// Settle mark-to-market PnL to the current oracle price (variation margin).
2236    ///
2237    /// This realizes all unrealized PnL at the given oracle price and resets
2238    /// entry_price = oracle_price. After calling this, mark_pnl_for_position
2239    /// will return 0 for this account at this oracle price.
2240    ///
2241    /// This makes positions fungible: any LP can close any user's position
2242    /// because PnL is settled to a common reference price.
2243    pub fn settle_mark_to_oracle(&mut self, idx: u16, oracle_price: u64) -> Result<()> {
2244        if idx as usize >= MAX_ACCOUNTS || !self.is_used(idx as usize) {
2245            return Err(RiskError::AccountNotFound);
2246        }
2247
2248        if self.accounts[idx as usize].position_size.is_zero() {
2249            // No position: just set entry to oracle for determinism
2250            self.accounts[idx as usize].entry_price = oracle_price;
2251            return Ok(());
2252        }
2253
2254        // Compute mark PnL at current oracle
2255        let mark = Self::mark_pnl_for_position(
2256            self.accounts[idx as usize].position_size.get(),
2257            self.accounts[idx as usize].entry_price,
2258            oracle_price,
2259        )?;
2260
2261        // Realize the mark PnL via set_pnl (maintains pnl_pos_tot)
2262        let new_pnl = self.accounts[idx as usize]
2263            .pnl
2264            .get()
2265            .checked_add(mark)
2266            .ok_or(RiskError::Overflow)?;
2267        self.set_pnl(idx as usize, new_pnl);
2268
2269        // Reset entry to oracle (mark PnL is now 0 at this price)
2270        self.accounts[idx as usize].entry_price = oracle_price;
2271
2272        Ok(())
2273    }
2274
2275    /// Best-effort mark-to-oracle settlement that uses saturating_add instead of
2276    /// checked_add, so it never fails on overflow.  This prevents the liquidation
2277    /// path from wedging on extreme mark PnL values.
2278    fn settle_mark_to_oracle_best_effort(&mut self, idx: u16, oracle_price: u64) -> Result<()> {
2279        if idx as usize >= MAX_ACCOUNTS || !self.is_used(idx as usize) {
2280            return Err(RiskError::AccountNotFound);
2281        }
2282
2283        if self.accounts[idx as usize].position_size.is_zero() {
2284            self.accounts[idx as usize].entry_price = oracle_price;
2285            return Ok(());
2286        }
2287
2288        // Compute mark PnL at current oracle
2289        let mark = Self::mark_pnl_for_position(
2290            self.accounts[idx as usize].position_size.get(),
2291            self.accounts[idx as usize].entry_price,
2292            oracle_price,
2293        )?;
2294
2295        // Realize the mark PnL via set_pnl (saturating — never fails on overflow)
2296        let new_pnl = self.accounts[idx as usize].pnl.get().saturating_add(mark);
2297        self.set_pnl(idx as usize, new_pnl);
2298
2299        // Reset entry to oracle (mark PnL is now 0 at this price)
2300        self.accounts[idx as usize].entry_price = oracle_price;
2301
2302        Ok(())
2303    }
2304
2305    /// Full account touch: funding + mark settlement + maintenance fees + warmup.
2306    /// This is the standard "lazy settlement" path called on every user operation.
2307    /// Triggers liquidation check if fees push account below maintenance margin.
2308    pub fn touch_account_full(&mut self, idx: u16, now_slot: u64, oracle_price: u64) -> Result<()> {
2309        // Update current_slot for consistent warmup/bookkeeping
2310        self.current_slot = now_slot;
2311
2312        // 1. Settle funding
2313        self.touch_account(idx)?;
2314
2315        // 2. Settle mark-to-market (variation margin)
2316        // Per spec §5.4: if AvailGross increases, warmup must restart.
2317        // Capture old AvailGross before mark settlement.
2318        let old_avail_gross = {
2319            let pnl = self.accounts[idx as usize].pnl.get();
2320            if pnl > 0 {
2321                (pnl as u128).saturating_sub(self.accounts[idx as usize].reserved_pnl as u128)
2322            } else {
2323                0
2324            }
2325        };
2326        self.settle_mark_to_oracle(idx, oracle_price)?;
2327        // If AvailGross increased, update warmup slope (restarts warmup timer)
2328        let new_avail_gross = {
2329            let pnl = self.accounts[idx as usize].pnl.get();
2330            if pnl > 0 {
2331                (pnl as u128).saturating_sub(self.accounts[idx as usize].reserved_pnl as u128)
2332            } else {
2333                0
2334            }
2335        };
2336        if new_avail_gross > old_avail_gross {
2337            self.update_warmup_slope(idx)?;
2338        }
2339
2340        // 3. Settle maintenance fees (may trigger undercollateralized error)
2341        self.settle_maintenance_fee(idx, now_slot, oracle_price)?;
2342
2343        // 4. Settle warmup (convert warmed PnL to capital, realize losses)
2344        self.settle_warmup_to_capital(idx)?;
2345
2346        // 5. Sweep any fee debt from newly-available capital (warmup may
2347        //    have created capital that should pay outstanding fee debt)
2348        self.pay_fee_debt_from_capital(idx);
2349
2350        // 6. Re-check maintenance margin after fee debt sweep
2351        if !self.accounts[idx as usize].position_size.is_zero() {
2352            if !self.is_above_maintenance_margin_mtm(
2353                &self.accounts[idx as usize],
2354                oracle_price,
2355            ) {
2356                return Err(RiskError::Undercollateralized);
2357            }
2358        }
2359
2360        Ok(())
2361    }
2362
2363    /// Minimal touch for crank liquidations: funding + maintenance only.
2364    /// Skips warmup settlement for performance - losses are handled inline
2365    /// by the deferred close helpers, positive warmup left for user ops.
2366    fn touch_account_for_crank(
2367        &mut self,
2368        idx: u16,
2369        now_slot: u64,
2370        oracle_price: u64,
2371    ) -> Result<()> {
2372        // 1. Settle funding
2373        self.touch_account(idx)?;
2374
2375        // 2. Settle maintenance fees (may trigger undercollateralized error)
2376        self.settle_maintenance_fee(idx, now_slot, oracle_price)?;
2377
2378        // NOTE: No warmup settlement - handled inline for losses in close helpers
2379        Ok(())
2380    }
2381
2382    // ========================================
2383    // Deposits and Withdrawals
2384    // ========================================
2385
2386    /// Deposit funds to account.
2387    ///
2388    /// Settles any accrued maintenance fees from the deposit first,
2389    /// with the remainder added to capital. This ensures fee conservation
2390    /// (fees are never forgiven) and prevents stuck accounts.
2391    pub fn deposit(&mut self, idx: u16, amount: u128, now_slot: u64) -> Result<()> {
2392        // Update current_slot so warmup/bookkeeping progresses consistently
2393        self.current_slot = now_slot;
2394
2395        if !self.is_used(idx as usize) {
2396            return Err(RiskError::AccountNotFound);
2397        }
2398
2399        let account = &mut self.accounts[idx as usize];
2400        let mut deposit_remaining = amount;
2401
2402        // Calculate and settle accrued fees
2403        let dt = now_slot.saturating_sub(account.last_fee_slot);
2404        if dt > 0 {
2405            let due = self
2406                .params
2407                .maintenance_fee_per_slot
2408                .get()
2409                .saturating_mul(dt as u128);
2410            account.last_fee_slot = now_slot;
2411
2412            // Deduct from fee_credits (coupon: no insurance booking here —
2413            // insurance was already paid when credits were granted)
2414            account.fee_credits = account.fee_credits.saturating_sub(due as i128);
2415        }
2416
2417        // Pay any owed fees from deposit first
2418        if account.fee_credits.is_negative() {
2419            let owed = neg_i128_to_u128(account.fee_credits.get());
2420            let pay = core::cmp::min(owed, deposit_remaining);
2421
2422            deposit_remaining -= pay;
2423            self.insurance_fund.balance = self.insurance_fund.balance + pay;
2424            self.insurance_fund.fee_revenue = self.insurance_fund.fee_revenue + pay;
2425
2426            // Credit back what was paid
2427            account.fee_credits = account.fee_credits.saturating_add(pay as i128);
2428        }
2429
2430        // Vault gets full deposit (tokens received)
2431        self.vault = U128::new(add_u128(self.vault.get(), amount));
2432
2433        // Capital gets remainder after fees (via set_capital to maintain c_tot)
2434        let new_cap = add_u128(self.accounts[idx as usize].capital.get(), deposit_remaining);
2435        self.set_capital(idx as usize, new_cap);
2436
2437        // Settle warmup after deposit (allows losses to be paid promptly if underwater)
2438        self.settle_warmup_to_capital(idx)?;
2439
2440        // If any older fee debt remains, use capital to pay it now.
2441        self.pay_fee_debt_from_capital(idx);
2442
2443        Ok(())
2444    }
2445
2446    /// Withdraw capital from an account.
2447    /// Relies on Solana transaction atomicity: if this returns Err, the entire TX aborts.
2448    pub fn withdraw(
2449        &mut self,
2450        idx: u16,
2451        amount: u128,
2452        now_slot: u64,
2453        oracle_price: u64,
2454    ) -> Result<()> {
2455        // Update current_slot so warmup/bookkeeping progresses consistently
2456        self.current_slot = now_slot;
2457
2458        // Validate oracle price bounds (prevents overflow in mark_pnl calculations)
2459        if oracle_price == 0 || oracle_price > MAX_ORACLE_PRICE {
2460            return Err(RiskError::Overflow);
2461        }
2462
2463        // Require fresh crank (time-based) before state-changing operations
2464        self.require_fresh_crank(now_slot)?;
2465
2466        // Require recent full sweep started
2467        self.require_recent_full_sweep(now_slot)?;
2468
2469        // Validate account exists
2470        if !self.is_used(idx as usize) {
2471            return Err(RiskError::AccountNotFound);
2472        }
2473
2474        // Full settlement: funding + maintenance fees + warmup
2475        self.touch_account_full(idx, now_slot, oracle_price)?;
2476
2477        // Read account state (scope the borrow)
2478        let (old_capital, pnl, position_size, entry_price, fee_credits) = {
2479            let account = &self.accounts[idx as usize];
2480            (
2481                account.capital,
2482                account.pnl,
2483                account.position_size,
2484                account.entry_price,
2485                account.fee_credits,
2486            )
2487        };
2488
2489        // Check we have enough capital
2490        if old_capital.get() < amount {
2491            return Err(RiskError::InsufficientBalance);
2492        }
2493
2494        // Calculate MTM equity after withdrawal with haircut (spec §3.3)
2495        // equity_mtm = max(0, new_capital + min(pnl, 0) + effective_pos_pnl(pnl) + mark_pnl)
2496        // Fail-safe: if mark_pnl overflows (corrupted entry_price/position_size), treat as 0 equity
2497        let new_capital = sub_u128(old_capital.get(), amount);
2498        let new_equity_mtm = {
2499            let eq = match Self::mark_pnl_for_position(position_size.get(), entry_price, oracle_price)
2500            {
2501                Ok(mark_pnl) => {
2502                    let cap_i = u128_to_i128_clamped(new_capital);
2503                    let neg_pnl = core::cmp::min(pnl.get(), 0);
2504                    let eff_pos = self.effective_pos_pnl(pnl.get());
2505                    let new_eq_i = cap_i
2506                        .saturating_add(neg_pnl)
2507                        .saturating_add(u128_to_i128_clamped(eff_pos))
2508                        .saturating_add(mark_pnl);
2509                    if new_eq_i > 0 {
2510                        new_eq_i as u128
2511                    } else {
2512                        0
2513                    }
2514                }
2515                Err(_) => 0, // Overflow => worst-case equity => will fail margin check below
2516            };
2517            // Subtract fee debt (negative fee_credits = unpaid maintenance fees)
2518            let fee_debt = if fee_credits.is_negative() {
2519                neg_i128_to_u128(fee_credits.get())
2520            } else {
2521                0
2522            };
2523            eq.saturating_sub(fee_debt)
2524        };
2525
2526        // If account has position, must maintain initial margin at ORACLE price (MTM check)
2527        // This prevents withdrawing to a state that's immediately liquidatable
2528        if !position_size.is_zero() {
2529            let position_notional = mul_u128(
2530                saturating_abs_i128(position_size.get()) as u128,
2531                oracle_price as u128,
2532            ) / 1_000_000;
2533
2534            let initial_margin_required =
2535                mul_u128(position_notional, self.params.initial_margin_bps as u128) / 10_000;
2536
2537            if new_equity_mtm < initial_margin_required {
2538                return Err(RiskError::Undercollateralized);
2539            }
2540        }
2541
2542        // Commit the withdrawal (via set_capital to maintain c_tot)
2543        self.set_capital(idx as usize, new_capital);
2544        self.vault = U128::new(sub_u128(self.vault.get(), amount));
2545
2546        // Post-withdrawal MTM maintenance margin check at oracle price
2547        // This is a safety belt to ensure we never leave an account in liquidatable state
2548        if !self.accounts[idx as usize].position_size.is_zero() {
2549            if !self.is_above_maintenance_margin_mtm(&self.accounts[idx as usize], oracle_price) {
2550                // Revert the withdrawal (via set_capital to maintain c_tot)
2551                self.set_capital(idx as usize, old_capital.get());
2552                self.vault = U128::new(add_u128(self.vault.get(), amount));
2553                return Err(RiskError::Undercollateralized);
2554            }
2555        }
2556
2557        // Regression assert: after settle + withdraw, negative PnL should have been settled
2558        #[cfg(any(test, kani))]
2559        debug_assert!(
2560            !self.accounts[idx as usize].pnl.is_negative()
2561                || self.accounts[idx as usize].capital.is_zero(),
2562            "Withdraw: negative PnL must settle immediately"
2563        );
2564
2565        Ok(())
2566    }
2567
2568    // ========================================
2569    // Trading
2570    // ========================================
2571
2572    /// Realized-only equity: max(0, capital + realized_pnl).
2573    ///
2574    /// DEPRECATED for margin checks: Use account_equity_mtm_at_oracle instead.
2575    /// This helper is retained for reporting, PnL display, and test assertions that
2576    /// specifically need realized-only equity.
2577    #[inline]
2578    pub fn account_equity(&self, account: &Account) -> u128 {
2579        let cap_i = u128_to_i128_clamped(account.capital.get());
2580        let eq_i = cap_i.saturating_add(account.pnl.get());
2581        if eq_i > 0 {
2582            eq_i as u128
2583        } else {
2584            0
2585        }
2586    }
2587
2588    /// Mark-to-market equity at oracle price with haircut (the ONLY correct equity for margin checks).
2589    /// equity_mtm = max(0, C_i + min(PNL_i, 0) + PNL_eff_pos_i + mark_pnl)
2590    /// where PNL_eff_pos_i = floor(max(PNL_i, 0) * h_num / h_den) per spec §3.3.
2591    ///
2592    /// FAIL-SAFE: On overflow, returns 0 (worst-case equity) to ensure liquidation
2593    /// can still trigger. This prevents overflow from blocking liquidation.
2594    pub fn account_equity_mtm_at_oracle(&self, account: &Account, oracle_price: u64) -> u128 {
2595        let mark = match Self::mark_pnl_for_position(
2596            account.position_size.get(),
2597            account.entry_price,
2598            oracle_price,
2599        ) {
2600            Ok(m) => m,
2601            Err(_) => return 0, // Overflow => worst-case equity
2602        };
2603        let cap_i = u128_to_i128_clamped(account.capital.get());
2604        let neg_pnl = core::cmp::min(account.pnl.get(), 0);
2605        let eff_pos = self.effective_pos_pnl(account.pnl.get());
2606        let eq_i = cap_i
2607            .saturating_add(neg_pnl)
2608            .saturating_add(u128_to_i128_clamped(eff_pos))
2609            .saturating_add(mark);
2610        let eq = if eq_i > 0 { eq_i as u128 } else { 0 };
2611        // Subtract fee debt (negative fee_credits = unpaid maintenance fees)
2612        let fee_debt = if account.fee_credits.is_negative() {
2613            neg_i128_to_u128(account.fee_credits.get())
2614        } else {
2615            0
2616        };
2617        eq.saturating_sub(fee_debt)
2618    }
2619
2620    /// MTM margin check: is equity_mtm > required margin?
2621    /// This is the ONLY correct margin predicate for all risk checks.
2622    ///
2623    /// FAIL-SAFE: Returns false on any error (treat as below margin / liquidatable).
2624    pub fn is_above_margin_bps_mtm(&self, account: &Account, oracle_price: u64, bps: u64) -> bool {
2625        let equity = self.account_equity_mtm_at_oracle(account, oracle_price);
2626
2627        // Position value at oracle price
2628        let position_value = mul_u128(
2629            saturating_abs_i128(account.position_size.get()) as u128,
2630            oracle_price as u128,
2631        ) / 1_000_000;
2632
2633        // Margin requirement at given bps
2634        let margin_required = mul_u128(position_value, bps as u128) / 10_000;
2635
2636        equity > margin_required
2637    }
2638
2639    /// MTM maintenance margin check (fail-safe: returns false on overflow)
2640    #[inline]
2641    pub fn is_above_maintenance_margin_mtm(&self, account: &Account, oracle_price: u64) -> bool {
2642        self.is_above_margin_bps_mtm(account, oracle_price, self.params.maintenance_margin_bps)
2643    }
2644
2645    /// Cheap priority score for ranking liquidation candidates.
2646    /// Score = max(maint_required - equity, 0).
2647    /// Higher score = more urgent to liquidate.
2648    ///
2649    /// This is a ranking heuristic only - NOT authoritative.
2650    /// Real liquidation still calls touch_account_full() and checks margin properly.
2651    /// A "wrong" top-K pick is harmless: it just won't liquidate.
2652    #[inline]
2653    fn liq_priority_score(&self, a: &Account, oracle_price: u64) -> u128 {
2654        if a.position_size.is_zero() {
2655            return 0;
2656        }
2657
2658        // MTM equity (fail-safe: overflow returns 0, making account appear liquidatable)
2659        let equity = self.account_equity_mtm_at_oracle(a, oracle_price);
2660
2661        let pos_value = mul_u128(
2662            saturating_abs_i128(a.position_size.get()) as u128,
2663            oracle_price as u128,
2664        ) / 1_000_000;
2665
2666        let maint = mul_u128(pos_value, self.params.maintenance_margin_bps as u128) / 10_000;
2667
2668        if equity >= maint {
2669            0
2670        } else {
2671            maint - equity
2672        }
2673    }
2674
2675    /// Risk-reduction-only mode is entered when the system is in deficit. Warmups are frozen so pending PNL cannot become principal. Withdrawals of principal (capital) are allowed (subject to margin). Risk-increasing actions are blocked; only risk-reducing/neutral operations are allowed.
2676    /// Execute a trade between LP and user.
2677    /// Relies on Solana transaction atomicity: if this returns Err, the entire TX aborts.
2678    pub fn execute_trade<M: MatchingEngine>(
2679        &mut self,
2680        matcher: &M,
2681        lp_idx: u16,
2682        user_idx: u16,
2683        now_slot: u64,
2684        oracle_price: u64,
2685        size: i128,
2686    ) -> Result<()> {
2687        // Update current_slot so warmup/bookkeeping progresses consistently
2688        self.current_slot = now_slot;
2689
2690        // Require fresh crank (time-based) before state-changing operations
2691        self.require_fresh_crank(now_slot)?;
2692
2693        // Validate indices
2694        if !self.is_used(lp_idx as usize) || !self.is_used(user_idx as usize) {
2695            return Err(RiskError::AccountNotFound);
2696        }
2697
2698        // Validate oracle price bounds (prevents overflow in mark_pnl calculations)
2699        if oracle_price == 0 || oracle_price > MAX_ORACLE_PRICE {
2700            return Err(RiskError::Overflow);
2701        }
2702
2703        // Validate requested size bounds
2704        if size == 0 || size == i128::MIN {
2705            return Err(RiskError::Overflow);
2706        }
2707        if saturating_abs_i128(size) as u128 > MAX_POSITION_ABS {
2708            return Err(RiskError::Overflow);
2709        }
2710
2711        // Validate account kinds (using is_lp/is_user methods for SBF workaround)
2712        if !self.accounts[lp_idx as usize].is_lp() {
2713            return Err(RiskError::AccountKindMismatch);
2714        }
2715        if !self.accounts[user_idx as usize].is_user() {
2716            return Err(RiskError::AccountKindMismatch);
2717        }
2718
2719        // Check if trade increases risk (absolute exposure for either party)
2720        let old_user_pos = self.accounts[user_idx as usize].position_size.get();
2721        let old_lp_pos = self.accounts[lp_idx as usize].position_size.get();
2722        let new_user_pos = old_user_pos.saturating_add(size);
2723        let new_lp_pos = old_lp_pos.saturating_sub(size);
2724
2725        let user_inc = saturating_abs_i128(new_user_pos) > saturating_abs_i128(old_user_pos);
2726        let lp_inc = saturating_abs_i128(new_lp_pos) > saturating_abs_i128(old_lp_pos);
2727
2728        if user_inc || lp_inc {
2729            // Risk-increasing: require recent full sweep
2730            self.require_recent_full_sweep(now_slot)?;
2731        }
2732
2733        // Call matching engine
2734        let lp = &self.accounts[lp_idx as usize];
2735        let execution = matcher.execute_match(
2736            &lp.matcher_program,
2737            &lp.matcher_context,
2738            lp.account_id,
2739            oracle_price,
2740            size,
2741        )?;
2742
2743        let exec_price = execution.price;
2744        let exec_size = execution.size;
2745
2746        // Validate matcher output (trust boundary enforcement)
2747        // Price bounds
2748        if exec_price == 0 || exec_price > MAX_ORACLE_PRICE {
2749            return Err(RiskError::InvalidMatchingEngine);
2750        }
2751
2752        // Size bounds
2753        if exec_size == 0 {
2754            // No fill: treat as no-op trade (no side effects, deterministic)
2755            return Ok(());
2756        }
2757        if exec_size == i128::MIN {
2758            return Err(RiskError::InvalidMatchingEngine);
2759        }
2760        if saturating_abs_i128(exec_size) as u128 > MAX_POSITION_ABS {
2761            return Err(RiskError::InvalidMatchingEngine);
2762        }
2763
2764        // Must be same direction as requested
2765        if (exec_size > 0) != (size > 0) {
2766            return Err(RiskError::InvalidMatchingEngine);
2767        }
2768
2769        // Must be partial fill at most (abs(exec) <= abs(request))
2770        if saturating_abs_i128(exec_size) > saturating_abs_i128(size) {
2771            return Err(RiskError::InvalidMatchingEngine);
2772        }
2773
2774        // Settle funding, mark-to-market, and maintenance fees for both accounts
2775        // Mark settlement MUST happen before position changes (variation margin)
2776        // Note: warmup is settled at the END after trade PnL is generated
2777        self.touch_account(user_idx)?;
2778        self.touch_account(lp_idx)?;
2779
2780        // Per spec §5.4: if AvailGross increases from mark settlement, warmup must restart.
2781        // Capture old AvailGross before mark settlement for both accounts.
2782        let user_old_avail = {
2783            let pnl = self.accounts[user_idx as usize].pnl.get();
2784            if pnl > 0 { (pnl as u128).saturating_sub(self.accounts[user_idx as usize].reserved_pnl as u128) } else { 0 }
2785        };
2786        let lp_old_avail = {
2787            let pnl = self.accounts[lp_idx as usize].pnl.get();
2788            if pnl > 0 { (pnl as u128).saturating_sub(self.accounts[lp_idx as usize].reserved_pnl as u128) } else { 0 }
2789        };
2790        self.settle_mark_to_oracle(user_idx, oracle_price)?;
2791        self.settle_mark_to_oracle(lp_idx, oracle_price)?;
2792        // If AvailGross increased from mark settlement, update warmup slope (restarts warmup)
2793        let user_new_avail = {
2794            let pnl = self.accounts[user_idx as usize].pnl.get();
2795            if pnl > 0 { (pnl as u128).saturating_sub(self.accounts[user_idx as usize].reserved_pnl as u128) } else { 0 }
2796        };
2797        let lp_new_avail = {
2798            let pnl = self.accounts[lp_idx as usize].pnl.get();
2799            if pnl > 0 { (pnl as u128).saturating_sub(self.accounts[lp_idx as usize].reserved_pnl as u128) } else { 0 }
2800        };
2801        if user_new_avail > user_old_avail {
2802            self.update_warmup_slope(user_idx)?;
2803        }
2804        if lp_new_avail > lp_old_avail {
2805            self.update_warmup_slope(lp_idx)?;
2806        }
2807
2808        self.settle_maintenance_fee(user_idx, now_slot, oracle_price)?;
2809        self.settle_maintenance_fee(lp_idx, now_slot, oracle_price)?;
2810
2811        // Calculate fee (ceiling division to prevent micro-trade fee evasion)
2812        let notional =
2813            mul_u128(saturating_abs_i128(exec_size) as u128, exec_price as u128) / 1_000_000;
2814        let fee = if notional > 0 && self.params.trading_fee_bps > 0 {
2815            // Ceiling division: ensures at least 1 atomic unit fee for any real trade
2816            (mul_u128(notional, self.params.trading_fee_bps as u128) + 9999) / 10_000
2817        } else {
2818            0
2819        };
2820
2821        // Access both accounts
2822        let (user, lp) = if user_idx < lp_idx {
2823            let (left, right) = self.accounts.split_at_mut(lp_idx as usize);
2824            (&mut left[user_idx as usize], &mut right[0])
2825        } else {
2826            let (left, right) = self.accounts.split_at_mut(user_idx as usize);
2827            (&mut right[0], &mut left[lp_idx as usize])
2828        };
2829
2830        // Calculate new positions (checked math - overflow returns Err)
2831        let new_user_position = user
2832            .position_size
2833            .get()
2834            .checked_add(exec_size)
2835            .ok_or(RiskError::Overflow)?;
2836        let new_lp_position = lp
2837            .position_size
2838            .get()
2839            .checked_sub(exec_size)
2840            .ok_or(RiskError::Overflow)?;
2841
2842        // Validate final position bounds (prevents overflow in mark_pnl calculations)
2843        if saturating_abs_i128(new_user_position) as u128 > MAX_POSITION_ABS
2844            || saturating_abs_i128(new_lp_position) as u128 > MAX_POSITION_ABS
2845        {
2846            return Err(RiskError::Overflow);
2847        }
2848
2849        // Trade PnL = (oracle - exec_price) * exec_size (zero-sum between parties)
2850        // User gains if buying below oracle (exec_size > 0, oracle > exec_price)
2851        // LP gets opposite sign
2852        // Note: entry_price is already oracle_price after settle_mark_to_oracle
2853        let price_diff = (oracle_price as i128)
2854            .checked_sub(exec_price as i128)
2855            .ok_or(RiskError::Overflow)?;
2856
2857        let trade_pnl = price_diff
2858            .checked_mul(exec_size)
2859            .ok_or(RiskError::Overflow)?
2860            .checked_div(1_000_000)
2861            .ok_or(RiskError::Overflow)?;
2862
2863        // Compute final PNL values (checked math - overflow returns Err)
2864        let new_user_pnl = user
2865            .pnl
2866            .get()
2867            .checked_add(trade_pnl)
2868            .ok_or(RiskError::Overflow)?;
2869        let new_lp_pnl = lp
2870            .pnl
2871            .get()
2872            .checked_sub(trade_pnl)
2873            .ok_or(RiskError::Overflow)?;
2874
2875        // Deduct trading fee from user capital, not PnL (spec §8.1)
2876        let new_user_capital = user
2877            .capital
2878            .get()
2879            .checked_sub(fee)
2880            .ok_or(RiskError::InsufficientBalance)?;
2881
2882        // Compute projected pnl_pos_tot AFTER trade PnL for fresh haircut in margin checks.
2883        // Can't call self.haircut_ratio() due to split_at_mut borrow on accounts;
2884        // inline the delta computation and haircut formula.
2885        let old_user_pnl_pos = if user.pnl.get() > 0 { user.pnl.get() as u128 } else { 0 };
2886        let new_user_pnl_pos = if new_user_pnl > 0 { new_user_pnl as u128 } else { 0 };
2887        let old_lp_pnl_pos = if lp.pnl.get() > 0 { lp.pnl.get() as u128 } else { 0 };
2888        let new_lp_pnl_pos = if new_lp_pnl > 0 { new_lp_pnl as u128 } else { 0 };
2889
2890        // Recompute haircut using projected post-trade pnl_pos_tot (spec §3.3).
2891        // Fee moves C→I so Residual = V - C_tot - I is unchanged; only pnl_pos_tot changes.
2892        let projected_pnl_pos_tot = self.pnl_pos_tot
2893            .get()
2894            .saturating_add(new_user_pnl_pos)
2895            .saturating_sub(old_user_pnl_pos)
2896            .saturating_add(new_lp_pnl_pos)
2897            .saturating_sub(old_lp_pnl_pos);
2898
2899        let (h_num, h_den) = if projected_pnl_pos_tot == 0 {
2900            (1u128, 1u128)
2901        } else {
2902            let residual = self.vault.get()
2903                .saturating_sub(self.c_tot.get())
2904                .saturating_sub(self.insurance_fund.balance.get());
2905            (core::cmp::min(residual, projected_pnl_pos_tot), projected_pnl_pos_tot)
2906        };
2907
2908        // Inline helper: compute effective positive PnL with post-trade haircut
2909        let eff_pos_pnl_inline = |pnl: i128| -> u128 {
2910            if pnl <= 0 {
2911                return 0;
2912            }
2913            let pos_pnl = pnl as u128;
2914            if h_den == 0 {
2915                return pos_pnl;
2916            }
2917            mul_u128(pos_pnl, h_num) / h_den
2918        };
2919
2920        // Check user margin with haircut (spec §3.3, §10.4 step 7)
2921        // After settle_mark_to_oracle, entry_price = oracle_price, so mark_pnl = 0
2922        // Equity = max(0, new_capital + min(pnl, 0) + eff_pos_pnl)
2923        // Use initial margin if risk-increasing, maintenance margin otherwise
2924        if new_user_position != 0 {
2925            let user_cap_i = u128_to_i128_clamped(new_user_capital);
2926            let neg_pnl = core::cmp::min(new_user_pnl, 0);
2927            let eff_pos = eff_pos_pnl_inline(new_user_pnl);
2928            let user_eq_i = user_cap_i
2929                .saturating_add(neg_pnl)
2930                .saturating_add(u128_to_i128_clamped(eff_pos));
2931            let user_equity = if user_eq_i > 0 { user_eq_i as u128 } else { 0 };
2932            // Subtract fee debt (negative fee_credits = unpaid maintenance fees)
2933            let user_fee_debt = if user.fee_credits.is_negative() {
2934                neg_i128_to_u128(user.fee_credits.get())
2935            } else {
2936                0
2937            };
2938            let user_equity = user_equity.saturating_sub(user_fee_debt);
2939            let position_value = mul_u128(
2940                saturating_abs_i128(new_user_position) as u128,
2941                oracle_price as u128,
2942            ) / 1_000_000;
2943            // Risk-increasing if |new_pos| > |old_pos| OR position crosses zero (flip)
2944            // A flip is semantically a close + open, so the new side must meet initial margin
2945            let old_user_pos = user.position_size.get();
2946            let old_user_pos_abs = saturating_abs_i128(old_user_pos);
2947            let new_user_pos_abs = saturating_abs_i128(new_user_position);
2948            let user_crosses_zero =
2949                (old_user_pos > 0 && new_user_position < 0) || (old_user_pos < 0 && new_user_position > 0);
2950            let user_risk_increasing = new_user_pos_abs > old_user_pos_abs || user_crosses_zero;
2951            let margin_bps = if user_risk_increasing {
2952                self.params.initial_margin_bps
2953            } else {
2954                self.params.maintenance_margin_bps
2955            };
2956            let margin_required = mul_u128(position_value, margin_bps as u128) / 10_000;
2957            if user_equity <= margin_required {
2958                return Err(RiskError::Undercollateralized);
2959            }
2960        }
2961
2962        // Check LP margin with haircut (spec §3.3, §10.4 step 7)
2963        // After settle_mark_to_oracle, entry_price = oracle_price, so mark_pnl = 0
2964        // Use initial margin if risk-increasing, maintenance margin otherwise
2965        if new_lp_position != 0 {
2966            let lp_cap_i = u128_to_i128_clamped(lp.capital.get());
2967            let neg_pnl = core::cmp::min(new_lp_pnl, 0);
2968            let eff_pos = eff_pos_pnl_inline(new_lp_pnl);
2969            let lp_eq_i = lp_cap_i
2970                .saturating_add(neg_pnl)
2971                .saturating_add(u128_to_i128_clamped(eff_pos));
2972            let lp_equity = if lp_eq_i > 0 { lp_eq_i as u128 } else { 0 };
2973            // Subtract fee debt (negative fee_credits = unpaid maintenance fees)
2974            let lp_fee_debt = if lp.fee_credits.is_negative() {
2975                neg_i128_to_u128(lp.fee_credits.get())
2976            } else {
2977                0
2978            };
2979            let lp_equity = lp_equity.saturating_sub(lp_fee_debt);
2980            let position_value = mul_u128(
2981                saturating_abs_i128(new_lp_position) as u128,
2982                oracle_price as u128,
2983            ) / 1_000_000;
2984            // Risk-increasing if |new_pos| > |old_pos| OR position crosses zero (flip)
2985            // A flip is semantically a close + open, so the new side must meet initial margin
2986            let old_lp_pos = lp.position_size.get();
2987            let old_lp_pos_abs = saturating_abs_i128(old_lp_pos);
2988            let new_lp_pos_abs = saturating_abs_i128(new_lp_position);
2989            let lp_crosses_zero =
2990                (old_lp_pos > 0 && new_lp_position < 0) || (old_lp_pos < 0 && new_lp_position > 0);
2991            let lp_risk_increasing = new_lp_pos_abs > old_lp_pos_abs || lp_crosses_zero;
2992            let margin_bps = if lp_risk_increasing {
2993                self.params.initial_margin_bps
2994            } else {
2995                self.params.maintenance_margin_bps
2996            };
2997            let margin_required = mul_u128(position_value, margin_bps as u128) / 10_000;
2998            if lp_equity <= margin_required {
2999                return Err(RiskError::Undercollateralized);
3000            }
3001        }
3002
3003        // Commit all state changes
3004        self.insurance_fund.fee_revenue =
3005            U128::new(add_u128(self.insurance_fund.fee_revenue.get(), fee));
3006        self.insurance_fund.balance = U128::new(add_u128(self.insurance_fund.balance.get(), fee));
3007
3008        // Credit fee to user's fee_credits (active traders earn credits that offset maintenance)
3009        user.fee_credits = user.fee_credits.saturating_add(fee as i128);
3010
3011        // §4.3 Batch update exception: Direct field assignment for performance.
3012        // All aggregate deltas (old/new pnl_pos values) computed above before assignment;
3013        // aggregates (c_tot, pnl_pos_tot) updated atomically below.
3014        user.pnl = I128::new(new_user_pnl);
3015        user.position_size = I128::new(new_user_position);
3016        user.entry_price = oracle_price;
3017        // Commit fee deduction from user capital (spec §8.1)
3018        user.capital = U128::new(new_user_capital);
3019
3020        lp.pnl = I128::new(new_lp_pnl);
3021        lp.position_size = I128::new(new_lp_position);
3022        lp.entry_price = oracle_price;
3023
3024        // §4.1, §4.2: Atomic aggregate maintenance after batch field assignments
3025        // Maintain c_tot: user capital decreased by fee
3026        self.c_tot = U128::new(self.c_tot.get().saturating_sub(fee));
3027
3028        // Maintain pnl_pos_tot aggregate
3029        self.pnl_pos_tot = U128::new(
3030            self.pnl_pos_tot
3031                .get()
3032                .saturating_add(new_user_pnl_pos)
3033                .saturating_sub(old_user_pnl_pos)
3034                .saturating_add(new_lp_pnl_pos)
3035                .saturating_sub(old_lp_pnl_pos),
3036        );
3037
3038        // Update total open interest tracking (O(1))
3039        // OI = sum of abs(position_size) across all accounts
3040        let old_oi =
3041            saturating_abs_i128(old_user_pos) as u128 + saturating_abs_i128(old_lp_pos) as u128;
3042        let new_oi = saturating_abs_i128(new_user_position) as u128
3043            + saturating_abs_i128(new_lp_position) as u128;
3044        if new_oi > old_oi {
3045            self.total_open_interest = self.total_open_interest.saturating_add(new_oi - old_oi);
3046        } else {
3047            self.total_open_interest = self.total_open_interest.saturating_sub(old_oi - new_oi);
3048        }
3049
3050        // Update LP aggregates for funding/threshold (O(1))
3051        let old_lp_abs = saturating_abs_i128(old_lp_pos) as u128;
3052        let new_lp_abs = saturating_abs_i128(new_lp_position) as u128;
3053        // net_lp_pos: delta = new - old
3054        self.net_lp_pos = self
3055            .net_lp_pos
3056            .saturating_sub(old_lp_pos)
3057            .saturating_add(new_lp_position);
3058        // lp_sum_abs: delta of abs values
3059        if new_lp_abs > old_lp_abs {
3060            self.lp_sum_abs = self.lp_sum_abs.saturating_add(new_lp_abs - old_lp_abs);
3061        } else {
3062            self.lp_sum_abs = self.lp_sum_abs.saturating_sub(old_lp_abs - new_lp_abs);
3063        }
3064        // lp_max_abs: monotone increase only (conservative upper bound)
3065        self.lp_max_abs = U128::new(self.lp_max_abs.get().max(new_lp_abs));
3066
3067        // Two-pass settlement: losses first, then profits.
3068        // This ensures the loser's capital reduction increases Residual before
3069        // the winner's profit conversion reads the haircut ratio. Without this,
3070        // the winner's matured PnL can be haircutted to 0 because Residual
3071        // hasn't been increased by the loser's loss settlement yet (Finding G).
3072        self.settle_loss_only(user_idx)?;
3073        self.settle_loss_only(lp_idx)?;
3074        // Now Residual reflects realized losses; profit conversion uses correct h.
3075        self.settle_warmup_to_capital(user_idx)?;
3076        self.settle_warmup_to_capital(lp_idx)?;
3077
3078        // Now recompute warmup slopes after PnL changes (resets started_at_slot)
3079        self.update_warmup_slope(user_idx)?;
3080        self.update_warmup_slope(lp_idx)?;
3081
3082        Ok(())
3083    }
3084    /// Settle loss only (§6.1): negative PnL pays from capital immediately.
3085    /// If PnL still negative after capital exhausted, write off via set_pnl(i, 0).
3086    /// Used in two-pass settlement to ensure all losses are realized (increasing
3087    /// Residual) before any profit conversions use the haircut ratio.
3088    pub fn settle_loss_only(&mut self, idx: u16) -> Result<()> {
3089        if !self.is_used(idx as usize) {
3090            return Err(RiskError::AccountNotFound);
3091        }
3092
3093        let pnl = self.accounts[idx as usize].pnl.get();
3094        if pnl < 0 {
3095            let need = neg_i128_to_u128(pnl);
3096            let capital = self.accounts[idx as usize].capital.get();
3097            let pay = core::cmp::min(need, capital);
3098
3099            if pay > 0 {
3100                self.set_capital(idx as usize, capital - pay);
3101                self.set_pnl(idx as usize, pnl.saturating_add(pay as i128));
3102            }
3103
3104            // Write off any remaining negative PnL (spec §6.1 step 4)
3105            if self.accounts[idx as usize].pnl.is_negative() {
3106                self.set_pnl(idx as usize, 0);
3107            }
3108        }
3109
3110        Ok(())
3111    }
3112
3113    /// Settle warmup: loss settlement + profit conversion per spec §6
3114    ///
3115    /// §6.1 Loss settlement: negative PnL pays from capital immediately.
3116    ///   If PnL still negative after capital exhausted, write off via set_pnl(i, 0).
3117    ///
3118    /// §6.2 Profit conversion: warmable gross profit converts to capital at haircut ratio h.
3119    ///   y = floor(x * h_num / h_den), where (h_num, h_den) is computed pre-conversion.
3120    pub fn settle_warmup_to_capital(&mut self, idx: u16) -> Result<()> {
3121        if !self.is_used(idx as usize) {
3122            return Err(RiskError::AccountNotFound);
3123        }
3124
3125        // §6.1 Loss settlement (negative PnL → reduce capital immediately)
3126        let pnl = self.accounts[idx as usize].pnl.get();
3127        if pnl < 0 {
3128            let need = neg_i128_to_u128(pnl);
3129            let capital = self.accounts[idx as usize].capital.get();
3130            let pay = core::cmp::min(need, capital);
3131
3132            if pay > 0 {
3133                self.set_capital(idx as usize, capital - pay);
3134                self.set_pnl(idx as usize, pnl.saturating_add(pay as i128));
3135            }
3136
3137            // Write off any remaining negative PnL (spec §6.1 step 4)
3138            if self.accounts[idx as usize].pnl.is_negative() {
3139                self.set_pnl(idx as usize, 0);
3140            }
3141        }
3142
3143        // §6.2 Profit conversion (warmup converts junior profit → protected principal)
3144        let pnl = self.accounts[idx as usize].pnl.get();
3145        if pnl > 0 {
3146            let positive_pnl = pnl as u128;
3147            let reserved = self.accounts[idx as usize].reserved_pnl as u128;
3148            let avail_gross = positive_pnl.saturating_sub(reserved);
3149
3150            // Compute warmable cap from slope and elapsed time (spec §5.3)
3151            let started_at = self.accounts[idx as usize].warmup_started_at_slot;
3152            let elapsed = self.current_slot.saturating_sub(started_at);
3153            let slope = self.accounts[idx as usize].warmup_slope_per_step.get();
3154            let cap = mul_u128(slope, elapsed as u128);
3155
3156            let x = core::cmp::min(avail_gross, cap);
3157
3158            if x > 0 {
3159                // Compute haircut ratio BEFORE modifying PnL/capital (spec §6.2)
3160                let (h_num, h_den) = self.haircut_ratio();
3161                let y = if h_den == 0 {
3162                    x
3163                } else {
3164                    mul_u128(x, h_num) / h_den
3165                };
3166
3167                // Reduce junior profit claim by x
3168                self.set_pnl(idx as usize, pnl - (x as i128));
3169                // Increase protected principal by y
3170                let new_cap = add_u128(self.accounts[idx as usize].capital.get(), y);
3171                self.set_capital(idx as usize, new_cap);
3172            }
3173
3174            // Advance warmup time base and update slope (spec §5.4)
3175            self.accounts[idx as usize].warmup_started_at_slot = self.current_slot;
3176
3177            // Recompute warmup slope per spec §5.4
3178            let new_pnl = self.accounts[idx as usize].pnl.get();
3179            let new_avail = if new_pnl > 0 {
3180                (new_pnl as u128).saturating_sub(self.accounts[idx as usize].reserved_pnl as u128)
3181            } else {
3182                0
3183            };
3184            let slope = if new_avail == 0 {
3185                0
3186            } else if self.params.warmup_period_slots > 0 {
3187                core::cmp::max(1, new_avail / (self.params.warmup_period_slots as u128))
3188            } else {
3189                new_avail
3190            };
3191            self.accounts[idx as usize].warmup_slope_per_step = U128::new(slope);
3192        }
3193
3194        Ok(())
3195    }
3196
3197    // Panic Settlement (Atomic Global Settle)
3198    // ========================================
3199
3200    /// Top up insurance fund
3201    ///
3202    /// Adds tokens to both vault and insurance fund.
3203    /// Returns true if the top-up brings insurance above the risk reduction threshold.
3204    pub fn top_up_insurance_fund(&mut self, amount: u128) -> Result<bool> {
3205        // Add to vault
3206        self.vault = U128::new(add_u128(self.vault.get(), amount));
3207
3208        // Add to insurance fund
3209        self.insurance_fund.balance =
3210            U128::new(add_u128(self.insurance_fund.balance.get(), amount));
3211
3212        // Return whether we're now above the force-realize threshold
3213        let above_threshold =
3214            self.insurance_fund.balance > self.params.risk_reduction_threshold;
3215        Ok(above_threshold)
3216    }
3217
3218
3219    // ========================================
3220    // Utilities
3221    // ========================================
3222
3223    /// Check conservation invariant (spec §3.1)
3224    ///
3225    /// Primary invariant: V >= C_tot + I
3226    ///
3227    /// Extended check: vault >= sum(capital) + sum(positive_pnl_clamped) + insurance
3228    /// with bounded rounding slack from funding/mark settlement.
3229    ///
3230    /// We also verify the full accounting identity including settled/unsettled PnL:
3231    /// vault >= sum(capital) + sum(settled_pnl + mark_pnl) + insurance
3232    /// The difference (slack) must be bounded by MAX_ROUNDING_SLACK.
3233    pub fn check_conservation(&self, oracle_price: u64) -> bool {
3234        let mut total_capital = 0u128;
3235        let mut net_pnl: i128 = 0;
3236        let mut net_mark: i128 = 0;
3237        let mut mark_ok = true;
3238        let global_index = self.funding_index_qpb_e6;
3239
3240        self.for_each_used(|_idx, account| {
3241            total_capital = add_u128(total_capital, account.capital.get());
3242
3243            // Compute "would-be settled" PNL for this account
3244            let mut settled_pnl = account.pnl.get();
3245            if !account.position_size.is_zero() {
3246                let delta_f = global_index
3247                    .get()
3248                    .saturating_sub(account.funding_index.get());
3249                if delta_f != 0 {
3250                    let raw = account.position_size.get().saturating_mul(delta_f);
3251                    let payment = if raw > 0 {
3252                        raw.saturating_add(999_999).saturating_div(1_000_000)
3253                    } else {
3254                        raw.saturating_div(1_000_000)
3255                    };
3256                    settled_pnl = settled_pnl.saturating_sub(payment);
3257                }
3258
3259                match Self::mark_pnl_for_position(
3260                    account.position_size.get(),
3261                    account.entry_price,
3262                    oracle_price,
3263                ) {
3264                    Ok(mark) => {
3265                        net_mark = net_mark.saturating_add(mark);
3266                    }
3267                    Err(_) => {
3268                        mark_ok = false;
3269                    }
3270                }
3271            }
3272            net_pnl = net_pnl.saturating_add(settled_pnl);
3273        });
3274
3275        if !mark_ok {
3276            return false;
3277        }
3278
3279        // Conservation: vault >= C_tot + I (primary invariant)
3280        let primary = self.vault.get()
3281            >= total_capital.saturating_add(self.insurance_fund.balance.get());
3282        if !primary {
3283            return false;
3284        }
3285
3286        // Extended: vault >= sum(capital) + sum(settled_pnl + mark_pnl) + insurance
3287        let total_pnl = net_pnl.saturating_add(net_mark);
3288        let base = add_u128(total_capital, self.insurance_fund.balance.get());
3289
3290        let expected = if total_pnl >= 0 {
3291            add_u128(base, total_pnl as u128)
3292        } else {
3293            base.saturating_sub(neg_i128_to_u128(total_pnl))
3294        };
3295
3296        let actual = self.vault.get();
3297
3298        if actual < expected {
3299            return false;
3300        }
3301        let slack = actual - expected;
3302        slack <= MAX_ROUNDING_SLACK
3303    }
3304
3305    /// Advance to next slot (for testing warmup)
3306    pub fn advance_slot(&mut self, slots: u64) {
3307        self.current_slot = self.current_slot.saturating_add(slots);
3308    }
3309}
3310