Skip to main content

percolator/
percolator.rs

1#![allow(clippy::all, unused, unused_doc_comments)]
2//! Formally Verified Risk Engine for Perpetual DEX — v12.1.0
3//!
4//! Implements the v12.1.0 spec: Native 128-bit Architecture.
5//!
6//! This module implements a formally verified risk engine that guarantees:
7//! 1. Protected principal for flat accounts
8//! 2. PNL warmup prevents instant withdrawal of manipulated profits
9//! 3. ADL via lazy A/K side indices on the opposing OI side
10//! 4. Conservation of funds across all operations (V >= C_tot + I)
11//! 5. No hidden protocol MM — bankruptcy socialization through explicit A/K state only
12//!
13//! # Atomicity Model
14//!
15//! Public functions suffixed with `_not_atomic` can return `Err` after partial
16//! state mutation. **Callers MUST abort the entire transaction on `Err`** —
17//! they must not retry, suppress, or continue with mutated state.
18//!
19//! On Solana SVM, any `Err` return from an instruction aborts the transaction
20//! and rolls back all account state automatically. This is the expected
21//! deployment model.
22//!
23//! Public functions WITHOUT the suffix (`deposit`, `top_up_insurance_fund`,
24//! `deposit_fee_credits`, `accrue_market_to`) use validate-then-mutate:
25//! `Err` means no state was changed.
26//!
27//! Internal helpers (`enqueue_adl`, `liquidate_at_oracle_internal`, etc.)
28//! are not individually atomic — they rely on the calling `_not_atomic`
29//! method to propagate `Err` to the transaction boundary.
30
31#![no_std]
32#![forbid(unsafe_code)]
33
34#[cfg(kani)]
35extern crate kani;
36
37// ============================================================================
38// Conditional visibility macro
39// ============================================================================
40
41// ============================================================================
42// Conditional visibility macro
43// ============================================================================
44
45/// Internal methods that proof harnesses and integration tests need direct
46/// access to. Private in production builds, `pub` under test/kani.
47/// Each invocation emits two mutually-exclusive cfg-gated copies of the same
48/// function: one `pub`, one private.
49macro_rules! test_visible {
50    (
51        $(#[$meta:meta])*
52        fn $name:ident($($args:tt)*) $(-> $ret:ty)? $body:block
53    ) => {
54        $(#[$meta])*
55        #[cfg(any(feature = "test", kani))]
56        pub fn $name($($args)*) $(-> $ret)? $body
57
58        $(#[$meta])*
59        #[cfg(not(any(feature = "test", kani)))]
60        fn $name($($args)*) $(-> $ret)? $body
61    };
62}
63
64// ============================================================================
65// Constants
66// ============================================================================
67
68#[cfg(kani)]
69pub const MAX_ACCOUNTS: usize = 4;
70
71#[cfg(all(feature = "test", not(kani)))]
72pub const MAX_ACCOUNTS: usize = 64;
73
74#[cfg(all(not(kani), not(feature = "test")))]
75pub const MAX_ACCOUNTS: usize = 4096;
76
77pub const BITMAP_WORDS: usize = (MAX_ACCOUNTS + 63) / 64;
78pub const MAX_ROUNDING_SLACK: u128 = MAX_ACCOUNTS as u128;
79const ACCOUNT_IDX_MASK: usize = MAX_ACCOUNTS - 1;
80
81pub const GC_CLOSE_BUDGET: u32 = 32;
82pub const ACCOUNTS_PER_CRANK: u16 = 128;
83pub const LIQ_BUDGET_PER_CRANK: u16 = 64;
84
85/// POS_SCALE = 1_000_000 (spec §1.2)
86pub const POS_SCALE: u128 = 1_000_000;
87
88/// ADL_ONE = 1_000_000 (spec §1.3)
89pub const ADL_ONE: u128 = 1_000_000;
90
91/// MIN_A_SIDE = 1_000 (spec §1.4)
92pub const MIN_A_SIDE: u128 = 1_000;
93
94/// MAX_ORACLE_PRICE = 1_000_000_000_000 (spec §1.4)
95pub const MAX_ORACLE_PRICE: u64 = 1_000_000_000_000;
96
97/// MAX_FUNDING_DT = 65535 (spec §1.4)
98pub const MAX_FUNDING_DT: u64 = u16::MAX as u64;
99
100/// MAX_ABS_FUNDING_BPS_PER_SLOT = 10000 (spec §1.4)
101pub const MAX_ABS_FUNDING_BPS_PER_SLOT: i64 = 10_000;
102
103// Normative bounds (spec §1.4)
104pub const MAX_VAULT_TVL: u128 = 10_000_000_000_000_000;
105pub const MAX_POSITION_ABS_Q: u128 = 100_000_000_000_000;
106pub const MAX_ACCOUNT_NOTIONAL: u128 = 100_000_000_000_000_000_000;
107pub const MAX_TRADE_SIZE_Q: u128 = MAX_POSITION_ABS_Q; // spec §1.4
108pub const MAX_OI_SIDE_Q: u128 = 100_000_000_000_000;
109pub const MAX_MATERIALIZED_ACCOUNTS: u64 = 1_000_000;
110pub const MAX_ACCOUNT_POSITIVE_PNL: u128 = 100_000_000_000_000_000_000_000_000_000_000;
111pub const MAX_PNL_POS_TOT: u128 = 100_000_000_000_000_000_000_000_000_000_000_000_000;
112pub const MAX_TRADING_FEE_BPS: u64 = 10_000;
113pub const MAX_MARGIN_BPS: u64 = 10_000;
114pub const MAX_LIQUIDATION_FEE_BPS: u64 = 10_000;
115pub const MAX_PROTOCOL_FEE_ABS: u128 = 1_000_000_000_000_000_000_000_000_000_000_000_000; // 10^36, spec §1.4
116pub const MAX_MAINTENANCE_FEE_PER_SLOT: u128 = 10_000_000_000_000_000; // spec §1.4
117
118// ============================================================================
119// BPF-Safe 128-bit Types
120// ============================================================================
121pub mod i128;
122pub use i128::{I128, U128};
123
124// ============================================================================
125// Wide 256-bit Arithmetic (used for transient intermediates only)
126// ============================================================================
127pub mod wide_math;
128use wide_math::{
129    ceil_div_positive_checked, fee_debt_u128_checked, floor_div_signed_conservative_i128,
130    mul_div_ceil_u128, mul_div_floor_u128, mul_div_floor_u256_with_rem, saturating_mul_u128_u64,
131    wide_mul_div_ceil_u128_or_over_i128max, wide_mul_div_floor_u128,
132    wide_signed_mul_div_floor_from_k_pair, OverI128Magnitude, I256, U256,
133};
134
135// ============================================================================
136// Core Data Structures
137// ============================================================================
138
139// AccountKind as plain u8 — eliminates UB risk from invalid enum discriminants
140// when casting raw slab bytes to &Account via zero-copy. u8 has no invalid
141// representations, so &*(ptr as *const Account) is always sound.
142// pub enum AccountKind { User = 0, LP = 1 }  // replaced by constants below
143
144/// Side mode for OI sides (spec §2.4)
145#[repr(u8)]
146#[derive(Clone, Copy, Debug, PartialEq, Eq)]
147pub enum SideMode {
148    Normal = 0,
149    DrainOnly = 1,
150    ResetPending = 2,
151}
152
153/// Instruction context for deferred reset scheduling (spec §5.7-5.8)
154pub struct InstructionContext {
155    pub pending_reset_long: bool,
156    pub pending_reset_short: bool,
157}
158
159impl InstructionContext {
160    pub fn new() -> Self {
161        Self {
162            pending_reset_long: false,
163            pending_reset_short: false,
164        }
165    }
166}
167
168/// Unified account (spec §2.1)
169#[repr(C)]
170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
171pub struct Account {
172    pub account_id: u64,
173    pub capital: U128,
174    pub kind: u8, // 0 = User, 1 = LP (was AccountKind enum)
175
176    /// Realized PnL (i128, spec §2.1)
177    pub pnl: i128,
178
179    /// Reserved positive PnL (u128, spec §2.1)
180    pub reserved_pnl: u128,
181
182    /// Warmup start slot
183    pub warmup_started_at_slot: u64,
184
185    /// Linear warmup slope (u128, spec §2.1)
186    pub warmup_slope_per_step: u128,
187
188    /// Signed fixed-point base quantity basis (i128, spec §2.1)
189    pub position_basis_q: i128,
190
191    /// Side multiplier snapshot at last explicit position attachment (u128)
192    pub adl_a_basis: u128,
193
194    /// K coefficient snapshot (i128)
195    pub adl_k_snap: i128,
196
197    /// Side epoch snapshot
198    pub adl_epoch_snap: u64,
199
200    /// LP matching engine program ID
201    pub matcher_program: [u8; 32],
202    pub matcher_context: [u8; 32],
203
204    /// Owner pubkey
205    pub owner: [u8; 32],
206
207    /// Fee credits
208    pub fee_credits: I128,
209    pub last_fee_slot: u64,
210
211    /// Cumulative LP trading fees
212    pub fees_earned_total: U128,
213}
214
215impl Account {
216    pub const KIND_USER: u8 = 0;
217    pub const KIND_LP: u8 = 1;
218
219    pub fn is_lp(&self) -> bool {
220        self.kind == Self::KIND_LP
221    }
222
223    pub fn is_user(&self) -> bool {
224        self.kind == Self::KIND_USER
225    }
226}
227
228fn empty_account() -> Account {
229    Account {
230        account_id: 0,
231        capital: U128::ZERO,
232        kind: Account::KIND_USER,
233        pnl: 0i128,
234        reserved_pnl: 0u128,
235        warmup_started_at_slot: 0,
236        warmup_slope_per_step: 0u128,
237        position_basis_q: 0i128,
238        adl_a_basis: ADL_ONE,
239        adl_k_snap: 0i128,
240        adl_epoch_snap: 0,
241        matcher_program: [0; 32],
242        matcher_context: [0; 32],
243        owner: [0; 32],
244        fee_credits: I128::ZERO,
245        last_fee_slot: 0,
246        fees_earned_total: U128::ZERO,
247    }
248}
249
250/// Insurance fund state
251#[repr(C)]
252#[derive(Clone, Copy, Debug, PartialEq, Eq)]
253pub struct InsuranceFund {
254    pub balance: U128,
255}
256
257/// Risk engine parameters
258#[repr(C)]
259#[derive(Clone, Copy, Debug, PartialEq, Eq)]
260pub struct RiskParams {
261    pub warmup_period_slots: u64,
262    pub maintenance_margin_bps: u64,
263    pub initial_margin_bps: u64,
264    pub trading_fee_bps: u64,
265    pub max_accounts: u64,
266    pub new_account_fee: U128,
267    pub maintenance_fee_per_slot: U128,
268    pub max_crank_staleness_slots: u64,
269    pub liquidation_fee_bps: u64,
270    pub liquidation_fee_cap: U128,
271    pub min_liquidation_abs: U128,
272    pub min_initial_deposit: U128,
273    /// Absolute nonzero-position margin floors (spec §9.1)
274    pub min_nonzero_mm_req: u128,
275    pub min_nonzero_im_req: u128,
276    /// Insurance fund floor (spec §1.4: 0 <= I_floor <= MAX_VAULT_TVL)
277    pub insurance_floor: U128,
278}
279
280/// Main risk engine state (spec §2.2)
281#[repr(C)]
282#[derive(Clone, Debug, PartialEq, Eq)]
283pub struct RiskEngine {
284    pub vault: U128,
285    pub insurance_fund: InsuranceFund,
286    pub params: RiskParams,
287    pub current_slot: u64,
288
289    /// Stored funding rate for anti-retroactivity
290    pub funding_rate_bps_per_slot_last: i64,
291
292    // Keeper crank tracking
293    pub last_crank_slot: u64,
294    pub max_crank_staleness_slots: u64,
295
296    // O(1) aggregates (spec §2.2)
297    pub c_tot: U128,
298    pub pnl_pos_tot: u128,
299    pub pnl_matured_pos_tot: u128,
300
301    // Crank cursors
302    pub liq_cursor: u16,
303    pub gc_cursor: u16,
304    pub last_full_sweep_start_slot: u64,
305    pub last_full_sweep_completed_slot: u64,
306    pub crank_cursor: u16,
307    pub sweep_start_idx: u16,
308
309    // Lifetime counters
310    pub lifetime_liquidations: u64,
311
312    // ADL side state (spec §2.2)
313    pub adl_mult_long: u128,
314    pub adl_mult_short: u128,
315    pub adl_coeff_long: i128,
316    pub adl_coeff_short: i128,
317    pub adl_epoch_long: u64,
318    pub adl_epoch_short: u64,
319    pub adl_epoch_start_k_long: i128,
320    pub adl_epoch_start_k_short: i128,
321    pub oi_eff_long_q: u128,
322    pub oi_eff_short_q: u128,
323    pub side_mode_long: SideMode,
324    pub side_mode_short: SideMode,
325    pub stored_pos_count_long: u64,
326    pub stored_pos_count_short: u64,
327    pub stale_account_count_long: u64,
328    pub stale_account_count_short: u64,
329
330    /// Dynamic phantom dust bounds (spec §4.6, §5.7)
331    pub phantom_dust_bound_long_q: u128,
332    pub phantom_dust_bound_short_q: u128,
333
334    /// Materialized account count (spec §2.2)
335    pub materialized_account_count: u64,
336
337    /// Last oracle price used in accrue_market_to
338    pub last_oracle_price: u64,
339    /// Last slot used in accrue_market_to
340    pub last_market_slot: u64,
341    /// Funding price sample (for anti-retroactivity)
342    pub funding_price_sample_last: u64,
343
344    // Insurance floor is read from self.params.insurance_floor (no duplicate field)
345
346    // Slab management
347    pub used: [u64; BITMAP_WORDS],
348    pub num_used_accounts: u16,
349    pub next_account_id: u64,
350    pub free_head: u16,
351    pub next_free: [u16; MAX_ACCOUNTS],
352    pub accounts: [Account; MAX_ACCOUNTS],
353}
354
355// ============================================================================
356// Error Types
357// ============================================================================
358
359#[derive(Clone, Copy, Debug, PartialEq, Eq)]
360pub enum RiskError {
361    InsufficientBalance,
362    Undercollateralized,
363    Unauthorized,
364    InvalidMatchingEngine,
365    PnlNotWarmedUp,
366    Overflow,
367    AccountNotFound,
368    NotAnLPAccount,
369    PositionSizeMismatch,
370    AccountKindMismatch,
371    SideBlocked,
372    CorruptState,
373}
374
375pub type Result<T> = core::result::Result<T, RiskError>;
376
377/// Liquidation policy (spec §10.6)
378#[derive(Clone, Copy, Debug, PartialEq, Eq)]
379pub enum LiquidationPolicy {
380    FullClose,
381    ExactPartial(u128), // q_close_q
382}
383
384/// Outcome of a keeper crank operation
385#[derive(Clone, Copy, Debug, PartialEq, Eq)]
386pub struct CrankOutcome {
387    pub advanced: bool,
388    pub slots_forgiven: u64,
389    pub caller_settle_ok: bool,
390    pub force_realize_needed: bool,
391    pub panic_needed: bool,
392    pub num_liquidations: u32,
393    pub num_liq_errors: u16,
394    pub num_gc_closed: u32,
395    pub last_cursor: u16,
396    pub sweep_complete: bool,
397}
398
399// ============================================================================
400// Small Helpers
401// ============================================================================
402
403#[inline]
404fn add_u128(a: u128, b: u128) -> u128 {
405    a.checked_add(b).expect("add_u128 overflow")
406}
407
408#[inline]
409fn sub_u128(a: u128, b: u128) -> u128 {
410    a.checked_sub(b).expect("sub_u128 underflow")
411}
412
413#[inline]
414fn mul_u128(a: u128, b: u128) -> u128 {
415    a.checked_mul(b).expect("mul_u128 overflow")
416}
417
418/// Determine which side a signed position is on. Positive = long, negative = short.
419#[derive(Clone, Copy, Debug, PartialEq, Eq)]
420pub enum Side {
421    Long,
422    Short,
423}
424
425fn side_of_i128(v: i128) -> Option<Side> {
426    if v == 0 {
427        None
428    } else if v > 0 {
429        Some(Side::Long)
430    } else {
431        Some(Side::Short)
432    }
433}
434
435fn opposite_side(s: Side) -> Side {
436    match s {
437        Side::Long => Side::Short,
438        Side::Short => Side::Long,
439    }
440}
441
442/// Clamp i128 max(v, 0) as u128
443fn i128_clamp_pos(v: i128) -> u128 {
444    if v > 0 {
445        v as u128
446    } else {
447        0u128
448    }
449}
450
451// ============================================================================
452// Core Implementation
453// ============================================================================
454
455impl RiskEngine {
456    /// Validate configuration parameters (spec §1.4, §2.2.1).
457    /// Panics on invalid configuration to prevent deployment with unsafe params.
458    fn validate_params(params: &RiskParams) {
459        // Capacity: max_accounts within compile-time slab (spec §1.4)
460        assert!(
461            (params.max_accounts as usize) <= MAX_ACCOUNTS && params.max_accounts > 0,
462            "max_accounts must be in 1..=MAX_ACCOUNTS"
463        );
464
465        // Margin ordering: 0 <= maintenance_bps <= initial_bps <= 10_000 (spec §1.4)
466        assert!(
467            params.maintenance_margin_bps <= params.initial_margin_bps,
468            "maintenance_margin_bps must be <= initial_margin_bps (spec §1.4)"
469        );
470        assert!(
471            params.initial_margin_bps <= 10_000,
472            "initial_margin_bps must be <= 10_000"
473        );
474
475        // BPS bounds (spec §1.4)
476        assert!(
477            params.trading_fee_bps <= 10_000,
478            "trading_fee_bps must be <= 10_000"
479        );
480        assert!(
481            params.liquidation_fee_bps <= 10_000,
482            "liquidation_fee_bps must be <= 10_000"
483        );
484
485        // Nonzero margin floor ordering: 0 < mm < im <= min_initial_deposit (spec §1.4)
486        assert!(
487            params.min_nonzero_mm_req > 0,
488            "min_nonzero_mm_req must be > 0"
489        );
490        assert!(
491            params.min_nonzero_mm_req < params.min_nonzero_im_req,
492            "min_nonzero_mm_req must be strictly less than min_nonzero_im_req"
493        );
494        assert!(
495            params.min_nonzero_im_req <= params.min_initial_deposit.get(),
496            "min_nonzero_im_req must be <= min_initial_deposit (spec §1.4)"
497        );
498
499        // MIN_INITIAL_DEPOSIT bounds: 0 < min_initial_deposit <= MAX_VAULT_TVL (spec §1.4)
500        assert!(
501            params.min_initial_deposit.get() > 0,
502            "min_initial_deposit must be > 0 (spec §1.4)"
503        );
504        assert!(
505            params.min_initial_deposit.get() <= MAX_VAULT_TVL,
506            "min_initial_deposit must be <= MAX_VAULT_TVL"
507        );
508
509        // Liquidation fee ordering: 0 <= min_liquidation_abs <= liquidation_fee_cap (spec §1.4)
510        assert!(
511            params.min_liquidation_abs.get() <= params.liquidation_fee_cap.get(),
512            "min_liquidation_abs must be <= liquidation_fee_cap (spec §1.4)"
513        );
514        assert!(
515            params.liquidation_fee_cap.get() <= MAX_PROTOCOL_FEE_ABS,
516            "liquidation_fee_cap must be <= MAX_PROTOCOL_FEE_ABS (spec §1.4)"
517        );
518
519        // Maintenance fee bound (spec §8.2)
520        assert!(
521            params.maintenance_fee_per_slot.get() <= MAX_MAINTENANCE_FEE_PER_SLOT,
522            "maintenance_fee_per_slot must be <= MAX_MAINTENANCE_FEE_PER_SLOT (spec §8.2.1)"
523        );
524
525        // Insurance floor (spec §1.4: 0 <= I_floor <= MAX_VAULT_TVL)
526        assert!(
527            params.insurance_floor.get() <= MAX_VAULT_TVL,
528            "insurance_floor must be <= MAX_VAULT_TVL (spec §1.4)"
529        );
530    }
531
532    /// Create a new risk engine for testing. Initializes with
533    /// init_oracle_price = 1 (spec §2.7 compliant).
534    #[cfg(any(feature = "test", kani))]
535    pub fn new(params: RiskParams) -> Self {
536        Self::new_with_market(params, 0, 1)
537    }
538
539    /// Create a new risk engine with explicit market initialization (spec §2.7).
540    /// Requires `0 < init_oracle_price <= MAX_ORACLE_PRICE` per spec §1.2.
541    pub fn new_with_market(params: RiskParams, init_slot: u64, init_oracle_price: u64) -> Self {
542        Self::validate_params(&params);
543        assert!(
544            init_oracle_price > 0 && init_oracle_price <= MAX_ORACLE_PRICE,
545            "init_oracle_price must be in (0, MAX_ORACLE_PRICE] per spec §2.7"
546        );
547        let mut engine = Self {
548            vault: U128::ZERO,
549            insurance_fund: InsuranceFund {
550                balance: U128::ZERO,
551            },
552            params,
553            current_slot: init_slot,
554            funding_rate_bps_per_slot_last: 0,
555            last_crank_slot: 0,
556            max_crank_staleness_slots: params.max_crank_staleness_slots,
557            c_tot: U128::ZERO,
558            pnl_pos_tot: 0u128,
559            pnl_matured_pos_tot: 0u128,
560            liq_cursor: 0,
561            gc_cursor: 0,
562            last_full_sweep_start_slot: 0,
563            last_full_sweep_completed_slot: 0,
564            crank_cursor: 0,
565            sweep_start_idx: 0,
566            lifetime_liquidations: 0,
567            adl_mult_long: ADL_ONE,
568            adl_mult_short: ADL_ONE,
569            adl_coeff_long: 0i128,
570            adl_coeff_short: 0i128,
571            adl_epoch_long: 0,
572            adl_epoch_short: 0,
573            adl_epoch_start_k_long: 0i128,
574            adl_epoch_start_k_short: 0i128,
575            oi_eff_long_q: 0u128,
576            oi_eff_short_q: 0u128,
577            side_mode_long: SideMode::Normal,
578            side_mode_short: SideMode::Normal,
579            stored_pos_count_long: 0,
580            stored_pos_count_short: 0,
581            stale_account_count_long: 0,
582            stale_account_count_short: 0,
583            phantom_dust_bound_long_q: 0u128,
584            phantom_dust_bound_short_q: 0u128,
585            materialized_account_count: 0,
586            last_oracle_price: init_oracle_price,
587            last_market_slot: init_slot,
588            funding_price_sample_last: init_oracle_price,
589            used: [0; BITMAP_WORDS],
590            num_used_accounts: 0,
591            next_account_id: 0,
592            free_head: 0,
593            next_free: [0; MAX_ACCOUNTS],
594            accounts: [empty_account(); MAX_ACCOUNTS],
595        };
596
597        for i in 0..MAX_ACCOUNTS - 1 {
598            engine.next_free[i] = (i + 1) as u16;
599        }
600        engine.next_free[MAX_ACCOUNTS - 1] = u16::MAX;
601
602        engine
603    }
604
605    /// Initialize in place (for Solana BPF zero-copy, spec §2.7).
606    /// Fully canonicalizes all state — safe even on non-zeroed memory.
607    pub fn init_in_place(&mut self, params: RiskParams, init_slot: u64, init_oracle_price: u64) {
608        Self::validate_params(&params);
609        assert!(
610            init_oracle_price > 0 && init_oracle_price <= MAX_ORACLE_PRICE,
611            "init_oracle_price must be in (0, MAX_ORACLE_PRICE] per spec §2.7"
612        );
613        self.vault = U128::ZERO;
614        self.insurance_fund = InsuranceFund {
615            balance: U128::ZERO,
616        };
617        self.params = params;
618        self.current_slot = init_slot;
619        self.funding_rate_bps_per_slot_last = 0;
620        self.last_crank_slot = 0;
621        self.max_crank_staleness_slots = params.max_crank_staleness_slots;
622        self.c_tot = U128::ZERO;
623        self.pnl_pos_tot = 0;
624        self.pnl_matured_pos_tot = 0;
625        self.liq_cursor = 0;
626        self.gc_cursor = 0;
627        self.last_full_sweep_start_slot = 0;
628        self.last_full_sweep_completed_slot = 0;
629        self.crank_cursor = 0;
630        self.sweep_start_idx = 0;
631        self.lifetime_liquidations = 0;
632        self.adl_mult_long = ADL_ONE;
633        self.adl_mult_short = ADL_ONE;
634        self.adl_coeff_long = 0;
635        self.adl_coeff_short = 0;
636        self.adl_epoch_long = 0;
637        self.adl_epoch_short = 0;
638        self.adl_epoch_start_k_long = 0;
639        self.adl_epoch_start_k_short = 0;
640        self.oi_eff_long_q = 0;
641        self.oi_eff_short_q = 0;
642        self.side_mode_long = SideMode::Normal;
643        self.side_mode_short = SideMode::Normal;
644        self.stored_pos_count_long = 0;
645        self.stored_pos_count_short = 0;
646        self.stale_account_count_long = 0;
647        self.stale_account_count_short = 0;
648        self.phantom_dust_bound_long_q = 0;
649        self.phantom_dust_bound_short_q = 0;
650        self.materialized_account_count = 0;
651        self.last_oracle_price = init_oracle_price;
652        self.last_market_slot = init_slot;
653        self.funding_price_sample_last = init_oracle_price;
654        // insurance_floor is now read directly from self.params.insurance_floor
655        self.used = [0; BITMAP_WORDS];
656        self.num_used_accounts = 0;
657        self.next_account_id = 0;
658        self.free_head = 0;
659        self.accounts = [empty_account(); MAX_ACCOUNTS];
660        for i in 0..MAX_ACCOUNTS - 1 {
661            self.next_free[i] = (i + 1) as u16;
662        }
663        self.next_free[MAX_ACCOUNTS - 1] = u16::MAX;
664    }
665
666    // ========================================================================
667    // Bitmap Helpers
668    // ========================================================================
669
670    pub fn is_used(&self, idx: usize) -> bool {
671        if idx >= MAX_ACCOUNTS {
672            return false;
673        }
674        let w = idx >> 6;
675        let b = idx & 63;
676        ((self.used[w] >> b) & 1) == 1
677    }
678
679    fn set_used(&mut self, idx: usize) {
680        let w = idx >> 6;
681        let b = idx & 63;
682        self.used[w] |= 1u64 << b;
683    }
684
685    fn clear_used(&mut self, idx: usize) {
686        let w = idx >> 6;
687        let b = idx & 63;
688        self.used[w] &= !(1u64 << b);
689    }
690
691    #[allow(dead_code)]
692    fn for_each_used_mut<F: FnMut(usize, &mut Account)>(&mut self, mut f: F) {
693        for (block, word) in self.used.iter().copied().enumerate() {
694            let mut w = word;
695            while w != 0 {
696                let bit = w.trailing_zeros() as usize;
697                let idx = block * 64 + bit;
698                w &= w - 1;
699                if idx >= MAX_ACCOUNTS {
700                    continue;
701                }
702                f(idx, &mut self.accounts[idx]);
703            }
704        }
705    }
706
707    fn for_each_used<F: FnMut(usize, &Account)>(&self, mut f: F) {
708        for (block, word) in self.used.iter().copied().enumerate() {
709            let mut w = word;
710            while w != 0 {
711                let bit = w.trailing_zeros() as usize;
712                let idx = block * 64 + bit;
713                w &= w - 1;
714                if idx >= MAX_ACCOUNTS {
715                    continue;
716                }
717                f(idx, &self.accounts[idx]);
718            }
719        }
720    }
721
722    // ========================================================================
723    // Freelist
724    // ========================================================================
725
726    fn alloc_slot(&mut self) -> Result<u16> {
727        if self.free_head == u16::MAX {
728            return Err(RiskError::Overflow);
729        }
730        let idx = self.free_head;
731        self.free_head = self.next_free[idx as usize];
732        self.set_used(idx as usize);
733        self.num_used_accounts = self
734            .num_used_accounts
735            .checked_add(1)
736            .expect("num_used_accounts overflow — slot leak corruption");
737        Ok(idx)
738    }
739
740    test_visible! {
741    fn free_slot(&mut self, idx: u16) {
742        self.accounts[idx as usize] = empty_account();
743        self.clear_used(idx as usize);
744        self.next_free[idx as usize] = self.free_head;
745        self.free_head = idx;
746        self.num_used_accounts = self.num_used_accounts.checked_sub(1)
747            .expect("free_slot: num_used_accounts underflow — double-free corruption");
748        // Decrement materialized_account_count (spec §2.1.2)
749        self.materialized_account_count = self.materialized_account_count.checked_sub(1)
750            .expect("free_slot: materialized_account_count underflow — double-free corruption");
751    }
752    }
753
754    /// materialize_account(i, slot_anchor) — spec §2.5.
755    /// Materializes a missing account at a specific slot index.
756    /// The slot must not be currently in use.
757    fn materialize_at(&mut self, idx: u16, slot_anchor: u64) -> Result<()> {
758        if idx as usize >= MAX_ACCOUNTS {
759            return Err(RiskError::AccountNotFound);
760        }
761
762        let used_count = self.num_used_accounts as u64;
763        if used_count >= self.params.max_accounts {
764            return Err(RiskError::Overflow);
765        }
766
767        // Enforce materialized_account_count bound (spec §10.0)
768        self.materialized_account_count = self
769            .materialized_account_count
770            .checked_add(1)
771            .ok_or(RiskError::Overflow)?;
772        if self.materialized_account_count > MAX_MATERIALIZED_ACCOUNTS {
773            self.materialized_account_count -= 1;
774            return Err(RiskError::Overflow);
775        }
776
777        // Remove idx from free list. Must succeed — if idx is not in the
778        // freelist, the state is corrupt and we must not proceed.
779        let mut found = false;
780        if self.free_head == idx {
781            self.free_head = self.next_free[idx as usize];
782            found = true;
783        } else {
784            let mut prev = self.free_head;
785            let mut steps = 0usize;
786            while prev != u16::MAX && steps < MAX_ACCOUNTS {
787                if self.next_free[prev as usize] == idx {
788                    self.next_free[prev as usize] = self.next_free[idx as usize];
789                    found = true;
790                    break;
791                }
792                prev = self.next_free[prev as usize];
793                steps += 1;
794            }
795        }
796        if !found {
797            // Roll back materialized_account_count
798            self.materialized_account_count -= 1;
799            return Err(RiskError::CorruptState);
800        }
801
802        self.set_used(idx as usize);
803        self.num_used_accounts = self
804            .num_used_accounts
805            .checked_add(1)
806            .expect("num_used_accounts overflow — slot leak corruption");
807
808        let account_id = self.next_account_id;
809        self.next_account_id = self.next_account_id.saturating_add(1);
810
811        // Initialize per spec §2.5
812        self.accounts[idx as usize] = Account {
813            kind: Account::KIND_USER,
814            account_id,
815            capital: U128::ZERO,
816            pnl: 0i128,
817            reserved_pnl: 0u128,
818            warmup_started_at_slot: slot_anchor,
819            warmup_slope_per_step: 0u128,
820            position_basis_q: 0i128,
821            adl_a_basis: ADL_ONE,
822            adl_k_snap: 0i128,
823            adl_epoch_snap: 0,
824            matcher_program: [0; 32],
825            matcher_context: [0; 32],
826            owner: [0; 32],
827            fee_credits: I128::ZERO,
828            last_fee_slot: slot_anchor,
829            fees_earned_total: U128::ZERO,
830        };
831
832        Ok(())
833    }
834
835    // ========================================================================
836    // O(1) Aggregate Helpers (spec §4)
837    // ========================================================================
838
839    /// set_pnl (spec §4.4): Update PNL and maintain pnl_pos_tot + pnl_matured_pos_tot
840    /// with proper reserve handling. Forbids i128::MIN.
841    test_visible! {
842    fn set_pnl(&mut self, idx: usize, new_pnl: i128) {
843        // Step 1: forbid i128::MIN
844        assert!(new_pnl != i128::MIN, "set_pnl: i128::MIN forbidden");
845
846        let old = self.accounts[idx].pnl;
847        let old_pos = i128_clamp_pos(old);
848        let old_r = self.accounts[idx].reserved_pnl;
849        let old_rel = old_pos - old_r;
850        let new_pos = i128_clamp_pos(new_pnl);
851
852        // Step 6: per-account positive-PnL bound
853        assert!(new_pos <= MAX_ACCOUNT_POSITIVE_PNL, "set_pnl: exceeds MAX_ACCOUNT_POSITIVE_PNL");
854
855        // Steps 7-8: compute new_R
856        let new_r = if new_pos > old_pos {
857            // Step 7: positive increase → add to reserve
858            let reserve_add = new_pos - old_pos;
859            let nr = old_r.checked_add(reserve_add)
860                .expect("set_pnl: new_R overflow");
861            assert!(nr <= new_pos, "set_pnl: new_R > new_pos");
862            nr
863        } else {
864            // Step 8: decrease or same → saturating_sub loss from reserve
865            let pos_loss = old_pos - new_pos;
866            let nr = old_r.saturating_sub(pos_loss);
867            assert!(nr <= new_pos, "set_pnl: new_R > new_pos");
868            nr
869        };
870
871        let new_rel = new_pos - new_r;
872
873        // Steps 10-11: update pnl_pos_tot
874        if new_pos > old_pos {
875            let delta = new_pos - old_pos;
876            self.pnl_pos_tot = self.pnl_pos_tot.checked_add(delta)
877                .expect("set_pnl: pnl_pos_tot overflow");
878        } else if old_pos > new_pos {
879            let delta = old_pos - new_pos;
880            self.pnl_pos_tot = self.pnl_pos_tot.checked_sub(delta)
881                .expect("set_pnl: pnl_pos_tot underflow");
882        }
883        assert!(self.pnl_pos_tot <= MAX_PNL_POS_TOT, "set_pnl: exceeds MAX_PNL_POS_TOT");
884
885        // Steps 12-13: update pnl_matured_pos_tot
886        if new_rel > old_rel {
887            let delta = new_rel - old_rel;
888            self.pnl_matured_pos_tot = self.pnl_matured_pos_tot.checked_add(delta)
889                .expect("set_pnl: pnl_matured_pos_tot overflow");
890        } else if old_rel > new_rel {
891            let delta = old_rel - new_rel;
892            self.pnl_matured_pos_tot = self.pnl_matured_pos_tot.checked_sub(delta)
893                .expect("set_pnl: pnl_matured_pos_tot underflow");
894        }
895        assert!(self.pnl_matured_pos_tot <= self.pnl_pos_tot,
896            "set_pnl: pnl_matured_pos_tot > pnl_pos_tot");
897
898        // Steps 14-15: write PNL_i and R_i
899        self.accounts[idx].pnl = new_pnl;
900        self.accounts[idx].reserved_pnl = new_r;
901    }
902    }
903
904    /// set_reserved_pnl (spec §4.3): update R_i and maintain pnl_matured_pos_tot.
905    test_visible! {
906    fn set_reserved_pnl(&mut self, idx: usize, new_r: u128) {
907        let pos = i128_clamp_pos(self.accounts[idx].pnl);
908        assert!(new_r <= pos, "set_reserved_pnl: new_R > max(PNL_i, 0)");
909
910        let old_r = self.accounts[idx].reserved_pnl;
911        let old_rel = pos - old_r;
912        let new_rel = pos - new_r;
913
914        // Update pnl_matured_pos_tot by exact delta
915        if new_rel > old_rel {
916            let delta = new_rel - old_rel;
917            self.pnl_matured_pos_tot = self.pnl_matured_pos_tot.checked_add(delta)
918                .expect("set_reserved_pnl: pnl_matured_pos_tot overflow");
919        } else if old_rel > new_rel {
920            let delta = old_rel - new_rel;
921            self.pnl_matured_pos_tot = self.pnl_matured_pos_tot.checked_sub(delta)
922                .expect("set_reserved_pnl: pnl_matured_pos_tot underflow");
923        }
924        assert!(self.pnl_matured_pos_tot <= self.pnl_pos_tot,
925            "set_reserved_pnl: pnl_matured_pos_tot > pnl_pos_tot");
926
927        self.accounts[idx].reserved_pnl = new_r;
928    }
929    }
930
931    /// consume_released_pnl (spec §4.4.1): remove only matured released positive PnL,
932    /// leaving R_i unchanged.
933    test_visible! {
934    fn consume_released_pnl(&mut self, idx: usize, x: u128) {
935        assert!(x > 0, "consume_released_pnl: x must be > 0");
936
937        let old_pos = i128_clamp_pos(self.accounts[idx].pnl);
938        let old_r = self.accounts[idx].reserved_pnl;
939        let old_rel = old_pos - old_r;
940        assert!(x <= old_rel, "consume_released_pnl: x > ReleasedPos_i");
941
942        let new_pos = old_pos - x;
943        let new_rel = old_rel - x;
944        assert!(new_pos >= old_r, "consume_released_pnl: new_pos < old_R");
945
946        // Update pnl_pos_tot
947        self.pnl_pos_tot = self.pnl_pos_tot.checked_sub(x)
948            .expect("consume_released_pnl: pnl_pos_tot underflow");
949
950        // Update pnl_matured_pos_tot
951        self.pnl_matured_pos_tot = self.pnl_matured_pos_tot.checked_sub(x)
952            .expect("consume_released_pnl: pnl_matured_pos_tot underflow");
953        assert!(self.pnl_matured_pos_tot <= self.pnl_pos_tot,
954            "consume_released_pnl: pnl_matured_pos_tot > pnl_pos_tot");
955
956        // PNL_i = checked_sub_i128(PNL_i, checked_cast_i128(x))
957        let x_i128: i128 = x.try_into().expect("consume_released_pnl: x > i128::MAX");
958        let new_pnl = self.accounts[idx].pnl.checked_sub(x_i128)
959            .expect("consume_released_pnl: PNL underflow");
960        assert!(new_pnl != i128::MIN, "consume_released_pnl: PNL == i128::MIN");
961        self.accounts[idx].pnl = new_pnl;
962        // R_i remains unchanged
963    }
964    }
965
966    /// set_capital (spec §4.2): checked signed-delta update of C_tot
967    test_visible! {
968    fn set_capital(&mut self, idx: usize, new_capital: u128) {
969        let old = self.accounts[idx].capital.get();
970        if new_capital >= old {
971            let delta = new_capital - old;
972            self.c_tot = U128::new(self.c_tot.get().checked_add(delta)
973                .expect("set_capital: c_tot overflow"));
974        } else {
975            let delta = old - new_capital;
976            self.c_tot = U128::new(self.c_tot.get().checked_sub(delta)
977                .expect("set_capital: c_tot underflow"));
978        }
979        self.accounts[idx].capital = U128::new(new_capital);
980    }
981    }
982
983    /// set_position_basis_q (spec §4.4): update stored pos counts based on sign changes
984    test_visible! {
985    fn set_position_basis_q(&mut self, idx: usize, new_basis: i128) {
986        let old = self.accounts[idx].position_basis_q;
987        let old_side = side_of_i128(old);
988        let new_side = side_of_i128(new_basis);
989
990        // Decrement old side count
991        if let Some(s) = old_side {
992            match s {
993                Side::Long => {
994                    self.stored_pos_count_long = self.stored_pos_count_long
995                        .checked_sub(1).expect("set_position_basis_q: long count underflow");
996                }
997                Side::Short => {
998                    self.stored_pos_count_short = self.stored_pos_count_short
999                        .checked_sub(1).expect("set_position_basis_q: short count underflow");
1000                }
1001            }
1002        }
1003
1004        // Increment new side count
1005        if let Some(s) = new_side {
1006            match s {
1007                Side::Long => {
1008                    self.stored_pos_count_long = self.stored_pos_count_long
1009                        .checked_add(1).expect("set_position_basis_q: long count overflow");
1010                }
1011                Side::Short => {
1012                    self.stored_pos_count_short = self.stored_pos_count_short
1013                        .checked_add(1).expect("set_position_basis_q: short count overflow");
1014                }
1015            }
1016        }
1017
1018        self.accounts[idx].position_basis_q = new_basis;
1019    }
1020    }
1021
1022    /// attach_effective_position (spec §4.5)
1023    test_visible! {
1024    fn attach_effective_position(&mut self, idx: usize, new_eff_pos_q: i128) {
1025        // Before replacing a nonzero same-epoch basis, account for the fractional
1026        // remainder that will be orphaned (dynamic dust accounting).
1027        let old_basis = self.accounts[idx].position_basis_q;
1028        if old_basis != 0 {
1029            if let Some(old_side) = side_of_i128(old_basis) {
1030                let epoch_snap = self.accounts[idx].adl_epoch_snap;
1031                let epoch_side = self.get_epoch_side(old_side);
1032                if epoch_snap == epoch_side {
1033                    let a_basis = self.accounts[idx].adl_a_basis;
1034                    if a_basis != 0 {
1035                        let a_side = self.get_a_side(old_side);
1036                        let abs_basis = old_basis.unsigned_abs();
1037                        // Use U256 for the intermediate product to avoid u128 overflow
1038                        let product = U256::from_u128(abs_basis)
1039                            .checked_mul(U256::from_u128(a_side));
1040                        if let Some(p) = product {
1041                            let rem = p.checked_rem(U256::from_u128(a_basis));
1042                            if let Some(r) = rem {
1043                                if !r.is_zero() {
1044                                    self.inc_phantom_dust_bound(old_side);
1045                                }
1046                            }
1047                        }
1048                    }
1049                }
1050            }
1051        }
1052
1053        if new_eff_pos_q == 0 {
1054            self.set_position_basis_q(idx, 0i128);
1055            // Reset to canonical zero-position defaults (spec §2.4)
1056            self.accounts[idx].adl_a_basis = ADL_ONE;
1057            self.accounts[idx].adl_k_snap = 0i128;
1058            self.accounts[idx].adl_epoch_snap = 0;
1059        } else {
1060            // Spec §4.6: abs(new_eff_pos_q) <= MAX_POSITION_ABS_Q
1061            assert!(
1062                new_eff_pos_q.unsigned_abs() <= MAX_POSITION_ABS_Q,
1063                "attach: abs(new_eff_pos_q) exceeds MAX_POSITION_ABS_Q"
1064            );
1065            let side = side_of_i128(new_eff_pos_q).expect("attach: nonzero must have side");
1066            self.set_position_basis_q(idx, new_eff_pos_q);
1067
1068            match side {
1069                Side::Long => {
1070                    self.accounts[idx].adl_a_basis = self.adl_mult_long;
1071                    self.accounts[idx].adl_k_snap = self.adl_coeff_long;
1072                    self.accounts[idx].adl_epoch_snap = self.adl_epoch_long;
1073                }
1074                Side::Short => {
1075                    self.accounts[idx].adl_a_basis = self.adl_mult_short;
1076                    self.accounts[idx].adl_k_snap = self.adl_coeff_short;
1077                    self.accounts[idx].adl_epoch_snap = self.adl_epoch_short;
1078                }
1079            }
1080        }
1081    }
1082    }
1083
1084    // ========================================================================
1085    // Side state accessors
1086    // ========================================================================
1087
1088    fn get_a_side(&self, s: Side) -> u128 {
1089        match s {
1090            Side::Long => self.adl_mult_long,
1091            Side::Short => self.adl_mult_short,
1092        }
1093    }
1094
1095    fn get_k_side(&self, s: Side) -> i128 {
1096        match s {
1097            Side::Long => self.adl_coeff_long,
1098            Side::Short => self.adl_coeff_short,
1099        }
1100    }
1101
1102    fn get_epoch_side(&self, s: Side) -> u64 {
1103        match s {
1104            Side::Long => self.adl_epoch_long,
1105            Side::Short => self.adl_epoch_short,
1106        }
1107    }
1108
1109    fn get_k_epoch_start(&self, s: Side) -> i128 {
1110        match s {
1111            Side::Long => self.adl_epoch_start_k_long,
1112            Side::Short => self.adl_epoch_start_k_short,
1113        }
1114    }
1115
1116    fn get_side_mode(&self, s: Side) -> SideMode {
1117        match s {
1118            Side::Long => self.side_mode_long,
1119            Side::Short => self.side_mode_short,
1120        }
1121    }
1122
1123    fn get_oi_eff(&self, s: Side) -> u128 {
1124        match s {
1125            Side::Long => self.oi_eff_long_q,
1126            Side::Short => self.oi_eff_short_q,
1127        }
1128    }
1129
1130    fn set_oi_eff(&mut self, s: Side, v: u128) {
1131        match s {
1132            Side::Long => self.oi_eff_long_q = v,
1133            Side::Short => self.oi_eff_short_q = v,
1134        }
1135    }
1136
1137    fn set_side_mode(&mut self, s: Side, m: SideMode) {
1138        match s {
1139            Side::Long => self.side_mode_long = m,
1140            Side::Short => self.side_mode_short = m,
1141        }
1142    }
1143
1144    fn set_a_side(&mut self, s: Side, v: u128) {
1145        match s {
1146            Side::Long => self.adl_mult_long = v,
1147            Side::Short => self.adl_mult_short = v,
1148        }
1149    }
1150
1151    fn set_k_side(&mut self, s: Side, v: i128) {
1152        match s {
1153            Side::Long => self.adl_coeff_long = v,
1154            Side::Short => self.adl_coeff_short = v,
1155        }
1156    }
1157
1158    fn get_stale_count(&self, s: Side) -> u64 {
1159        match s {
1160            Side::Long => self.stale_account_count_long,
1161            Side::Short => self.stale_account_count_short,
1162        }
1163    }
1164
1165    fn set_stale_count(&mut self, s: Side, v: u64) {
1166        match s {
1167            Side::Long => self.stale_account_count_long = v,
1168            Side::Short => self.stale_account_count_short = v,
1169        }
1170    }
1171
1172    fn get_stored_pos_count(&self, s: Side) -> u64 {
1173        match s {
1174            Side::Long => self.stored_pos_count_long,
1175            Side::Short => self.stored_pos_count_short,
1176        }
1177    }
1178
1179    /// Spec §4.6: increment phantom dust bound by 1 q-unit (checked).
1180    fn inc_phantom_dust_bound(&mut self, s: Side) {
1181        match s {
1182            Side::Long => {
1183                self.phantom_dust_bound_long_q = self
1184                    .phantom_dust_bound_long_q
1185                    .checked_add(1u128)
1186                    .expect("phantom_dust_bound_long_q overflow");
1187            }
1188            Side::Short => {
1189                self.phantom_dust_bound_short_q = self
1190                    .phantom_dust_bound_short_q
1191                    .checked_add(1u128)
1192                    .expect("phantom_dust_bound_short_q overflow");
1193            }
1194        }
1195    }
1196
1197    /// Spec §4.6.1: increment phantom dust bound by amount_q (checked).
1198    fn inc_phantom_dust_bound_by(&mut self, s: Side, amount_q: u128) {
1199        match s {
1200            Side::Long => {
1201                self.phantom_dust_bound_long_q = self
1202                    .phantom_dust_bound_long_q
1203                    .checked_add(amount_q)
1204                    .expect("phantom_dust_bound_long_q overflow");
1205            }
1206            Side::Short => {
1207                self.phantom_dust_bound_short_q = self
1208                    .phantom_dust_bound_short_q
1209                    .checked_add(amount_q)
1210                    .expect("phantom_dust_bound_short_q overflow");
1211            }
1212        }
1213    }
1214
1215    // ========================================================================
1216    // effective_pos_q (spec §5.2)
1217    // ========================================================================
1218
1219    /// Compute effective position quantity for account idx.
1220    pub fn effective_pos_q(&self, idx: usize) -> i128 {
1221        let basis = self.accounts[idx].position_basis_q;
1222        if basis == 0 {
1223            return 0i128;
1224        }
1225
1226        let side = side_of_i128(basis).unwrap();
1227        let epoch_snap = self.accounts[idx].adl_epoch_snap;
1228        let epoch_side = self.get_epoch_side(side);
1229
1230        if epoch_snap != epoch_side {
1231            // Epoch mismatch → effective position is 0 for current-market risk
1232            return 0i128;
1233        }
1234
1235        let a_side = self.get_a_side(side);
1236        let a_basis = self.accounts[idx].adl_a_basis;
1237
1238        if a_basis == 0 {
1239            return 0i128;
1240        }
1241
1242        let abs_basis = basis.unsigned_abs();
1243        // floor(|basis| * A_s / a_basis)
1244        let effective_abs = mul_div_floor_u128(abs_basis, a_side, a_basis);
1245
1246        if basis < 0 {
1247            if effective_abs == 0 {
1248                0i128
1249            } else {
1250                assert!(
1251                    effective_abs <= i128::MAX as u128,
1252                    "effective_pos_q: overflow"
1253                );
1254                -(effective_abs as i128)
1255            }
1256        } else {
1257            assert!(
1258                effective_abs <= i128::MAX as u128,
1259                "effective_pos_q: overflow"
1260            );
1261            effective_abs as i128
1262        }
1263    }
1264
1265    // ========================================================================
1266    // settle_side_effects (spec §5.3)
1267    // ========================================================================
1268
1269    test_visible! {
1270    fn settle_side_effects(&mut self, idx: usize) -> Result<()> {
1271        let basis = self.accounts[idx].position_basis_q;
1272        if basis == 0 {
1273            return Ok(());
1274        }
1275
1276        let side = side_of_i128(basis).unwrap();
1277        let epoch_snap = self.accounts[idx].adl_epoch_snap;
1278        let epoch_side = self.get_epoch_side(side);
1279        let a_basis = self.accounts[idx].adl_a_basis;
1280
1281        if a_basis == 0 {
1282            return Err(RiskError::CorruptState);
1283        }
1284
1285        let abs_basis = basis.unsigned_abs();
1286
1287        if epoch_snap == epoch_side {
1288            // Same epoch (spec §5.3 step 4)
1289            let a_side = self.get_a_side(side);
1290            let k_side = self.get_k_side(side);
1291            let k_snap = self.accounts[idx].adl_k_snap;
1292
1293            // q_eff_new = floor(|basis| * A_s / a_basis)
1294            let q_eff_new = mul_div_floor_u128(abs_basis, a_side, a_basis);
1295
1296            // Record old_R before set_pnl (spec §5.3)
1297            let old_r = self.accounts[idx].reserved_pnl;
1298
1299            // pnl_delta (spec §5.3 step 4: k_then=k_snap, k_now=K_s)
1300            let den = a_basis.checked_mul(POS_SCALE).ok_or(RiskError::Overflow)?;
1301            let pnl_delta = wide_signed_mul_div_floor_from_k_pair(abs_basis, k_snap, k_side, den);
1302
1303            let old_pnl = self.accounts[idx].pnl;
1304            let new_pnl = old_pnl.checked_add(pnl_delta).ok_or(RiskError::Overflow)?;
1305            if new_pnl == i128::MIN {
1306                return Err(RiskError::Overflow);
1307            }
1308            self.set_pnl(idx, new_pnl);
1309
1310            // Caller obligation: if R_i increased, restart warmup (spec §4.4 / §5.3)
1311            if self.accounts[idx].reserved_pnl > old_r {
1312                self.restart_warmup_after_reserve_increase(idx);
1313            }
1314
1315            if q_eff_new == 0 {
1316                // Position effectively zeroed (spec §5.3 step 4)
1317                // Reset to canonical zero-position defaults (spec §2.4)
1318                self.inc_phantom_dust_bound(side);
1319                self.set_position_basis_q(idx, 0i128);
1320                self.accounts[idx].adl_a_basis = ADL_ONE;
1321                self.accounts[idx].adl_k_snap = 0i128;
1322                self.accounts[idx].adl_epoch_snap = 0;
1323            } else {
1324                // Update k_snap only; do NOT change basis or a_basis (non-compounding)
1325                self.accounts[idx].adl_k_snap = k_side;
1326                self.accounts[idx].adl_epoch_snap = epoch_side;
1327            }
1328        } else {
1329            // Epoch mismatch (spec §5.3 step 5)
1330            // Validate-then-mutate: all fallible checks before any mutation.
1331            let side_mode = self.get_side_mode(side);
1332            if side_mode != SideMode::ResetPending {
1333                return Err(RiskError::CorruptState);
1334            }
1335            if epoch_snap.checked_add(1) != Some(epoch_side) {
1336                return Err(RiskError::CorruptState);
1337            }
1338
1339            let k_epoch_start = self.get_k_epoch_start(side);
1340            let k_snap = self.accounts[idx].adl_k_snap;
1341
1342            // Phase 1: COMPUTE + VALIDATE (no mutations)
1343            let den = a_basis.checked_mul(POS_SCALE).ok_or(RiskError::Overflow)?;
1344            let pnl_delta = wide_signed_mul_div_floor_from_k_pair(abs_basis, k_snap, k_epoch_start, den);
1345
1346            let new_pnl = self.accounts[idx].pnl.checked_add(pnl_delta).ok_or(RiskError::Overflow)?;
1347            if new_pnl == i128::MIN {
1348                return Err(RiskError::Overflow);
1349            }
1350
1351            let old_stale = self.get_stale_count(side);
1352            let new_stale = old_stale.checked_sub(1).ok_or(RiskError::CorruptState)?;
1353
1354            // Phase 2: MUTATE (all validated)
1355            let old_r = self.accounts[idx].reserved_pnl;
1356            self.set_pnl(idx, new_pnl);
1357
1358            if self.accounts[idx].reserved_pnl > old_r {
1359                self.restart_warmup_after_reserve_increase(idx);
1360            }
1361
1362            self.set_position_basis_q(idx, 0i128);
1363            self.set_stale_count(side, new_stale);
1364
1365            // Reset to canonical zero-position defaults (spec §2.4)
1366            self.accounts[idx].adl_a_basis = ADL_ONE;
1367            self.accounts[idx].adl_k_snap = 0i128;
1368            self.accounts[idx].adl_epoch_snap = 0;
1369        }
1370
1371        Ok(())
1372    }
1373    }
1374
1375    // ========================================================================
1376    // accrue_market_to (spec §5.4)
1377    // ========================================================================
1378
1379    pub fn accrue_market_to(&mut self, now_slot: u64, oracle_price: u64) -> Result<()> {
1380        if oracle_price == 0 || oracle_price > MAX_ORACLE_PRICE {
1381            return Err(RiskError::Overflow);
1382        }
1383
1384        // Time monotonicity (spec §5.4 preconditions)
1385        if now_slot < self.current_slot {
1386            return Err(RiskError::Overflow);
1387        }
1388        if now_slot < self.last_market_slot {
1389            return Err(RiskError::Overflow);
1390        }
1391
1392        // Step 4: snapshot OI at start (fixed for all sub-steps per spec §5.4)
1393        let long_live = self.oi_eff_long_q != 0;
1394        let short_live = self.oi_eff_short_q != 0;
1395
1396        let total_dt = now_slot.saturating_sub(self.last_market_slot);
1397        if total_dt == 0 && self.last_oracle_price == oracle_price {
1398            // Step 5: no change — set current_slot and return (spec §5.4)
1399            self.current_slot = now_slot;
1400            return Ok(());
1401        }
1402
1403        // Use scratch K values for the entire mark + funding computation.
1404        // Only commit to engine state after ALL computations succeed.
1405        // This prevents partial K advancement on mid-function errors.
1406        let mut k_long = self.adl_coeff_long;
1407        let mut k_short = self.adl_coeff_short;
1408
1409        // Step 5: Mark-to-market (once, spec §1.5 item 21)
1410        let current_price = self.last_oracle_price;
1411        let delta_p = (oracle_price as i128)
1412            .checked_sub(current_price as i128)
1413            .ok_or(RiskError::Overflow)?;
1414        if delta_p != 0 {
1415            if long_live {
1416                let dk = checked_u128_mul_i128(self.adl_mult_long, delta_p)?;
1417                k_long = k_long.checked_add(dk).ok_or(RiskError::Overflow)?;
1418            }
1419            if short_live {
1420                let dk = checked_u128_mul_i128(self.adl_mult_short, delta_p)?;
1421                k_short = k_short.checked_sub(dk).ok_or(RiskError::Overflow)?;
1422            }
1423        }
1424
1425        // Step 6: Funding transfer via sub-stepping (spec v12.1.0 §5.4)
1426        let r_last = self.funding_rate_bps_per_slot_last;
1427        if r_last != 0 && total_dt > 0 && long_live && short_live {
1428            let fund_px_0 = self.funding_price_sample_last;
1429
1430            if fund_px_0 > 0 {
1431                let mut dt_remaining = total_dt;
1432
1433                while dt_remaining > 0 {
1434                    let dt_sub = core::cmp::min(dt_remaining, MAX_FUNDING_DT);
1435                    dt_remaining -= dt_sub;
1436
1437                    let fund_num: i128 = (fund_px_0 as i128)
1438                        .checked_mul(r_last as i128)
1439                        .ok_or(RiskError::Overflow)?
1440                        .checked_mul(dt_sub as i128)
1441                        .ok_or(RiskError::Overflow)?;
1442
1443                    let fund_term = floor_div_signed_conservative_i128(fund_num, 10_000u128);
1444
1445                    if fund_term != 0 {
1446                        let dk_long = checked_u128_mul_i128(self.adl_mult_long, fund_term)?;
1447                        k_long = k_long.checked_sub(dk_long).ok_or(RiskError::Overflow)?;
1448                        let dk_short = checked_u128_mul_i128(self.adl_mult_short, fund_term)?;
1449                        k_short = k_short.checked_add(dk_short).ok_or(RiskError::Overflow)?;
1450                    }
1451                }
1452            }
1453        }
1454
1455        // ALL computations succeeded — commit K values and synchronize state
1456        self.adl_coeff_long = k_long;
1457        self.adl_coeff_short = k_short;
1458        self.current_slot = now_slot;
1459        self.last_market_slot = now_slot;
1460        self.last_oracle_price = oracle_price;
1461        self.funding_price_sample_last = oracle_price;
1462
1463        Ok(())
1464    }
1465
1466    /// Pre-validate funding rate bound (called at top of each instruction,
1467    /// before any mutations, so bad rates never cause partial-mutation errors).
1468    fn validate_funding_rate(rate: i64) -> Result<()> {
1469        if rate.unsigned_abs() > MAX_ABS_FUNDING_BPS_PER_SLOT as u64 {
1470            return Err(RiskError::Overflow);
1471        }
1472        Ok(())
1473    }
1474
1475    /// recompute_r_last_from_final_state (spec v12.1.0 §4.12).
1476    /// Stores the pre-validated funding rate for the next interval.
1477    test_visible! {
1478    fn recompute_r_last_from_final_state(&mut self, externally_computed_rate: i64) -> Result<()> {
1479        // Rate already validated at instruction entry; store unconditionally.
1480        // Belt-and-suspenders: re-check here too.
1481        if externally_computed_rate.unsigned_abs() > MAX_ABS_FUNDING_BPS_PER_SLOT as u64 {
1482            return Err(RiskError::Overflow);
1483        }
1484        self.funding_rate_bps_per_slot_last = externally_computed_rate;
1485        Ok(())
1486    }
1487    }
1488
1489    /// Public entry-point for the end-of-instruction lifecycle
1490    /// (spec §10.0 steps 4-7 / §10.8 steps 9-12).
1491    ///
1492    /// Runs schedule_end_of_instruction_resets, finalize, and
1493    /// recompute_r_last_from_final_state in the canonical order.
1494    /// Callers that bypass `keeper_crank_not_atomic` (e.g. the resolved-market
1495    /// settlement crank) must invoke this before returning.
1496    pub fn run_end_of_instruction_lifecycle(
1497        &mut self,
1498        ctx: &mut InstructionContext,
1499        funding_rate: i64,
1500    ) -> Result<()> {
1501        Self::validate_funding_rate(funding_rate)?;
1502
1503        self.schedule_end_of_instruction_resets(ctx)?;
1504        self.finalize_end_of_instruction_resets(ctx);
1505        self.recompute_r_last_from_final_state(funding_rate)?;
1506        Ok(())
1507    }
1508
1509    // ========================================================================
1510    // absorb_protocol_loss (spec §4.7)
1511    // ========================================================================
1512
1513    /// use_insurance_buffer (spec §4.11): deduct loss from insurance down to floor,
1514    /// return the remaining uninsured loss.
1515    fn use_insurance_buffer(&mut self, loss: u128) -> u128 {
1516        if loss == 0 {
1517            return 0;
1518        }
1519        let ins_bal = self.insurance_fund.balance.get();
1520        let available = ins_bal.saturating_sub(self.params.insurance_floor.get());
1521        let pay = core::cmp::min(loss, available);
1522        if pay > 0 {
1523            self.insurance_fund.balance = U128::new(ins_bal - pay);
1524        }
1525        loss - pay
1526    }
1527
1528    /// absorb_protocol_loss (spec §4.11): use_insurance_buffer then record
1529    /// any remaining uninsured loss as implicit haircut.
1530    test_visible! {
1531    fn absorb_protocol_loss(&mut self, loss: u128) {
1532        if loss == 0 {
1533            return;
1534        }
1535        let _rem = self.use_insurance_buffer(loss);
1536        // Remaining loss is implicit haircut through h
1537    }
1538    }
1539
1540    // ========================================================================
1541    // enqueue_adl (spec §5.6)
1542    // ========================================================================
1543
1544    test_visible! {
1545    fn enqueue_adl(&mut self, ctx: &mut InstructionContext, liq_side: Side, q_close_q: u128, d: u128) -> Result<()> {
1546        let opp = opposite_side(liq_side);
1547
1548        // Step 1: decrease liquidated side OI (checked — underflow is corrupt state)
1549        if q_close_q != 0 {
1550            let old_oi = self.get_oi_eff(liq_side);
1551            let new_oi = old_oi.checked_sub(q_close_q).ok_or(RiskError::CorruptState)?;
1552            self.set_oi_eff(liq_side, new_oi);
1553        }
1554
1555        // Step 2 (§5.6 step 2): insurance-first deficit coverage
1556        let d_rem = if d > 0 { self.use_insurance_buffer(d) } else { 0u128 };
1557
1558        // Step 3: read opposing OI
1559        let oi = self.get_oi_eff(opp);
1560
1561        // Step 4 (§5.6 step 4): if OI == 0
1562        if oi == 0 {
1563            // D_rem > 0 → record_uninsured_protocol_loss (implicit through h, no-op)
1564            if self.get_oi_eff(liq_side) == 0 {
1565                set_pending_reset(ctx, liq_side);
1566                set_pending_reset(ctx, opp);
1567            }
1568            return Ok(());
1569        }
1570
1571        // Step 5 (§5.6 step 5): if OI > 0 and stored_pos_count_opp == 0,
1572        // route deficit through record_uninsured and do NOT modify K_opp.
1573        if self.get_stored_pos_count(opp) == 0 {
1574            if q_close_q > oi {
1575                return Err(RiskError::CorruptState);
1576            }
1577            let oi_post = oi.checked_sub(q_close_q).ok_or(RiskError::Overflow)?;
1578            // D_rem > 0 → record_uninsured_protocol_loss (implicit through h, no-op)
1579            self.set_oi_eff(opp, oi_post);
1580            if oi_post == 0 {
1581                // Unconditionally reset the drained opp side (fixes phantom dust revert).
1582                set_pending_reset(ctx, opp);
1583                // Also reset liq_side only if it too has zero OI
1584                if self.get_oi_eff(liq_side) == 0 {
1585                    set_pending_reset(ctx, liq_side);
1586                }
1587            }
1588            return Ok(());
1589        }
1590
1591        // Step 6 (§5.6 step 6): require q_close_q <= OI
1592        if q_close_q > oi {
1593            return Err(RiskError::CorruptState);
1594        }
1595
1596        let a_old = self.get_a_side(opp);
1597        let oi_post = oi.checked_sub(q_close_q).ok_or(RiskError::Overflow)?;
1598
1599        // Step 7 (§5.6 step 7): handle D_rem > 0 (quote deficit after insurance)
1600        // Fused delta_K_abs = ceil(D_rem * A_old * POS_SCALE / OI)
1601        // Per §1.5 Rule 14: if the quotient doesn't fit in i128, route to
1602        // record_uninsured_protocol_loss instead of panicking.
1603        if d_rem != 0 {
1604            let a_ps = a_old.checked_mul(POS_SCALE).ok_or(RiskError::Overflow)?;
1605            match wide_mul_div_ceil_u128_or_over_i128max(d_rem, a_ps, oi) {
1606                Ok(delta_k_abs) => {
1607                    let delta_k = -(delta_k_abs as i128);
1608                    let k_opp = self.get_k_side(opp);
1609                    match k_opp.checked_add(delta_k) {
1610                        Some(new_k) => {
1611                            self.set_k_side(opp, new_k);
1612                        }
1613                        None => {
1614                            // K-space overflow: record_uninsured (no-op)
1615                        }
1616                    }
1617                }
1618                Err(OverI128Magnitude) => {
1619                    // Quotient overflow: record_uninsured (no-op)
1620                }
1621            }
1622        }
1623
1624        // Step 8 (§5.6 step 8): if OI_post == 0
1625        if oi_post == 0 {
1626            self.set_oi_eff(opp, 0u128);
1627            set_pending_reset(ctx, opp);
1628            if self.get_oi_eff(liq_side) == 0 {
1629                set_pending_reset(ctx, liq_side);
1630            }
1631            return Ok(());
1632        }
1633
1634        // Steps 8-9: compute A_candidate and A_trunc_rem using U256 intermediates
1635        let a_old_u256 = U256::from_u128(a_old);
1636        let oi_post_u256 = U256::from_u128(oi_post);
1637        let oi_u256 = U256::from_u128(oi);
1638        let (a_candidate_u256, a_trunc_rem) = mul_div_floor_u256_with_rem(
1639            a_old_u256,
1640            oi_post_u256,
1641            oi_u256,
1642        );
1643
1644        // Step 10: A_candidate > 0
1645        if !a_candidate_u256.is_zero() {
1646            let a_new = a_candidate_u256.try_into_u128().expect("A_candidate exceeds u128");
1647            self.set_a_side(opp, a_new);
1648            self.set_oi_eff(opp, oi_post);
1649            // Only account for global A-truncation dust when actual truncation occurs
1650            if !a_trunc_rem.is_zero() {
1651                let n_opp = self.get_stored_pos_count(opp) as u128;
1652                let n_opp_u256 = U256::from_u128(n_opp);
1653                // global_a_dust_bound = N_opp + ceil((OI + N_opp) / A_old)
1654                let oi_plus_n = oi_u256.checked_add(n_opp_u256).unwrap_or(U256::MAX);
1655                let ceil_term = ceil_div_positive_checked(oi_plus_n, a_old_u256);
1656                let global_a_dust_bound = n_opp_u256.checked_add(ceil_term)
1657                    .unwrap_or(U256::MAX);
1658                let bound_u128 = global_a_dust_bound.try_into_u128().unwrap_or(u128::MAX);
1659                self.inc_phantom_dust_bound_by(opp, bound_u128);
1660            }
1661            if a_new < MIN_A_SIDE {
1662                self.set_side_mode(opp, SideMode::DrainOnly);
1663            }
1664            return Ok(());
1665        }
1666
1667        // Step 11: precision exhaustion terminal drain
1668        self.set_oi_eff(opp, 0u128);
1669        self.set_oi_eff(liq_side, 0u128);
1670        set_pending_reset(ctx, opp);
1671        set_pending_reset(ctx, liq_side);
1672
1673        Ok(())
1674    }
1675    }
1676
1677    // ========================================================================
1678    // begin_full_drain_reset / finalize_side_reset (spec §2.5, §2.7)
1679    // ========================================================================
1680
1681    test_visible! {
1682    fn begin_full_drain_reset(&mut self, side: Side) {
1683        // Require OI_eff_side == 0
1684        assert!(self.get_oi_eff(side) == 0, "begin_full_drain_reset: OI not zero");
1685
1686        // K_epoch_start_side = K_side
1687        let k = self.get_k_side(side);
1688        match side {
1689            Side::Long => self.adl_epoch_start_k_long = k,
1690            Side::Short => self.adl_epoch_start_k_short = k,
1691        }
1692
1693        // Increment epoch
1694        match side {
1695            Side::Long => self.adl_epoch_long = self.adl_epoch_long.checked_add(1)
1696                .expect("epoch overflow"),
1697            Side::Short => self.adl_epoch_short = self.adl_epoch_short.checked_add(1)
1698                .expect("epoch overflow"),
1699        }
1700
1701        // A_side = ADL_ONE
1702        self.set_a_side(side, ADL_ONE);
1703
1704        // stale_account_count_side = stored_pos_count_side
1705        let spc = self.get_stored_pos_count(side);
1706        self.set_stale_count(side, spc);
1707
1708        // phantom_dust_bound_side_q = 0 (spec §2.5 step 6)
1709        match side {
1710            Side::Long => self.phantom_dust_bound_long_q = 0u128,
1711            Side::Short => self.phantom_dust_bound_short_q = 0u128,
1712        }
1713
1714        // mode = ResetPending
1715        self.set_side_mode(side, SideMode::ResetPending);
1716    }
1717    }
1718
1719    test_visible! {
1720    fn finalize_side_reset(&mut self, side: Side) -> Result<()> {
1721        if self.get_side_mode(side) != SideMode::ResetPending {
1722            return Err(RiskError::CorruptState);
1723        }
1724        if self.get_oi_eff(side) != 0 {
1725            return Err(RiskError::CorruptState);
1726        }
1727        if self.get_stale_count(side) != 0 {
1728            return Err(RiskError::CorruptState);
1729        }
1730        if self.get_stored_pos_count(side) != 0 {
1731            return Err(RiskError::CorruptState);
1732        }
1733        self.set_side_mode(side, SideMode::Normal);
1734        Ok(())
1735    }
1736    }
1737
1738    // ========================================================================
1739    // schedule_end_of_instruction_resets / finalize (spec §5.7-5.8)
1740    // ========================================================================
1741
1742    test_visible! {
1743    fn schedule_end_of_instruction_resets(&mut self, ctx: &mut InstructionContext) -> Result<()> {
1744        // §5.7.A: Bilateral-empty dust clearance
1745        if self.stored_pos_count_long == 0 && self.stored_pos_count_short == 0 {
1746            let clear_bound_q = self.phantom_dust_bound_long_q
1747                .checked_add(self.phantom_dust_bound_short_q)
1748                .ok_or(RiskError::CorruptState)?;
1749            let has_residual = self.oi_eff_long_q != 0
1750                || self.oi_eff_short_q != 0
1751                || self.phantom_dust_bound_long_q != 0
1752                || self.phantom_dust_bound_short_q != 0;
1753            if has_residual {
1754                if self.oi_eff_long_q != self.oi_eff_short_q {
1755                    return Err(RiskError::CorruptState);
1756                }
1757                if self.oi_eff_long_q <= clear_bound_q && self.oi_eff_short_q <= clear_bound_q {
1758                    self.oi_eff_long_q = 0u128;
1759                    self.oi_eff_short_q = 0u128;
1760                    ctx.pending_reset_long = true;
1761                    ctx.pending_reset_short = true;
1762                } else {
1763                    return Err(RiskError::CorruptState);
1764                }
1765            }
1766        }
1767        // §5.7.B: Unilateral-empty long (long empty, short has positions)
1768        else if self.stored_pos_count_long == 0 && self.stored_pos_count_short > 0 {
1769            let has_residual = self.oi_eff_long_q != 0
1770                || self.oi_eff_short_q != 0
1771                || self.phantom_dust_bound_long_q != 0;
1772            if has_residual {
1773                if self.oi_eff_long_q != self.oi_eff_short_q {
1774                    return Err(RiskError::CorruptState);
1775                }
1776                if self.oi_eff_long_q <= self.phantom_dust_bound_long_q {
1777                    self.oi_eff_long_q = 0u128;
1778                    self.oi_eff_short_q = 0u128;
1779                    ctx.pending_reset_long = true;
1780                    ctx.pending_reset_short = true;
1781                } else {
1782                    return Err(RiskError::CorruptState);
1783                }
1784            }
1785        }
1786        // §5.7.C: Unilateral-empty short (short empty, long has positions)
1787        else if self.stored_pos_count_short == 0 && self.stored_pos_count_long > 0 {
1788            let has_residual = self.oi_eff_long_q != 0
1789                || self.oi_eff_short_q != 0
1790                || self.phantom_dust_bound_short_q != 0;
1791            if has_residual {
1792                if self.oi_eff_long_q != self.oi_eff_short_q {
1793                    return Err(RiskError::CorruptState);
1794                }
1795                if self.oi_eff_short_q <= self.phantom_dust_bound_short_q {
1796                    self.oi_eff_long_q = 0u128;
1797                    self.oi_eff_short_q = 0u128;
1798                    ctx.pending_reset_long = true;
1799                    ctx.pending_reset_short = true;
1800                } else {
1801                    return Err(RiskError::CorruptState);
1802                }
1803            }
1804        }
1805
1806        // §5.7.D: DrainOnly sides with zero OI
1807        if self.side_mode_long == SideMode::DrainOnly && self.oi_eff_long_q == 0 {
1808            ctx.pending_reset_long = true;
1809        }
1810        if self.side_mode_short == SideMode::DrainOnly && self.oi_eff_short_q == 0 {
1811            ctx.pending_reset_short = true;
1812        }
1813
1814        Ok(())
1815    }
1816    }
1817
1818    test_visible! {
1819    fn finalize_end_of_instruction_resets(&mut self, ctx: &InstructionContext) {
1820        if ctx.pending_reset_long && self.side_mode_long != SideMode::ResetPending {
1821            self.begin_full_drain_reset(Side::Long);
1822        }
1823        if ctx.pending_reset_short && self.side_mode_short != SideMode::ResetPending {
1824            self.begin_full_drain_reset(Side::Short);
1825        }
1826        // Auto-finalize sides that are fully ready for reopening
1827        self.maybe_finalize_ready_reset_sides();
1828    }
1829    }
1830
1831    /// Preflight finalize: if a side is ResetPending with OI=0, stale=0, pos_count=0,
1832    /// transition it back to Normal so fresh OI can be added.
1833    /// Called before OI-increase gating and at end-of-instruction.
1834    fn maybe_finalize_ready_reset_sides(&mut self) {
1835        if self.side_mode_long == SideMode::ResetPending
1836            && self.get_oi_eff(Side::Long) == 0
1837            && self.get_stale_count(Side::Long) == 0
1838            && self.get_stored_pos_count(Side::Long) == 0
1839        {
1840            self.set_side_mode(Side::Long, SideMode::Normal);
1841        }
1842        if self.side_mode_short == SideMode::ResetPending
1843            && self.get_oi_eff(Side::Short) == 0
1844            && self.get_stale_count(Side::Short) == 0
1845            && self.get_stored_pos_count(Side::Short) == 0
1846        {
1847            self.set_side_mode(Side::Short, SideMode::Normal);
1848        }
1849    }
1850
1851    // ========================================================================
1852    // Haircut and Equity (spec §3)
1853    // ========================================================================
1854
1855    /// Compute haircut ratio (h_num, h_den) as u128 pair (spec §3.3)
1856    /// Uses pnl_matured_pos_tot as denominator per v12.1.0.
1857    pub fn haircut_ratio(&self) -> (u128, u128) {
1858        if self.pnl_matured_pos_tot == 0 {
1859            return (1u128, 1u128);
1860        }
1861        let senior_sum = self
1862            .c_tot
1863            .get()
1864            .checked_add(self.insurance_fund.balance.get());
1865        let residual: u128 = match senior_sum {
1866            Some(ss) => {
1867                if self.vault.get() >= ss {
1868                    self.vault.get() - ss
1869                } else {
1870                    0u128
1871                }
1872            }
1873            None => 0u128, // overflow in senior_sum → deficit
1874        };
1875        let h_num = if residual < self.pnl_matured_pos_tot {
1876            residual
1877        } else {
1878            self.pnl_matured_pos_tot
1879        };
1880        (h_num, self.pnl_matured_pos_tot)
1881    }
1882
1883    /// PNL_eff_matured_i (spec §3.3): haircutted matured released positive PnL
1884    pub fn effective_matured_pnl(&self, idx: usize) -> u128 {
1885        let released = self.released_pos(idx);
1886        if released == 0 {
1887            return 0u128;
1888        }
1889        let (h_num, h_den) = self.haircut_ratio();
1890        if h_den == 0 {
1891            return released;
1892        }
1893        wide_mul_div_floor_u128(released, h_num, h_den)
1894    }
1895
1896    /// Eq_maint_raw_i (spec §3.4): C_i + PNL_i - FeeDebt_i in exact widened signed domain.
1897    /// For maintenance margin and one-sided health checks. Uses full local PNL_i.
1898    /// Returns i128. Negative overflow is projected to i128::MIN + 1 per §3.4
1899    /// (safe for one-sided checks against nonneg thresholds). For strict
1900    /// before/after buffer comparisons, use account_equity_maint_raw_wide.
1901    pub fn account_equity_maint_raw(&self, account: &Account) -> i128 {
1902        let wide = self.account_equity_maint_raw_wide(account);
1903        match wide.try_into_i128() {
1904            Some(v) => v,
1905            None => {
1906                // Positive overflow: unreachable under configured bounds (spec §3.4),
1907                // but MUST fail conservatively — account is over-collateralized,
1908                // so project to i128::MAX to prevent false liquidation.
1909                // Negative overflow: project to i128::MIN + 1 per spec §3.4.
1910                if wide.is_negative() {
1911                    i128::MIN + 1
1912                } else {
1913                    i128::MAX
1914                }
1915            }
1916        }
1917    }
1918
1919    /// Eq_maint_raw_i in exact I256 (spec §3.4 "transient widened signed type").
1920    /// MUST be used for strict before/after raw maintenance-buffer comparisons
1921    /// (§10.5 step 29). No saturation or clamping.
1922    pub fn account_equity_maint_raw_wide(&self, account: &Account) -> I256 {
1923        let cap = I256::from_u128(account.capital.get());
1924        let pnl = I256::from_i128(account.pnl);
1925        let fee_debt = I256::from_u128(fee_debt_u128_checked(account.fee_credits.get()));
1926
1927        // C + PNL - FeeDebt in exact I256 — cannot overflow 256 bits
1928        let sum = cap.checked_add(pnl).expect("I256 add overflow");
1929        sum.checked_sub(fee_debt).expect("I256 sub overflow")
1930    }
1931
1932    /// Eq_net_i (spec §3.4): max(0, Eq_maint_raw_i). For maintenance margin checks.
1933    pub fn account_equity_net(&self, account: &Account, _oracle_price: u64) -> i128 {
1934        let raw = self.account_equity_maint_raw(account);
1935        if raw < 0 {
1936            0i128
1937        } else {
1938            raw
1939        }
1940    }
1941
1942    /// Eq_init_raw_i (spec §3.4): C_i + min(PNL_i, 0) + PNL_eff_matured_i - FeeDebt_i
1943    /// For initial margin and withdrawal checks. Uses haircutted matured PnL only.
1944    /// Returns i128. Negative overflow projected to i128::MIN + 1 per §3.4.
1945    pub fn account_equity_init_raw(&self, account: &Account, idx: usize) -> i128 {
1946        let cap = I256::from_u128(account.capital.get());
1947        let neg_pnl = I256::from_i128(if account.pnl < 0 { account.pnl } else { 0i128 });
1948        let eff_matured = I256::from_u128(self.effective_matured_pnl(idx));
1949        let fee_debt = I256::from_u128(fee_debt_u128_checked(account.fee_credits.get()));
1950
1951        let sum = cap
1952            .checked_add(neg_pnl)
1953            .expect("I256 add overflow")
1954            .checked_add(eff_matured)
1955            .expect("I256 add overflow")
1956            .checked_sub(fee_debt)
1957            .expect("I256 sub overflow");
1958
1959        match sum.try_into_i128() {
1960            Some(v) => v,
1961            None => {
1962                // Positive overflow: unreachable under configured bounds (spec §3.4),
1963                // but MUST fail conservatively — project to i128::MAX.
1964                // Negative overflow: project to i128::MIN + 1 per spec §3.4.
1965                if sum.is_negative() {
1966                    i128::MIN + 1
1967                } else {
1968                    i128::MAX
1969                }
1970            }
1971        }
1972    }
1973
1974    /// Eq_init_net_i (spec §3.4): max(0, Eq_init_raw_i). For IM/withdrawal checks.
1975    pub fn account_equity_init_net(&self, account: &Account, idx: usize) -> i128 {
1976        let raw = self.account_equity_init_raw(account, idx);
1977        if raw < 0 {
1978            0i128
1979        } else {
1980            raw
1981        }
1982    }
1983
1984    /// notional (spec §9.1): floor(|effective_pos_q| * oracle_price / POS_SCALE)
1985    pub fn notional(&self, idx: usize, oracle_price: u64) -> u128 {
1986        let eff = self.effective_pos_q(idx);
1987        if eff == 0 {
1988            return 0;
1989        }
1990        let abs_eff = eff.unsigned_abs();
1991        mul_div_floor_u128(abs_eff, oracle_price as u128, POS_SCALE)
1992    }
1993
1994    /// is_above_maintenance_margin (spec §9.1): Eq_net_i > MM_req_i
1995    /// Per spec §9.1: if eff == 0 then MM_req = 0; else MM_req = max(proportional, MIN_NONZERO_MM_REQ)
1996    pub fn is_above_maintenance_margin(
1997        &self,
1998        account: &Account,
1999        idx: usize,
2000        oracle_price: u64,
2001    ) -> bool {
2002        let eq_net = self.account_equity_net(account, oracle_price);
2003        let eff = self.effective_pos_q(idx);
2004        if eff == 0 {
2005            return eq_net > 0;
2006        }
2007        let not = self.notional(idx, oracle_price);
2008        let proportional =
2009            mul_div_floor_u128(not, self.params.maintenance_margin_bps as u128, 10_000);
2010        let mm_req = core::cmp::max(proportional, self.params.min_nonzero_mm_req);
2011        let mm_req_i128 = if mm_req > i128::MAX as u128 {
2012            i128::MAX
2013        } else {
2014            mm_req as i128
2015        };
2016        eq_net > mm_req_i128
2017    }
2018
2019    /// is_above_initial_margin (spec §9.1): exact Eq_init_raw_i >= IM_req_i
2020    /// Per spec §9.1: if eff == 0 then IM_req = 0; else IM_req = max(proportional, MIN_NONZERO_IM_REQ)
2021    /// Per spec §3.4: MUST use exact raw equity, not clamped Eq_init_net_i,
2022    /// so negative raw equity is distinguishable from zero.
2023    pub fn is_above_initial_margin(
2024        &self,
2025        account: &Account,
2026        idx: usize,
2027        oracle_price: u64,
2028    ) -> bool {
2029        let eq_init_raw = self.account_equity_init_raw(account, idx);
2030        let eff = self.effective_pos_q(idx);
2031        if eff == 0 {
2032            return eq_init_raw >= 0;
2033        }
2034        let not = self.notional(idx, oracle_price);
2035        let proportional = mul_div_floor_u128(not, self.params.initial_margin_bps as u128, 10_000);
2036        let im_req = core::cmp::max(proportional, self.params.min_nonzero_im_req);
2037        let im_req_i128 = if im_req > i128::MAX as u128 {
2038            i128::MAX
2039        } else {
2040            im_req as i128
2041        };
2042        eq_init_raw >= im_req_i128
2043    }
2044
2045    // ========================================================================
2046    // Conservation check (spec §3.1)
2047    // ========================================================================
2048
2049    pub fn check_conservation(&self) -> bool {
2050        let senior = self
2051            .c_tot
2052            .get()
2053            .checked_add(self.insurance_fund.balance.get());
2054        match senior {
2055            Some(s) => self.vault.get() >= s,
2056            None => false,
2057        }
2058    }
2059
2060    // ========================================================================
2061    // Warmup Helpers (spec §6)
2062    // ========================================================================
2063
2064    /// released_pos (spec §2.1): ReleasedPos_i = max(PNL_i, 0) - R_i
2065    pub fn released_pos(&self, idx: usize) -> u128 {
2066        let pnl = self.accounts[idx].pnl;
2067        let pos_pnl = i128_clamp_pos(pnl);
2068        pos_pnl.saturating_sub(self.accounts[idx].reserved_pnl)
2069    }
2070
2071    /// restart_warmup_after_reserve_increase (spec §4.9)
2072    /// Caller obligation: MUST be called after set_pnl increases R_i.
2073    test_visible! {
2074    fn restart_warmup_after_reserve_increase(&mut self, idx: usize) {
2075        let t = self.params.warmup_period_slots;
2076        if t == 0 {
2077            // Instantaneous warmup: release all reserve immediately
2078            self.set_reserved_pnl(idx, 0);
2079            self.accounts[idx].warmup_slope_per_step = 0;
2080            self.accounts[idx].warmup_started_at_slot = self.current_slot;
2081            return;
2082        }
2083        let r = self.accounts[idx].reserved_pnl;
2084        if r == 0 {
2085            self.accounts[idx].warmup_slope_per_step = 0;
2086            self.accounts[idx].warmup_started_at_slot = self.current_slot;
2087            return;
2088        }
2089        // slope = max(1, floor(R_i / T))
2090        let base = r / (t as u128);
2091        let slope = if base == 0 { 1u128 } else { base };
2092        self.accounts[idx].warmup_slope_per_step = slope;
2093        self.accounts[idx].warmup_started_at_slot = self.current_slot;
2094    }
2095    }
2096
2097    /// advance_profit_warmup (spec §4.9)
2098    test_visible! {
2099    fn advance_profit_warmup(&mut self, idx: usize) {
2100        let r = self.accounts[idx].reserved_pnl;
2101        if r == 0 {
2102            self.accounts[idx].warmup_slope_per_step = 0;
2103            self.accounts[idx].warmup_started_at_slot = self.current_slot;
2104            return;
2105        }
2106        let t = self.params.warmup_period_slots;
2107        if t == 0 {
2108            self.set_reserved_pnl(idx, 0);
2109            self.accounts[idx].warmup_slope_per_step = 0;
2110            self.accounts[idx].warmup_started_at_slot = self.current_slot;
2111            return;
2112        }
2113        let elapsed = self.current_slot.saturating_sub(self.accounts[idx].warmup_started_at_slot);
2114        let cap = saturating_mul_u128_u64(self.accounts[idx].warmup_slope_per_step, elapsed);
2115        let release = core::cmp::min(r, cap);
2116        if release > 0 {
2117            self.set_reserved_pnl(idx, r - release);
2118        }
2119        if self.accounts[idx].reserved_pnl == 0 {
2120            self.accounts[idx].warmup_slope_per_step = 0;
2121        }
2122        self.accounts[idx].warmup_started_at_slot = self.current_slot;
2123    }
2124    }
2125
2126    // ========================================================================
2127    // Loss settlement and profit conversion (spec §7)
2128    // ========================================================================
2129
2130    /// settle_losses (spec §7.1): settle negative PnL from principal
2131    fn settle_losses(&mut self, idx: usize) {
2132        let pnl = self.accounts[idx].pnl;
2133        if pnl >= 0 {
2134            return;
2135        }
2136        assert!(pnl != i128::MIN, "settle_losses: i128::MIN");
2137        let need = pnl.unsigned_abs();
2138        let cap = self.accounts[idx].capital.get();
2139        let pay = core::cmp::min(need, cap);
2140        if pay > 0 {
2141            self.set_capital(idx, cap - pay);
2142            let pay_i128 = pay as i128; // pay <= need = |pnl| <= i128::MAX, safe
2143            let new_pnl = pnl
2144                .checked_add(pay_i128)
2145                .expect("settle_losses: unreachable overflow (pay <= |pnl|)");
2146            assert!(
2147                new_pnl != i128::MIN,
2148                "settle_losses: new_pnl == i128::MIN is unreachable"
2149            );
2150            self.set_pnl(idx, new_pnl);
2151        }
2152    }
2153
2154    /// resolve_flat_negative (spec §7.3): for flat accounts with negative PnL
2155    fn resolve_flat_negative(&mut self, idx: usize) {
2156        let eff = self.effective_pos_q(idx);
2157        if eff != 0 {
2158            return; // Not flat — must resolve through liquidation
2159        }
2160        let pnl = self.accounts[idx].pnl;
2161        if pnl < 0 {
2162            assert!(pnl != i128::MIN, "resolve_flat_negative: i128::MIN");
2163            let loss = pnl.unsigned_abs();
2164            self.absorb_protocol_loss(loss);
2165            self.set_pnl(idx, 0i128);
2166        }
2167    }
2168
2169    /// Profit conversion (spec §7.4): converts matured released profit into
2170    /// protected principal using consume_released_pnl. Flat-only in automatic touch.
2171    fn do_profit_conversion(&mut self, idx: usize) {
2172        let x = self.released_pos(idx);
2173        if x == 0 {
2174            return;
2175        }
2176
2177        // Compute y using pre-conversion haircut (spec §7.4).
2178        // Because x > 0 implies pnl_matured_pos_tot > 0, h_den is strictly positive
2179        // (spec test property 69).
2180        let (h_num, h_den) = self.haircut_ratio();
2181        assert!(
2182            h_den > 0,
2183            "do_profit_conversion: h_den must be > 0 when x > 0"
2184        );
2185        let y: u128 = wide_mul_div_floor_u128(x, h_num, h_den);
2186
2187        // consume_released_pnl(i, x) — leaves R_i unchanged
2188        self.consume_released_pnl(idx, x);
2189
2190        // set_capital(i, C_i + y)
2191        let new_cap = add_u128(self.accounts[idx].capital.get(), y);
2192        self.set_capital(idx, new_cap);
2193
2194        // Handle warmup schedule per spec §7.4 step 3-4
2195        if self.accounts[idx].reserved_pnl == 0 {
2196            self.accounts[idx].warmup_slope_per_step = 0;
2197            self.accounts[idx].warmup_started_at_slot = self.current_slot;
2198        }
2199        // else leave the existing warmup schedule unchanged
2200    }
2201
2202    /// fee_debt_sweep (spec §7.5): after any capital increase, sweep fee debt
2203    test_visible! {
2204    fn fee_debt_sweep(&mut self, idx: usize) {
2205        let fc = self.accounts[idx].fee_credits.get();
2206        let debt = fee_debt_u128_checked(fc);
2207        if debt == 0 {
2208            return;
2209        }
2210        let cap = self.accounts[idx].capital.get();
2211        let pay = core::cmp::min(debt, cap);
2212        if pay > 0 {
2213            self.set_capital(idx, cap - pay);
2214            // pay <= debt = |fee_credits|, so fee_credits + pay <= 0: no overflow
2215            let pay_i128 = core::cmp::min(pay, i128::MAX as u128) as i128;
2216            self.accounts[idx].fee_credits = I128::new(self.accounts[idx].fee_credits.get()
2217                .checked_add(pay_i128).expect("fee_debt_sweep: pay <= debt guarantees no overflow"));
2218            self.insurance_fund.balance = U128::new(
2219                self.insurance_fund.balance.get().checked_add(pay)
2220                    .expect("fee_debt_sweep: insurance overflow (I <= V <= MAX_VAULT_TVL)"));
2221        }
2222        // Per spec §7.5: unpaid fee debt remains as local fee_credits until
2223        // physical capital becomes available or manual profit conversion occurs.
2224        // MUST NOT consume junior PnL claims to mint senior insurance capital.
2225    }
2226    }
2227
2228    // ========================================================================
2229    // touch_account_full_not_atomic (spec §10.1)
2230    // ========================================================================
2231
2232    pub fn touch_account_full_not_atomic(
2233        &mut self,
2234        idx: usize,
2235        oracle_price: u64,
2236        now_slot: u64,
2237    ) -> Result<()> {
2238        // Bounds and existence check (hardened public API surface)
2239        if idx >= MAX_ACCOUNTS || !self.is_used(idx) {
2240            return Err(RiskError::AccountNotFound);
2241        }
2242        // Preconditions (spec §10.1 steps 1-4)
2243        if now_slot < self.current_slot {
2244            return Err(RiskError::Overflow);
2245        }
2246        if now_slot < self.last_market_slot {
2247            return Err(RiskError::Overflow);
2248        }
2249        if oracle_price == 0 || oracle_price > MAX_ORACLE_PRICE {
2250            return Err(RiskError::Overflow);
2251        }
2252
2253        // Step 5: current_slot = now_slot
2254        self.current_slot = now_slot;
2255
2256        // Step 6: accrue_market_to
2257        self.accrue_market_to(now_slot, oracle_price)?;
2258
2259        // Step 7: advance_profit_warmup (spec §4.9)
2260        self.advance_profit_warmup(idx);
2261
2262        // Step 8: settle_side_effects (handles restart_warmup_after_reserve_increase internally)
2263        self.settle_side_effects(idx)?;
2264
2265        // Step 9: settle losses from principal
2266        self.settle_losses(idx);
2267
2268        // Step 10: resolve flat negative (eff == 0 and PNL < 0)
2269        if self.effective_pos_q(idx) == 0 && self.accounts[idx].pnl < 0 {
2270            self.resolve_flat_negative(idx);
2271        }
2272
2273        // Step 11: maintenance fees (spec §8.2)
2274        self.settle_maintenance_fee_internal(idx, now_slot)?;
2275
2276        // Step 12: if flat, convert matured released profits (spec §7.4)
2277        if self.accounts[idx].position_basis_q == 0 {
2278            self.do_profit_conversion(idx);
2279        }
2280
2281        // Step 13: fee debt sweep
2282        self.fee_debt_sweep(idx);
2283
2284        Ok(())
2285    }
2286
2287    /// realize_recurring_maintenance_fee (spec §8.2.2).
2288    fn settle_maintenance_fee_internal(&mut self, idx: usize, now_slot: u64) -> Result<()> {
2289        let fee_per_slot = self.params.maintenance_fee_per_slot.get();
2290        if fee_per_slot == 0 {
2291            self.accounts[idx].last_fee_slot = now_slot;
2292            return Ok(());
2293        }
2294
2295        let last = self.accounts[idx].last_fee_slot;
2296        let dt_fee = now_slot.saturating_sub(last);
2297        if dt_fee == 0 {
2298            self.accounts[idx].last_fee_slot = now_slot;
2299            return Ok(());
2300        }
2301
2302        // Step 4: fee_due = checked_mul(maintenance_fee_per_slot, dt_fee)
2303        let fee_due = (dt_fee as u128)
2304            .checked_mul(fee_per_slot)
2305            .ok_or(RiskError::Overflow)?;
2306
2307        // Step 5: require fee_due <= MAX_PROTOCOL_FEE_ABS
2308        if fee_due > MAX_PROTOCOL_FEE_ABS {
2309            return Err(RiskError::Overflow);
2310        }
2311
2312        // Step 6: charge via charge_fee_to_insurance (spec §8.2.2 ordering)
2313        if fee_due > 0 {
2314            self.charge_fee_to_insurance(idx, fee_due)?;
2315        }
2316
2317        // Step 7: stamp last_fee_slot after charge (matches spec ordering)
2318        self.accounts[idx].last_fee_slot = now_slot;
2319
2320        Ok(())
2321    }
2322
2323    // ========================================================================
2324    // Account Management
2325    // ========================================================================
2326
2327    test_visible! {
2328    fn add_user(&mut self, fee_payment: u128) -> Result<u16> {
2329        let used_count = self.num_used_accounts as u64;
2330        if used_count >= self.params.max_accounts {
2331            return Err(RiskError::Overflow);
2332        }
2333
2334        let required_fee = self.params.new_account_fee.get();
2335        if fee_payment < required_fee {
2336            return Err(RiskError::InsufficientBalance);
2337        }
2338
2339        // MAX_VAULT_TVL bound
2340        let v_candidate = self.vault.get().checked_add(fee_payment)
2341            .ok_or(RiskError::Overflow)?;
2342        if v_candidate > MAX_VAULT_TVL {
2343            return Err(RiskError::Overflow);
2344        }
2345
2346        // All fallible checks before state mutations
2347        // Enforce materialized_account_count bound (spec §10.0)
2348        self.materialized_account_count = self.materialized_account_count
2349            .checked_add(1).ok_or(RiskError::Overflow)?;
2350        if self.materialized_account_count > MAX_MATERIALIZED_ACCOUNTS {
2351            self.materialized_account_count -= 1;
2352            return Err(RiskError::Overflow);
2353        }
2354
2355        let idx = match self.alloc_slot() {
2356            Ok(i) => i,
2357            Err(e) => {
2358                self.materialized_account_count -= 1;
2359                return Err(e);
2360            }
2361        };
2362
2363        // Commit vault/insurance only after all checks pass
2364        let excess = fee_payment.saturating_sub(required_fee);
2365        self.vault = U128::new(v_candidate);
2366        self.insurance_fund.balance = self.insurance_fund.balance + required_fee;
2367
2368        let account_id = self.next_account_id;
2369        self.next_account_id = self.next_account_id.saturating_add(1);
2370
2371        self.accounts[idx as usize] = Account {
2372            kind: Account::KIND_USER,
2373            account_id,
2374            capital: U128::new(excess),
2375            pnl: 0i128,
2376            reserved_pnl: 0u128,
2377            warmup_started_at_slot: self.current_slot,
2378            warmup_slope_per_step: 0u128,
2379            position_basis_q: 0i128,
2380            adl_a_basis: ADL_ONE,
2381            adl_k_snap: 0i128,
2382            adl_epoch_snap: 0,
2383            matcher_program: [0; 32],
2384            matcher_context: [0; 32],
2385            owner: [0; 32],
2386            fee_credits: I128::ZERO,
2387            last_fee_slot: self.current_slot,
2388            fees_earned_total: U128::ZERO,
2389        };
2390
2391        if excess > 0 {
2392            self.c_tot = U128::new(self.c_tot.get().checked_add(excess)
2393                .ok_or(RiskError::Overflow)?);
2394        }
2395
2396        Ok(idx)
2397    }
2398    }
2399
2400    test_visible! {
2401    fn add_lp(
2402        &mut self,
2403        matching_engine_program: [u8; 32],
2404        matching_engine_context: [u8; 32],
2405        fee_payment: u128,
2406    ) -> Result<u16> {
2407        let used_count = self.num_used_accounts as u64;
2408        if used_count >= self.params.max_accounts {
2409            return Err(RiskError::Overflow);
2410        }
2411
2412        let required_fee = self.params.new_account_fee.get();
2413        if fee_payment < required_fee {
2414            return Err(RiskError::InsufficientBalance);
2415        }
2416
2417        // MAX_VAULT_TVL bound
2418        let v_candidate = self.vault.get().checked_add(fee_payment)
2419            .ok_or(RiskError::Overflow)?;
2420        if v_candidate > MAX_VAULT_TVL {
2421            return Err(RiskError::Overflow);
2422        }
2423
2424        // Enforce materialized_account_count bound (spec §10.0)
2425        self.materialized_account_count = self.materialized_account_count
2426            .checked_add(1).ok_or(RiskError::Overflow)?;
2427        if self.materialized_account_count > MAX_MATERIALIZED_ACCOUNTS {
2428            self.materialized_account_count -= 1;
2429            return Err(RiskError::Overflow);
2430        }
2431
2432        let idx = match self.alloc_slot() {
2433            Ok(i) => i,
2434            Err(e) => {
2435                self.materialized_account_count -= 1;
2436                return Err(e);
2437            }
2438        };
2439
2440        // Commit vault/insurance only after all checks pass
2441        let excess = fee_payment.saturating_sub(required_fee);
2442        self.vault = U128::new(v_candidate);
2443        self.insurance_fund.balance = self.insurance_fund.balance + required_fee;
2444
2445        let account_id = self.next_account_id;
2446        self.next_account_id = self.next_account_id.saturating_add(1);
2447
2448        self.accounts[idx as usize] = Account {
2449            kind: Account::KIND_LP,
2450            account_id,
2451            capital: U128::new(excess),
2452            pnl: 0i128,
2453            reserved_pnl: 0u128,
2454            warmup_started_at_slot: self.current_slot,
2455            warmup_slope_per_step: 0u128,
2456            position_basis_q: 0i128,
2457            adl_a_basis: ADL_ONE,
2458            adl_k_snap: 0i128,
2459            adl_epoch_snap: 0,
2460            matcher_program: matching_engine_program,
2461            matcher_context: matching_engine_context,
2462            owner: [0; 32],
2463            fee_credits: I128::ZERO,
2464            last_fee_slot: self.current_slot,
2465            fees_earned_total: U128::ZERO,
2466        };
2467
2468        if excess > 0 {
2469            self.c_tot = U128::new(self.c_tot.get().checked_add(excess)
2470                .ok_or(RiskError::Overflow)?);
2471        }
2472
2473        Ok(idx)
2474    }
2475    }
2476
2477    pub fn set_owner(&mut self, idx: u16, owner: [u8; 32]) -> Result<()> {
2478        if idx as usize >= MAX_ACCOUNTS || !self.is_used(idx as usize) {
2479            return Err(RiskError::Unauthorized);
2480        }
2481        // Defense-in-depth: reject if owner is already claimed (non-zero).
2482        // Authorization is the wrapper layer's job, but the engine should
2483        // not silently overwrite an existing owner.
2484        if self.accounts[idx as usize].owner != [0u8; 32] {
2485            return Err(RiskError::Unauthorized);
2486        }
2487        self.accounts[idx as usize].owner = owner;
2488        Ok(())
2489    }
2490
2491    // ========================================================================
2492    // deposit (spec §10.2)
2493    // ========================================================================
2494
2495    pub fn deposit(
2496        &mut self,
2497        idx: u16,
2498        amount: u128,
2499        _oracle_price: u64,
2500        now_slot: u64,
2501    ) -> Result<()> {
2502        // Time monotonicity (spec §10.3 step 1)
2503        if now_slot < self.current_slot {
2504            return Err(RiskError::Overflow);
2505        }
2506        if now_slot < self.last_market_slot {
2507            return Err(RiskError::Overflow);
2508        }
2509
2510        // Pre-validate vault capacity before any mutations (prevents ghost account)
2511        let v_candidate = self
2512            .vault
2513            .get()
2514            .checked_add(amount)
2515            .ok_or(RiskError::Overflow)?;
2516        if v_candidate > MAX_VAULT_TVL {
2517            return Err(RiskError::Overflow);
2518        }
2519
2520        // Step 2: if account missing, require amount >= MIN_INITIAL_DEPOSIT and materialize
2521        // Per spec §10.3 step 2 and §2.3: deposit is the canonical materialization path.
2522        if !self.is_used(idx as usize) {
2523            let min_dep = self.params.min_initial_deposit.get();
2524            if amount < min_dep {
2525                return Err(RiskError::InsufficientBalance);
2526            }
2527            self.materialize_at(idx, now_slot)?;
2528        }
2529
2530        // Step 3: current_slot = now_slot
2531        self.current_slot = now_slot;
2532        self.vault = U128::new(v_candidate);
2533
2534        // Step 6: set_capital(i, C_i + amount)
2535        let new_cap = add_u128(self.accounts[idx as usize].capital.get(), amount);
2536        self.set_capital(idx as usize, new_cap);
2537
2538        // Step 7: settle_losses_from_principal
2539        self.settle_losses(idx as usize);
2540
2541        // Step 8: deposit MUST NOT invoke resolve_flat_negative (spec §7.3).
2542        // A pure deposit path that does not call accrue_market_to MUST NOT
2543        // invoke this path — surviving flat negative PNL waits for a later
2544        // accrued touch.
2545
2546        // Step 9: if flat and PNL >= 0, sweep fee debt (spec §7.5)
2547        // Per spec §10.3: deposit into account with basis != 0 MUST defer.
2548        // Per spec §7.5: only a surviving negative PNL_i blocks the sweep.
2549        if self.accounts[idx as usize].position_basis_q == 0 && self.accounts[idx as usize].pnl >= 0
2550        {
2551            self.fee_debt_sweep(idx as usize);
2552        }
2553
2554        Ok(())
2555    }
2556
2557    // ========================================================================
2558    // withdraw_not_atomic (spec §10.3)
2559    // ========================================================================
2560
2561    pub fn withdraw_not_atomic(
2562        &mut self,
2563        idx: u16,
2564        amount: u128,
2565        oracle_price: u64,
2566        now_slot: u64,
2567        funding_rate: i64,
2568    ) -> Result<()> {
2569        Self::validate_funding_rate(funding_rate)?;
2570
2571        if oracle_price == 0 || oracle_price > MAX_ORACLE_PRICE {
2572            return Err(RiskError::Overflow);
2573        }
2574
2575        // No require_fresh_crank: spec §10.4 does not gate withdraw_not_atomic on keeper
2576        // liveness. touch_account_full_not_atomic calls accrue_market_to with the caller's
2577        // oracle and slot, satisfying spec §0 goal 6 (liveness without external action).
2578
2579        if !self.is_used(idx as usize) {
2580            return Err(RiskError::AccountNotFound);
2581        }
2582
2583        let mut ctx = InstructionContext::new();
2584
2585        // Step 3: touch_account_full_not_atomic
2586        self.touch_account_full_not_atomic(idx as usize, oracle_price, now_slot)?;
2587
2588        // Step 4: require amount <= C_i
2589        if self.accounts[idx as usize].capital.get() < amount {
2590            return Err(RiskError::InsufficientBalance);
2591        }
2592
2593        // Step 5: universal dust guard — post-withdraw_not_atomic capital must be 0 or >= MIN_INITIAL_DEPOSIT
2594        let post_cap = self.accounts[idx as usize].capital.get() - amount;
2595        if post_cap != 0 && post_cap < self.params.min_initial_deposit.get() {
2596            return Err(RiskError::InsufficientBalance);
2597        }
2598
2599        // Step 6: if position exists, require post-withdraw_not_atomic initial margin
2600        let eff = self.effective_pos_q(idx as usize);
2601        if eff != 0 {
2602            // Simulate withdrawal: adjust BOTH capital AND vault to keep Residual consistent
2603            let old_cap = self.accounts[idx as usize].capital.get();
2604            let old_vault = self.vault;
2605            self.set_capital(idx as usize, post_cap);
2606            self.vault = U128::new(sub_u128(self.vault.get(), amount));
2607            let passes_im = self.is_above_initial_margin(
2608                &self.accounts[idx as usize],
2609                idx as usize,
2610                oracle_price,
2611            );
2612            // Revert both
2613            self.set_capital(idx as usize, old_cap);
2614            self.vault = old_vault;
2615            if !passes_im {
2616                return Err(RiskError::Undercollateralized);
2617            }
2618        }
2619
2620        // Step 7: commit withdrawal
2621        self.set_capital(
2622            idx as usize,
2623            self.accounts[idx as usize].capital.get() - amount,
2624        );
2625        self.vault = U128::new(sub_u128(self.vault.get(), amount));
2626
2627        // Steps 8-9: end-of-instruction resets
2628        self.schedule_end_of_instruction_resets(&mut ctx)?;
2629        self.finalize_end_of_instruction_resets(&ctx);
2630        self.recompute_r_last_from_final_state(funding_rate)?;
2631
2632        Ok(())
2633    }
2634
2635    // ========================================================================
2636    // settle_account_not_atomic (spec §10.7)
2637    // ========================================================================
2638
2639    /// Top-level settle wrapper per spec §10.7.
2640    /// If settlement is exposed as a standalone instruction, this wrapper MUST be used.
2641    pub fn settle_account_not_atomic(
2642        &mut self,
2643        idx: u16,
2644        oracle_price: u64,
2645        now_slot: u64,
2646        funding_rate: i64,
2647    ) -> Result<()> {
2648        Self::validate_funding_rate(funding_rate)?;
2649
2650        if oracle_price == 0 || oracle_price > MAX_ORACLE_PRICE {
2651            return Err(RiskError::Overflow);
2652        }
2653        if !self.is_used(idx as usize) {
2654            return Err(RiskError::AccountNotFound);
2655        }
2656
2657        let mut ctx = InstructionContext::new();
2658
2659        // Step 3: touch_account_full_not_atomic
2660        self.touch_account_full_not_atomic(idx as usize, oracle_price, now_slot)?;
2661
2662        // Steps 4-5: end-of-instruction resets
2663        self.schedule_end_of_instruction_resets(&mut ctx)?;
2664        self.finalize_end_of_instruction_resets(&ctx);
2665        self.recompute_r_last_from_final_state(funding_rate)?;
2666
2667        // Step 7: assert OI balance
2668        assert!(
2669            self.oi_eff_long_q == self.oi_eff_short_q,
2670            "OI_eff_long != OI_eff_short after settle"
2671        );
2672
2673        Ok(())
2674    }
2675
2676    // ========================================================================
2677    // execute_trade_not_atomic (spec §10.4)
2678    // ========================================================================
2679
2680    pub fn execute_trade_not_atomic(
2681        &mut self,
2682        a: u16,
2683        b: u16,
2684        oracle_price: u64,
2685        now_slot: u64,
2686        size_q: i128,
2687        exec_price: u64,
2688        funding_rate: i64,
2689    ) -> Result<()> {
2690        Self::validate_funding_rate(funding_rate)?;
2691
2692        if oracle_price == 0 || oracle_price > MAX_ORACLE_PRICE {
2693            return Err(RiskError::Overflow);
2694        }
2695        if exec_price == 0 || exec_price > MAX_ORACLE_PRICE {
2696            return Err(RiskError::Overflow);
2697        }
2698        // Spec §10.5 step 7: require 0 < size_q <= MAX_TRADE_SIZE_Q
2699        if size_q <= 0 {
2700            return Err(RiskError::Overflow);
2701        }
2702        if size_q as u128 > MAX_TRADE_SIZE_Q {
2703            return Err(RiskError::Overflow);
2704        }
2705
2706        // trade_notional check (spec §10.4 step 6)
2707        let trade_notional_check =
2708            mul_div_floor_u128(size_q as u128, exec_price as u128, POS_SCALE);
2709        if trade_notional_check > MAX_ACCOUNT_NOTIONAL {
2710            return Err(RiskError::Overflow);
2711        }
2712
2713        // No require_fresh_crank: spec §10.5 does not gate execute_trade_not_atomic on
2714        // keeper liveness. touch_account_full_not_atomic calls accrue_market_to with the
2715        // caller's oracle and slot, satisfying spec §0 goal 6.
2716
2717        if !self.is_used(a as usize) || !self.is_used(b as usize) {
2718            return Err(RiskError::AccountNotFound);
2719        }
2720        if a == b {
2721            return Err(RiskError::Overflow);
2722        }
2723
2724        let mut ctx = InstructionContext::new();
2725
2726        // Steps 11-12: touch both
2727        self.touch_account_full_not_atomic(a as usize, oracle_price, now_slot)?;
2728        self.touch_account_full_not_atomic(b as usize, oracle_price, now_slot)?;
2729
2730        // Step 13: capture old effective positions
2731        let old_eff_a = self.effective_pos_q(a as usize);
2732        let old_eff_b = self.effective_pos_q(b as usize);
2733
2734        // Steps 14-16: capture pre-trade MM requirements and raw maintenance buffers
2735        // Spec §9.1: if effective_pos_q(i) == 0, MM_req_i = 0
2736        let mm_req_pre_a = if old_eff_a == 0 {
2737            0u128
2738        } else {
2739            let not = self.notional(a as usize, oracle_price);
2740            core::cmp::max(
2741                mul_div_floor_u128(not, self.params.maintenance_margin_bps as u128, 10_000),
2742                self.params.min_nonzero_mm_req,
2743            )
2744        };
2745        let mm_req_pre_b = if old_eff_b == 0 {
2746            0u128
2747        } else {
2748            let not = self.notional(b as usize, oracle_price);
2749            core::cmp::max(
2750                mul_div_floor_u128(not, self.params.maintenance_margin_bps as u128, 10_000),
2751                self.params.min_nonzero_mm_req,
2752            )
2753        };
2754        let maint_raw_wide_pre_a = self.account_equity_maint_raw_wide(&self.accounts[a as usize]);
2755        let maint_raw_wide_pre_b = self.account_equity_maint_raw_wide(&self.accounts[b as usize]);
2756        let buffer_pre_a = maint_raw_wide_pre_a
2757            .checked_sub(I256::from_u128(mm_req_pre_a))
2758            .expect("I256 sub");
2759        let buffer_pre_b = maint_raw_wide_pre_b
2760            .checked_sub(I256::from_u128(mm_req_pre_b))
2761            .expect("I256 sub");
2762
2763        // Step 6: compute new effective positions
2764        let new_eff_a = old_eff_a.checked_add(size_q).ok_or(RiskError::Overflow)?;
2765        let neg_size_q = size_q.checked_neg().ok_or(RiskError::Overflow)?;
2766        let new_eff_b = old_eff_b
2767            .checked_add(neg_size_q)
2768            .ok_or(RiskError::Overflow)?;
2769
2770        // Validate position bounds
2771        if new_eff_a != 0 && new_eff_a.unsigned_abs() > MAX_POSITION_ABS_Q {
2772            return Err(RiskError::Overflow);
2773        }
2774        if new_eff_b != 0 && new_eff_b.unsigned_abs() > MAX_POSITION_ABS_Q {
2775            return Err(RiskError::Overflow);
2776        }
2777
2778        // Validate notional bounds
2779        {
2780            let notional_a =
2781                mul_div_floor_u128(new_eff_a.unsigned_abs(), oracle_price as u128, POS_SCALE);
2782            if notional_a > MAX_ACCOUNT_NOTIONAL {
2783                return Err(RiskError::Overflow);
2784            }
2785            let notional_b =
2786                mul_div_floor_u128(new_eff_b.unsigned_abs(), oracle_price as u128, POS_SCALE);
2787            if notional_b > MAX_ACCOUNT_NOTIONAL {
2788                return Err(RiskError::Overflow);
2789            }
2790        }
2791
2792        // Preflight: finalize any ResetPending sides that are fully ready,
2793        // so OI-increase gating doesn't block trades on reopenable sides.
2794        self.maybe_finalize_ready_reset_sides();
2795
2796        // Step 5: compute bilateral OI once (spec §5.2.2) and use for both
2797        // mode gating and later writeback. Avoids redundant checked arithmetic.
2798        let (oi_long_after, oi_short_after) =
2799            self.bilateral_oi_after(&old_eff_a, &new_eff_a, &old_eff_b, &new_eff_b)?;
2800
2801        // Validate OI bounds
2802        if oi_long_after > MAX_OI_SIDE_Q || oi_short_after > MAX_OI_SIDE_Q {
2803            return Err(RiskError::Overflow);
2804        }
2805
2806        // Reject if trade would increase OI on a blocked side
2807        if (self.side_mode_long == SideMode::DrainOnly
2808            || self.side_mode_long == SideMode::ResetPending)
2809            && oi_long_after > self.oi_eff_long_q
2810        {
2811            return Err(RiskError::SideBlocked);
2812        }
2813        if (self.side_mode_short == SideMode::DrainOnly
2814            || self.side_mode_short == SideMode::ResetPending)
2815            && oi_short_after > self.oi_eff_short_q
2816        {
2817            return Err(RiskError::SideBlocked);
2818        }
2819
2820        // Step 21: trade PnL alignment (spec §10.5)
2821        let price_diff = (oracle_price as i128) - (exec_price as i128);
2822        let trade_pnl_a = compute_trade_pnl(size_q, price_diff)?;
2823        let trade_pnl_b = trade_pnl_a.checked_neg().ok_or(RiskError::Overflow)?;
2824
2825        let old_r_a = self.accounts[a as usize].reserved_pnl;
2826        let old_r_b = self.accounts[b as usize].reserved_pnl;
2827
2828        let pnl_a = self.accounts[a as usize]
2829            .pnl
2830            .checked_add(trade_pnl_a)
2831            .ok_or(RiskError::Overflow)?;
2832        if pnl_a == i128::MIN {
2833            return Err(RiskError::Overflow);
2834        }
2835        self.set_pnl(a as usize, pnl_a);
2836
2837        let pnl_b = self.accounts[b as usize]
2838            .pnl
2839            .checked_add(trade_pnl_b)
2840            .ok_or(RiskError::Overflow)?;
2841        if pnl_b == i128::MIN {
2842            return Err(RiskError::Overflow);
2843        }
2844        self.set_pnl(b as usize, pnl_b);
2845
2846        // Caller obligation: restart warmup if R increased
2847        if self.accounts[a as usize].reserved_pnl > old_r_a {
2848            self.restart_warmup_after_reserve_increase(a as usize);
2849        }
2850        if self.accounts[b as usize].reserved_pnl > old_r_b {
2851            self.restart_warmup_after_reserve_increase(b as usize);
2852        }
2853
2854        // Step 8: attach effective positions
2855        self.attach_effective_position(a as usize, new_eff_a);
2856        self.attach_effective_position(b as usize, new_eff_b);
2857
2858        // Step 9: write pre-computed OI (same values from step 5, spec §5.2.2)
2859        self.oi_eff_long_q = oi_long_after;
2860        self.oi_eff_short_q = oi_short_after;
2861
2862        // Step 10: settle post-trade losses from principal for both accounts (spec §10.4 step 18)
2863        // Loss seniority: losses MUST be settled before explicit fees (spec §0 item 14)
2864        self.settle_losses(a as usize);
2865        self.settle_losses(b as usize);
2866
2867        // Step 11: charge trading fees (spec §10.4 step 19, §8.1)
2868        let trade_notional =
2869            mul_div_floor_u128(size_q.unsigned_abs(), exec_price as u128, POS_SCALE);
2870        let fee = if trade_notional > 0 && self.params.trading_fee_bps > 0 {
2871            mul_div_ceil_u128(trade_notional, self.params.trading_fee_bps as u128, 10_000)
2872        } else {
2873            0
2874        };
2875
2876        // Charge fee from both accounts (spec §10.5 step 28)
2877        // (cash_to_insurance, total_equity_impact) for each side
2878        let mut fee_cash_a = 0u128;
2879        let mut fee_cash_b = 0u128;
2880        let mut fee_impact_a = 0u128;
2881        let mut fee_impact_b = 0u128;
2882        if fee > 0 {
2883            if fee > MAX_PROTOCOL_FEE_ABS {
2884                return Err(RiskError::Overflow);
2885            }
2886            let (cash_a, impact_a) = self.charge_fee_to_insurance(a as usize, fee)?;
2887            let (cash_b, impact_b) = self.charge_fee_to_insurance(b as usize, fee)?;
2888            fee_cash_a = cash_a;
2889            fee_cash_b = cash_b;
2890            fee_impact_a = impact_a;
2891            fee_impact_b = impact_b;
2892        }
2893
2894        // Track LP fees: use total equity impact (capital paid + collectible debt).
2895        // This is the nominal fee obligation from the counterparty's trade.
2896        // Debt may be collected later via fee_debt_sweep or forgiven on dust
2897        // reclamation — that's an insurance concern, not LP attribution.
2898        if self.accounts[a as usize].is_lp() {
2899            self.accounts[a as usize].fees_earned_total = U128::new(add_u128(
2900                self.accounts[a as usize].fees_earned_total.get(),
2901                fee_impact_b,
2902            ));
2903        }
2904        if self.accounts[b as usize].is_lp() {
2905            self.accounts[b as usize].fees_earned_total = U128::new(add_u128(
2906                self.accounts[b as usize].fees_earned_total.get(),
2907                fee_impact_a,
2908            ));
2909        }
2910
2911        // Steps 25-26: flat-close PNL guard (spec §10.5)
2912        if new_eff_a == 0 && self.accounts[a as usize].pnl < 0 {
2913            return Err(RiskError::Undercollateralized);
2914        }
2915        if new_eff_b == 0 && self.accounts[b as usize].pnl < 0 {
2916            return Err(RiskError::Undercollateralized);
2917        }
2918
2919        // Step 29: post-trade margin enforcement (spec §10.5)
2920        // The spec says "(Eq_maint_raw_i + fee)" using the nominal fee.
2921        // We use fee_impact (capital_paid + collectible_debt) instead because:
2922        // - charge_fee_to_insurance can drop excess beyond collectible headroom
2923        // - Eq_maint_raw only decreased by impact, not the full nominal fee
2924        // - Adding back impact correctly reverses the actual state change
2925        // - Using nominal fee would over-compensate and admit invalid trades
2926        self.enforce_post_trade_margin(
2927            a as usize,
2928            b as usize,
2929            oracle_price,
2930            &old_eff_a,
2931            &new_eff_a,
2932            &old_eff_b,
2933            &new_eff_b,
2934            buffer_pre_a,
2935            buffer_pre_b,
2936            fee_impact_a,
2937            fee_impact_b,
2938        )?;
2939
2940        // Steps 16-17: end-of-instruction resets
2941        self.schedule_end_of_instruction_resets(&mut ctx)?;
2942        self.finalize_end_of_instruction_resets(&ctx);
2943
2944        // Step 32: recompute r_last if funding-rate inputs changed (spec §10.5)
2945        self.recompute_r_last_from_final_state(funding_rate)?;
2946
2947        // Step 18: assert OI balance (spec §10.4)
2948        assert!(
2949            self.oi_eff_long_q == self.oi_eff_short_q,
2950            "OI_eff_long != OI_eff_short after trade"
2951        );
2952
2953        Ok(())
2954    }
2955
2956    /// Charge fee per spec §8.1 — route shortfall through fee_credits instead of PNL.
2957    /// Returns (capital_paid_to_insurance, total_equity_impact).
2958    /// capital_paid is realized revenue; total includes collectible debt.
2959    /// Any excess beyond collectible headroom is silently dropped.
2960    fn charge_fee_to_insurance(&mut self, idx: usize, fee: u128) -> Result<(u128, u128)> {
2961        if fee > MAX_PROTOCOL_FEE_ABS {
2962            return Err(RiskError::Overflow);
2963        }
2964        let cap = self.accounts[idx].capital.get();
2965        let fee_paid = core::cmp::min(fee, cap);
2966        if fee_paid > 0 {
2967            self.set_capital(idx, cap - fee_paid);
2968            self.insurance_fund.balance = self.insurance_fund.balance + fee_paid;
2969        }
2970        let fee_shortfall = fee - fee_paid;
2971        if fee_shortfall > 0 {
2972            // Route collectible shortfall through fee_credits (debit).
2973            // Cap at collectible headroom to avoid reverting (spec §8.2.2):
2974            // fee_credits must stay in [-(i128::MAX), 0]; any excess is dropped.
2975            let current_fc = self.accounts[idx].fee_credits.get();
2976            // Headroom = current_fc - (-(i128::MAX)) = current_fc + i128::MAX
2977            let headroom = match current_fc.checked_add(i128::MAX) {
2978                Some(h) if h > 0 => h as u128,
2979                _ => 0u128, // at or beyond limit — no room
2980            };
2981            let collectible = core::cmp::min(fee_shortfall, headroom);
2982            if collectible > 0 {
2983                // Safe: collectible <= headroom <= i128::MAX, and
2984                // current_fc - collectible >= -(i128::MAX)
2985                let new_fc = current_fc - (collectible as i128);
2986                self.accounts[idx].fee_credits = I128::new(new_fc);
2987            }
2988            // Any excess beyond collectible headroom is silently dropped
2989            Ok((fee_paid, fee_paid + collectible))
2990        } else {
2991            Ok((fee_paid, fee_paid))
2992        }
2993    }
2994
2995    /// OI component helpers for exact bilateral decomposition (spec §5.2.2)
2996    fn oi_long_component(pos: i128) -> u128 {
2997        if pos > 0 {
2998            pos as u128
2999        } else {
3000            0u128
3001        }
3002    }
3003
3004    fn oi_short_component(pos: i128) -> u128 {
3005        if pos < 0 {
3006            pos.unsigned_abs()
3007        } else {
3008            0u128
3009        }
3010    }
3011
3012    /// Compute exact bilateral candidate side-OI after-values (spec §5.2.2).
3013    /// Returns (OI_long_after, OI_short_after).
3014    fn bilateral_oi_after(
3015        &self,
3016        old_a: &i128,
3017        new_a: &i128,
3018        old_b: &i128,
3019        new_b: &i128,
3020    ) -> Result<(u128, u128)> {
3021        let oi_long_after = self
3022            .oi_eff_long_q
3023            .checked_sub(Self::oi_long_component(*old_a))
3024            .ok_or(RiskError::CorruptState)?
3025            .checked_sub(Self::oi_long_component(*old_b))
3026            .ok_or(RiskError::CorruptState)?
3027            .checked_add(Self::oi_long_component(*new_a))
3028            .ok_or(RiskError::Overflow)?
3029            .checked_add(Self::oi_long_component(*new_b))
3030            .ok_or(RiskError::Overflow)?;
3031
3032        let oi_short_after = self
3033            .oi_eff_short_q
3034            .checked_sub(Self::oi_short_component(*old_a))
3035            .ok_or(RiskError::CorruptState)?
3036            .checked_sub(Self::oi_short_component(*old_b))
3037            .ok_or(RiskError::CorruptState)?
3038            .checked_add(Self::oi_short_component(*new_a))
3039            .ok_or(RiskError::Overflow)?
3040            .checked_add(Self::oi_short_component(*new_b))
3041            .ok_or(RiskError::Overflow)?;
3042
3043        Ok((oi_long_after, oi_short_after))
3044    }
3045
3046    /// Check side-mode gating using exact bilateral OI decomposition (spec §5.2.2 + §9.6).
3047    /// A trade would increase net side OI iff OI_side_after > OI_eff_side.
3048    fn check_side_mode_for_trade(
3049        &self,
3050        old_a: &i128,
3051        new_a: &i128,
3052        old_b: &i128,
3053        new_b: &i128,
3054    ) -> Result<()> {
3055        let (oi_long_after, oi_short_after) =
3056            self.bilateral_oi_after(old_a, new_a, old_b, new_b)?;
3057
3058        for &side in &[Side::Long, Side::Short] {
3059            let mode = self.get_side_mode(side);
3060            if mode != SideMode::DrainOnly && mode != SideMode::ResetPending {
3061                continue;
3062            }
3063            let (oi_after, oi_before) = match side {
3064                Side::Long => (oi_long_after, self.oi_eff_long_q),
3065                Side::Short => (oi_short_after, self.oi_eff_short_q),
3066            };
3067            if oi_after > oi_before {
3068                return Err(RiskError::SideBlocked);
3069            }
3070        }
3071        Ok(())
3072    }
3073
3074    /// Enforce post-trade margin per spec §10.5 step 29.
3075    /// Uses strict risk-reducing buffer comparison with exact I256 Eq_maint_raw.
3076    fn enforce_post_trade_margin(
3077        &self,
3078        a: usize,
3079        b: usize,
3080        oracle_price: u64,
3081        old_eff_a: &i128,
3082        new_eff_a: &i128,
3083        old_eff_b: &i128,
3084        new_eff_b: &i128,
3085        buffer_pre_a: I256,
3086        buffer_pre_b: I256,
3087        fee_a: u128,
3088        fee_b: u128,
3089    ) -> Result<()> {
3090        self.enforce_one_side_margin(a, oracle_price, old_eff_a, new_eff_a, buffer_pre_a, fee_a)?;
3091        self.enforce_one_side_margin(b, oracle_price, old_eff_b, new_eff_b, buffer_pre_b, fee_b)?;
3092        Ok(())
3093    }
3094
3095    fn enforce_one_side_margin(
3096        &self,
3097        idx: usize,
3098        oracle_price: u64,
3099        old_eff: &i128,
3100        new_eff: &i128,
3101        buffer_pre: I256,
3102        fee: u128,
3103    ) -> Result<()> {
3104        if *new_eff == 0 {
3105            // v12.1.0 §10.5 step 29: flat-close guard uses exact Eq_maint_raw_i >= 0
3106            // (not just PNL >= 0). Prevents flat exits with negative net wealth from fee debt.
3107            let maint_raw = self.account_equity_maint_raw_wide(&self.accounts[idx]);
3108            if maint_raw.is_negative() {
3109                return Err(RiskError::Undercollateralized);
3110            }
3111            return Ok(());
3112        }
3113
3114        let abs_old: u128 = if *old_eff == 0 {
3115            0u128
3116        } else {
3117            old_eff.unsigned_abs()
3118        };
3119        let abs_new = new_eff.unsigned_abs();
3120
3121        // Determine if risk-increasing (spec §9.2)
3122        let risk_increasing = abs_new > abs_old
3123            || (*old_eff > 0 && *new_eff < 0)
3124            || (*old_eff < 0 && *new_eff > 0)
3125            || *old_eff == 0;
3126
3127        // Determine if strictly risk-reducing (spec §9.2)
3128        let strictly_reducing = *old_eff != 0
3129            && *new_eff != 0
3130            && ((*old_eff > 0 && *new_eff > 0) || (*old_eff < 0 && *new_eff < 0))
3131            && abs_new < abs_old;
3132
3133        if risk_increasing {
3134            // Require initial-margin healthy using Eq_init_net_i
3135            if !self.is_above_initial_margin(&self.accounts[idx], idx, oracle_price) {
3136                return Err(RiskError::Undercollateralized);
3137            }
3138        } else if self.is_above_maintenance_margin(&self.accounts[idx], idx, oracle_price) {
3139            // Maintenance healthy: allow
3140        } else if strictly_reducing {
3141            // v12.1.0 §10.5 step 29: strict risk-reducing exemption (fee-neutral).
3142            // Both conditions must hold in exact widened I256:
3143            // 1. Fee-neutral buffer improves: (Eq_maint_raw_post + fee) - MM_req_post > buffer_pre
3144            // 2. Fee-neutral shortfall does not worsen: min(Eq_maint_raw_post + fee, 0) >= min(Eq_maint_raw_pre, 0)
3145            let maint_raw_wide_post = self.account_equity_maint_raw_wide(&self.accounts[idx]);
3146            let fee_wide = I256::from_u128(fee);
3147
3148            // Fee-neutral post equity and buffer
3149            let maint_raw_fee_neutral =
3150                maint_raw_wide_post.checked_add(fee_wide).expect("I256 add");
3151            let mm_req_post = {
3152                let not = self.notional(idx, oracle_price);
3153                core::cmp::max(
3154                    mul_div_floor_u128(not, self.params.maintenance_margin_bps as u128, 10_000),
3155                    self.params.min_nonzero_mm_req,
3156                )
3157            };
3158            let buffer_post_fee_neutral = maint_raw_fee_neutral
3159                .checked_sub(I256::from_u128(mm_req_post))
3160                .expect("I256 sub");
3161
3162            // Recover pre-trade raw equity from buffer_pre + MM_req_pre
3163            let mm_req_pre = {
3164                let not_pre = if *old_eff == 0 {
3165                    0u128
3166                } else {
3167                    mul_div_floor_u128(old_eff.unsigned_abs(), oracle_price as u128, POS_SCALE)
3168                };
3169                core::cmp::max(
3170                    mul_div_floor_u128(not_pre, self.params.maintenance_margin_bps as u128, 10_000),
3171                    self.params.min_nonzero_mm_req,
3172                )
3173            };
3174            let maint_raw_pre = buffer_pre
3175                .checked_add(I256::from_u128(mm_req_pre))
3176                .expect("I256 add");
3177
3178            // Condition 1: fee-neutral buffer strictly improves
3179            let cond1 = buffer_post_fee_neutral > buffer_pre;
3180
3181            // Condition 2: fee-neutral shortfall below zero does not worsen
3182            // min(post + fee, 0) >= min(pre, 0)
3183            let zero = I256::from_i128(0);
3184            let shortfall_post = if maint_raw_fee_neutral < zero {
3185                maint_raw_fee_neutral
3186            } else {
3187                zero
3188            };
3189            let shortfall_pre = if maint_raw_pre < zero {
3190                maint_raw_pre
3191            } else {
3192                zero
3193            };
3194            let cond2 = shortfall_post >= shortfall_pre;
3195
3196            if cond1 && cond2 {
3197                // Both conditions met: allow
3198            } else {
3199                return Err(RiskError::Undercollateralized);
3200            }
3201        } else {
3202            return Err(RiskError::Undercollateralized);
3203        }
3204        Ok(())
3205    }
3206
3207    /// Update OI using exact bilateral decomposition (spec §5.2.2).
3208    /// The same values computed for gating MUST be written back — no alternate decomposition.
3209    fn update_oi_from_positions(
3210        &mut self,
3211        old_a: &i128,
3212        new_a: &i128,
3213        old_b: &i128,
3214        new_b: &i128,
3215    ) -> Result<()> {
3216        let (oi_long_after, oi_short_after) =
3217            self.bilateral_oi_after(old_a, new_a, old_b, new_b)?;
3218
3219        // Check bounds
3220        if oi_long_after > MAX_OI_SIDE_Q {
3221            return Err(RiskError::Overflow);
3222        }
3223        if oi_short_after > MAX_OI_SIDE_Q {
3224            return Err(RiskError::Overflow);
3225        }
3226
3227        self.oi_eff_long_q = oi_long_after;
3228        self.oi_eff_short_q = oi_short_after;
3229
3230        Ok(())
3231    }
3232
3233    // ========================================================================
3234    // liquidate_at_oracle_not_atomic (spec §10.5 + §10.0)
3235    // ========================================================================
3236
3237    /// Top-level liquidation: creates its own InstructionContext and finalizes resets.
3238    /// Accepts LiquidationPolicy per spec §10.6.
3239    pub fn liquidate_at_oracle_not_atomic(
3240        &mut self,
3241        idx: u16,
3242        now_slot: u64,
3243        oracle_price: u64,
3244        policy: LiquidationPolicy,
3245        funding_rate: i64,
3246    ) -> Result<bool> {
3247        Self::validate_funding_rate(funding_rate)?;
3248
3249        // Bounds and existence check BEFORE touch_account_full_not_atomic to prevent
3250        // market-state mutation (accrue_market_to) on missing accounts.
3251        if idx as usize >= MAX_ACCOUNTS || !self.is_used(idx as usize) {
3252            return Ok(false);
3253        }
3254
3255        let mut ctx = InstructionContext::new();
3256
3257        // Per spec §10.6 step 3: touch_account_full_not_atomic before the liquidation routine.
3258        self.touch_account_full_not_atomic(idx as usize, oracle_price, now_slot)?;
3259
3260        let result =
3261            self.liquidate_at_oracle_internal(idx, now_slot, oracle_price, policy, &mut ctx)?;
3262
3263        // End-of-instruction resets must run unconditionally because
3264        // touch_account_full_not_atomic mutates state even when liquidation doesn't proceed.
3265        self.schedule_end_of_instruction_resets(&mut ctx)?;
3266        self.finalize_end_of_instruction_resets(&ctx);
3267        self.recompute_r_last_from_final_state(funding_rate)?;
3268
3269        // Assert OI balance unconditionally (spec §10.6 step 11)
3270        assert!(
3271            self.oi_eff_long_q == self.oi_eff_short_q,
3272            "OI_eff_long != OI_eff_short after liquidation"
3273        );
3274        Ok(result)
3275    }
3276
3277    /// Internal liquidation routine: takes caller's shared InstructionContext.
3278    /// Precondition (spec §9.4): caller has already called touch_account_full_not_atomic(i).
3279    /// Does NOT call schedule/finalize resets — caller is responsible.
3280    fn liquidate_at_oracle_internal(
3281        &mut self,
3282        idx: u16,
3283        _now_slot: u64,
3284        oracle_price: u64,
3285        policy: LiquidationPolicy,
3286        ctx: &mut InstructionContext,
3287    ) -> Result<bool> {
3288        if idx as usize >= MAX_ACCOUNTS || !self.is_used(idx as usize) {
3289            return Ok(false);
3290        }
3291
3292        if oracle_price == 0 || oracle_price > MAX_ORACLE_PRICE {
3293            return Err(RiskError::Overflow);
3294        }
3295
3296        // Check position exists
3297        let old_eff = self.effective_pos_q(idx as usize);
3298        if old_eff == 0 {
3299            return Ok(false);
3300        }
3301
3302        // Step 4: check liquidation eligibility (spec §9.3)
3303        if self.is_above_maintenance_margin(
3304            &self.accounts[idx as usize],
3305            idx as usize,
3306            oracle_price,
3307        ) {
3308            return Ok(false);
3309        }
3310
3311        let liq_side = side_of_i128(old_eff).unwrap();
3312        let abs_old_eff = old_eff.unsigned_abs();
3313
3314        match policy {
3315            LiquidationPolicy::ExactPartial(q_close_q) => {
3316                // Spec §9.4: partial liquidation
3317                // Step 1-2: require 0 < q_close_q < abs(old_eff_pos_q_i)
3318                if q_close_q == 0 || q_close_q >= abs_old_eff {
3319                    return Err(RiskError::Overflow);
3320                }
3321                // Step 4: new_eff_abs_q = abs(old) - q_close_q
3322                let new_eff_abs_q = abs_old_eff
3323                    .checked_sub(q_close_q)
3324                    .ok_or(RiskError::Overflow)?;
3325                // Step 5: require new_eff_abs_q > 0 (property 68)
3326                if new_eff_abs_q == 0 {
3327                    return Err(RiskError::Overflow);
3328                }
3329                // Step 6: new_eff_pos_q_i = sign(old) * new_eff_abs_q
3330                let sign = if old_eff > 0 { 1i128 } else { -1i128 };
3331                let new_eff = sign
3332                    .checked_mul(new_eff_abs_q as i128)
3333                    .ok_or(RiskError::Overflow)?;
3334
3335                // Step 7-8: close q_close_q at oracle, attach new position
3336                self.attach_effective_position(idx as usize, new_eff);
3337
3338                // Step 9: settle realized losses from principal
3339                self.settle_losses(idx as usize);
3340
3341                // Step 10-11: charge liquidation fee on quantity closed
3342                let liq_fee = {
3343                    let notional_val =
3344                        mul_div_floor_u128(q_close_q, oracle_price as u128, POS_SCALE);
3345                    let liq_fee_raw = mul_div_ceil_u128(
3346                        notional_val,
3347                        self.params.liquidation_fee_bps as u128,
3348                        10_000,
3349                    );
3350                    core::cmp::min(
3351                        core::cmp::max(liq_fee_raw, self.params.min_liquidation_abs.get()),
3352                        self.params.liquidation_fee_cap.get(),
3353                    )
3354                };
3355                self.charge_fee_to_insurance(idx as usize, liq_fee)?;
3356
3357                // Step 12: enqueue ADL with d=0 (partial, no bankruptcy)
3358                self.enqueue_adl(ctx, liq_side, q_close_q, 0)?;
3359
3360                // Step 13: check if pending reset was scheduled
3361                // (If so, skip further live-OI-dependent work, but step 14 still runs)
3362
3363                // Step 14: MANDATORY post-partial local maintenance health check
3364                // This MUST run even when step 13 has scheduled a pending reset (spec §9.4).
3365                if !self.is_above_maintenance_margin(
3366                    &self.accounts[idx as usize],
3367                    idx as usize,
3368                    oracle_price,
3369                ) {
3370                    return Err(RiskError::Undercollateralized);
3371                }
3372
3373                self.lifetime_liquidations = self.lifetime_liquidations.saturating_add(1);
3374                Ok(true)
3375            }
3376            LiquidationPolicy::FullClose => {
3377                // Spec §9.5: full-close liquidation (existing behavior)
3378                let q_close_q = abs_old_eff;
3379
3380                // Close entire position at oracle
3381                self.attach_effective_position(idx as usize, 0i128);
3382
3383                // Settle losses from principal
3384                self.settle_losses(idx as usize);
3385
3386                // Charge liquidation fee (spec §8.3)
3387                let liq_fee = if q_close_q == 0 {
3388                    0u128
3389                } else {
3390                    let notional_val =
3391                        mul_div_floor_u128(q_close_q, oracle_price as u128, POS_SCALE);
3392                    let liq_fee_raw = mul_div_ceil_u128(
3393                        notional_val,
3394                        self.params.liquidation_fee_bps as u128,
3395                        10_000,
3396                    );
3397                    core::cmp::min(
3398                        core::cmp::max(liq_fee_raw, self.params.min_liquidation_abs.get()),
3399                        self.params.liquidation_fee_cap.get(),
3400                    )
3401                };
3402                self.charge_fee_to_insurance(idx as usize, liq_fee)?;
3403
3404                // Determine deficit D
3405                let eff_post = self.effective_pos_q(idx as usize);
3406                let d: u128 = if eff_post == 0 && self.accounts[idx as usize].pnl < 0 {
3407                    assert!(
3408                        self.accounts[idx as usize].pnl != i128::MIN,
3409                        "liquidate: i128::MIN pnl"
3410                    );
3411                    self.accounts[idx as usize].pnl.unsigned_abs()
3412                } else {
3413                    0u128
3414                };
3415
3416                // Enqueue ADL
3417                if q_close_q != 0 || d != 0 {
3418                    self.enqueue_adl(ctx, liq_side, q_close_q, d)?;
3419                }
3420
3421                // If D > 0, set_pnl(i, 0)
3422                if d != 0 {
3423                    self.set_pnl(idx as usize, 0i128);
3424                }
3425
3426                self.lifetime_liquidations = self.lifetime_liquidations.saturating_add(1);
3427                Ok(true)
3428            }
3429        }
3430    }
3431
3432    // ========================================================================
3433    // keeper_crank_not_atomic (spec §10.6)
3434    // ========================================================================
3435
3436    /// keeper_crank_not_atomic (spec §10.8): Minimal on-chain permissionless shortlist processor.
3437    /// Candidate discovery is performed off-chain. ordered_candidates[] is untrusted.
3438    /// Each candidate is (account_idx, optional liquidation policy hint).
3439    pub fn keeper_crank_not_atomic(
3440        &mut self,
3441        now_slot: u64,
3442        oracle_price: u64,
3443        ordered_candidates: &[(u16, Option<LiquidationPolicy>)],
3444        max_revalidations: u16,
3445        funding_rate: i64,
3446    ) -> Result<CrankOutcome> {
3447        Self::validate_funding_rate(funding_rate)?;
3448
3449        if oracle_price == 0 || oracle_price > MAX_ORACLE_PRICE {
3450            return Err(RiskError::Overflow);
3451        }
3452
3453        // Step 1: initialize instruction context
3454        let mut ctx = InstructionContext::new();
3455
3456        // Steps 2-4: validate inputs
3457        if now_slot < self.current_slot {
3458            return Err(RiskError::Overflow);
3459        }
3460        if now_slot < self.last_market_slot {
3461            return Err(RiskError::Overflow);
3462        }
3463
3464        // Step 5: accrue_market_to exactly once
3465        self.accrue_market_to(now_slot, oracle_price)?;
3466
3467        // Step 6: current_slot = now_slot
3468        self.current_slot = now_slot;
3469
3470        let advanced = now_slot > self.last_crank_slot;
3471        if advanced {
3472            self.last_crank_slot = now_slot;
3473        }
3474
3475        // Step 7-8: process candidates in keeper-supplied order
3476        let mut attempts: u16 = 0;
3477        let mut num_liquidations: u32 = 0;
3478
3479        for &(candidate_idx, ref hint) in ordered_candidates {
3480            // Budget check
3481            if attempts >= max_revalidations {
3482                break;
3483            }
3484            // Stop on pending reset
3485            if ctx.pending_reset_long || ctx.pending_reset_short {
3486                break;
3487            }
3488            // Skip missing accounts (doesn't count against budget)
3489            if (candidate_idx as usize) >= MAX_ACCOUNTS || !self.is_used(candidate_idx as usize) {
3490                continue;
3491            }
3492
3493            // Count as an attempt
3494            attempts += 1;
3495            let cidx = candidate_idx as usize;
3496
3497            // Per-candidate local exact-touch (spec §11.2): same as touch_account_full_not_atomic
3498            // steps 7-13 on already-accrued state. MUST NOT call accrue_market_to again.
3499
3500            // Step 7: advance_profit_warmup
3501            self.advance_profit_warmup(cidx);
3502
3503            // Step 8: settle_side_effects (handles restart_warmup internally)
3504            self.settle_side_effects(cidx)?;
3505
3506            // Step 9: settle losses
3507            self.settle_losses(cidx);
3508
3509            // Step 10: resolve flat negative
3510            if self.effective_pos_q(cidx) == 0 && self.accounts[cidx].pnl < 0 {
3511                self.resolve_flat_negative(cidx);
3512            }
3513
3514            // Step 11: maintenance fees (spec §8.2)
3515            self.settle_maintenance_fee_internal(cidx, now_slot)?;
3516
3517            // Step 12: if flat, profit conversion
3518            if self.accounts[cidx].position_basis_q == 0 {
3519                self.do_profit_conversion(cidx);
3520            }
3521
3522            // Step 13: fee debt sweep
3523            self.fee_debt_sweep(cidx);
3524
3525            // Check if liquidatable after exact current-state touch.
3526            // Apply hint if present and current-state-valid (spec §11.1 rule 3).
3527            if !ctx.pending_reset_long && !ctx.pending_reset_short {
3528                let eff = self.effective_pos_q(cidx);
3529                if eff != 0 {
3530                    if !self.is_above_maintenance_margin(&self.accounts[cidx], cidx, oracle_price) {
3531                        // Validate hint via stateless pre-flight (spec §11.1 rule 3).
3532                        // None hint → no action per spec §11.2.
3533                        // Invalid ExactPartial → None (no action) per spec §11.1 rule 3.
3534                        if let Some(policy) =
3535                            self.validate_keeper_hint(candidate_idx, eff, hint, oracle_price)
3536                        {
3537                            match self.liquidate_at_oracle_internal(
3538                                candidate_idx,
3539                                now_slot,
3540                                oracle_price,
3541                                policy,
3542                                &mut ctx,
3543                            ) {
3544                                Ok(true) => {
3545                                    num_liquidations += 1;
3546                                }
3547                                Ok(false) => {}
3548                                Err(e) => return Err(e),
3549                            }
3550                        }
3551                    }
3552                }
3553            }
3554        }
3555
3556        // Steps 9-10: end-of-instruction resets
3557        self.schedule_end_of_instruction_resets(&mut ctx)?;
3558        self.finalize_end_of_instruction_resets(&ctx);
3559
3560        // Step 11: recompute r_last exactly once from final post-reset state
3561        self.recompute_r_last_from_final_state(funding_rate)?;
3562
3563        // Step 12: assert OI balance
3564        assert!(
3565            self.oi_eff_long_q == self.oi_eff_short_q,
3566            "OI_eff_long != OI_eff_short after keeper_crank_not_atomic"
3567        );
3568
3569        Ok(CrankOutcome {
3570            advanced,
3571            slots_forgiven: 0,
3572            caller_settle_ok: true,
3573            force_realize_needed: false,
3574            panic_needed: false,
3575            num_liquidations,
3576            num_liq_errors: 0,
3577            num_gc_closed: 0,
3578            last_cursor: 0,
3579            sweep_complete: false,
3580        })
3581    }
3582
3583    /// Validate a keeper-supplied liquidation-policy hint (spec §11.1 rule 3).
3584    /// Returns None if no liquidation action should be taken (absent hint per
3585    /// spec §11.2), or Some(policy) if the hint is valid. ExactPartial hints
3586    /// are validated via a stateless pre-flight check; invalid partials fall
3587    /// back to FullClose to preserve crank liveness.
3588    ///
3589    /// Pre-flight correctness: settle_losses preserves C + PNL (spec §7.1),
3590    /// and the synthetic close at oracle generates zero additional PnL delta,
3591    /// so Eq_maint_raw after partial = Eq_maint_raw_before - liq_fee.
3592    test_visible! {
3593    fn validate_keeper_hint(
3594        &self,
3595        idx: u16,
3596        eff: i128,
3597        hint: &Option<LiquidationPolicy>,
3598        oracle_price: u64,
3599    ) -> Option<LiquidationPolicy> {
3600        match hint {
3601            // Spec §11.2: absent hint means no liquidation action for this candidate.
3602            None => None,
3603            Some(LiquidationPolicy::FullClose) => Some(LiquidationPolicy::FullClose),
3604            Some(LiquidationPolicy::ExactPartial(q_close_q)) => {
3605                let abs_eff = eff.unsigned_abs();
3606                // Bounds check: 0 < q_close_q < abs(eff)
3607                // Spec §11.1 rule 3: invalid hint → no liquidation action (None)
3608                if *q_close_q == 0 || *q_close_q >= abs_eff {
3609                    return None;
3610                }
3611
3612                // Stateless pre-flight: predict post-partial maintenance health.
3613                let account = &self.accounts[idx as usize];
3614
3615                // 1. Predict liquidation fee
3616                let notional_closed = mul_div_floor_u128(*q_close_q, oracle_price as u128, POS_SCALE);
3617                let liq_fee_raw = mul_div_ceil_u128(notional_closed, self.params.liquidation_fee_bps as u128, 10_000);
3618                let liq_fee = core::cmp::min(
3619                    core::cmp::max(liq_fee_raw, self.params.min_liquidation_abs.get()),
3620                    self.params.liquidation_fee_cap.get(),
3621                );
3622
3623                // 2. Predict post-partial Eq_maint_raw (settle_losses preserves C + PNL sum).
3624                // Model the same capped fee application as charge_fee_to_insurance:
3625                // only capital + collectible fee-debt headroom is actually applied.
3626                let cap = account.capital.get();
3627                let fee_from_capital = core::cmp::min(liq_fee, cap);
3628                let fee_shortfall = liq_fee - fee_from_capital;
3629                let current_fc = account.fee_credits.get();
3630                let fc_headroom = match current_fc.checked_add(i128::MAX) {
3631                    Some(h) if h > 0 => h as u128,
3632                    _ => 0u128,
3633                };
3634                let fee_from_debt = core::cmp::min(fee_shortfall, fc_headroom);
3635                let fee_applied = fee_from_capital + fee_from_debt;
3636
3637                let eq_raw_wide = self.account_equity_maint_raw_wide(account);
3638                let predicted_eq = match eq_raw_wide.checked_sub(I256::from_u128(fee_applied)) {
3639                    Some(v) => v,
3640                    None => return None,
3641                };
3642
3643                // 3. Predict post-partial MM_req
3644                let rem_eff = abs_eff - *q_close_q;
3645                let rem_notional = mul_div_floor_u128(rem_eff, oracle_price as u128, POS_SCALE);
3646                let proportional_mm = mul_div_floor_u128(rem_notional, self.params.maintenance_margin_bps as u128, 10_000);
3647                let predicted_mm_req = if rem_eff == 0 {
3648                    0u128
3649                } else {
3650                    core::cmp::max(proportional_mm, self.params.min_nonzero_mm_req)
3651                };
3652
3653                // 4. Health check: predicted_eq > predicted_mm_req
3654                // Spec §11.1 rule 3: failed pre-flight → no liquidation action (None)
3655                if predicted_eq <= I256::from_u128(predicted_mm_req) {
3656                    return None;
3657                }
3658
3659                Some(LiquidationPolicy::ExactPartial(*q_close_q))
3660            }
3661        }
3662    }
3663    }
3664
3665    // ========================================================================
3666    // convert_released_pnl_not_atomic (spec §10.4.1)
3667    // ========================================================================
3668
3669    /// Explicit voluntary conversion of matured released positive PnL for open-position accounts.
3670    pub fn convert_released_pnl_not_atomic(
3671        &mut self,
3672        idx: u16,
3673        x_req: u128,
3674        oracle_price: u64,
3675        now_slot: u64,
3676        funding_rate: i64,
3677    ) -> Result<()> {
3678        Self::validate_funding_rate(funding_rate)?;
3679
3680        if oracle_price == 0 || oracle_price > MAX_ORACLE_PRICE {
3681            return Err(RiskError::Overflow);
3682        }
3683        if !self.is_used(idx as usize) {
3684            return Err(RiskError::AccountNotFound);
3685        }
3686
3687        let mut ctx = InstructionContext::new();
3688
3689        // Step 3: touch_account_full_not_atomic
3690        self.touch_account_full_not_atomic(idx as usize, oracle_price, now_slot)?;
3691
3692        // Step 4: if flat, auto-conversion already happened in touch
3693        if self.accounts[idx as usize].position_basis_q == 0 {
3694            self.schedule_end_of_instruction_resets(&mut ctx)?;
3695            self.finalize_end_of_instruction_resets(&ctx);
3696            self.recompute_r_last_from_final_state(funding_rate)?;
3697            return Ok(());
3698        }
3699
3700        // Step 5: require 0 < x_req <= ReleasedPos_i
3701        let released = self.released_pos(idx as usize);
3702        if x_req == 0 || x_req > released {
3703            return Err(RiskError::Overflow);
3704        }
3705
3706        // Step 6: compute y using pre-conversion haircut (spec §7.4).
3707        // Because x_req > 0 implies pnl_matured_pos_tot > 0, h_den is strictly positive.
3708        let (h_num, h_den) = self.haircut_ratio();
3709        assert!(
3710            h_den > 0,
3711            "convert_released_pnl_not_atomic: h_den must be > 0 when x_req > 0"
3712        );
3713        let y: u128 = wide_mul_div_floor_u128(x_req, h_num, h_den);
3714
3715        // Step 7: consume_released_pnl(i, x_req)
3716        self.consume_released_pnl(idx as usize, x_req);
3717
3718        // Step 8: set_capital(i, C_i + y)
3719        let new_cap = add_u128(self.accounts[idx as usize].capital.get(), y);
3720        self.set_capital(idx as usize, new_cap);
3721
3722        // Step 9: sweep fee debt
3723        self.fee_debt_sweep(idx as usize);
3724
3725        // Step 10: require maintenance healthy if still has position
3726        let eff = self.effective_pos_q(idx as usize);
3727        if eff != 0 {
3728            if !self.is_above_maintenance_margin(
3729                &self.accounts[idx as usize],
3730                idx as usize,
3731                oracle_price,
3732            ) {
3733                return Err(RiskError::Undercollateralized);
3734            }
3735        }
3736
3737        // Steps 11-12: end-of-instruction resets
3738        self.schedule_end_of_instruction_resets(&mut ctx)?;
3739        self.finalize_end_of_instruction_resets(&ctx);
3740        self.recompute_r_last_from_final_state(funding_rate)?;
3741
3742        Ok(())
3743    }
3744
3745    // ========================================================================
3746    // close_account_not_atomic
3747    // ========================================================================
3748
3749    pub fn close_account_not_atomic(
3750        &mut self,
3751        idx: u16,
3752        now_slot: u64,
3753        oracle_price: u64,
3754        funding_rate: i64,
3755    ) -> Result<u128> {
3756        Self::validate_funding_rate(funding_rate)?;
3757
3758        if idx as usize >= MAX_ACCOUNTS || !self.is_used(idx as usize) {
3759            return Err(RiskError::AccountNotFound);
3760        }
3761
3762        let mut ctx = InstructionContext::new();
3763
3764        self.touch_account_full_not_atomic(idx as usize, oracle_price, now_slot)?;
3765
3766        // Position must be zero
3767        let eff = self.effective_pos_q(idx as usize);
3768        if eff != 0 {
3769            return Err(RiskError::Undercollateralized);
3770        }
3771
3772        // PnL must be zero (check BEFORE fee forgiveness to avoid
3773        // mutating fee_credits on a path that returns Err)
3774        if self.accounts[idx as usize].pnl > 0 {
3775            return Err(RiskError::PnlNotWarmedUp);
3776        }
3777        if self.accounts[idx as usize].pnl < 0 {
3778            return Err(RiskError::Undercollateralized);
3779        }
3780
3781        // Forgive fee debt (safe: position is zero, PnL is zero)
3782        if self.accounts[idx as usize].fee_credits.get() < 0 {
3783            self.accounts[idx as usize].fee_credits = I128::ZERO;
3784        }
3785
3786        let capital = self.accounts[idx as usize].capital;
3787
3788        if capital > self.vault {
3789            return Err(RiskError::InsufficientBalance);
3790        }
3791        self.vault = self.vault - capital;
3792        self.set_capital(idx as usize, 0);
3793
3794        // End-of-instruction resets before freeing
3795        self.schedule_end_of_instruction_resets(&mut ctx)?;
3796        self.finalize_end_of_instruction_resets(&ctx);
3797        self.recompute_r_last_from_final_state(funding_rate)?;
3798
3799        self.free_slot(idx);
3800
3801        Ok(capital.get())
3802    }
3803
3804    // ========================================================================
3805    // force_close_resolved_not_atomic (resolved/frozen market path)
3806    // ========================================================================
3807
3808    /// Force-close an account on a resolved market.
3809    ///
3810    /// `resolved_slot` is the market resolution boundary slot, used to anchor
3811    /// `current_slot` and realize maintenance fees through that slot.
3812    ///
3813    /// Settles K-pair PnL, zeros position, settles losses, absorbs from
3814    /// insurance, converts profit (bypassing warmup), sweeps fee debt,
3815    /// forgives remainder, returns capital, frees slot.
3816    ///
3817    /// Skips accrue_market_to (market is frozen). Handles both same-epoch
3818    /// and epoch-mismatch accounts.
3819    pub fn force_close_resolved_not_atomic(
3820        &mut self,
3821        idx: u16,
3822        resolved_slot: u64,
3823    ) -> Result<u128> {
3824        if idx as usize >= MAX_ACCOUNTS || !self.is_used(idx as usize) {
3825            return Err(RiskError::AccountNotFound);
3826        }
3827        if resolved_slot < self.current_slot {
3828            return Err(RiskError::Overflow);
3829        }
3830        self.current_slot = resolved_slot;
3831
3832        let i = idx as usize;
3833
3834        // Step 1: Settle K-pair PnL and zero position.
3835        // Uses validate-then-mutate: compute pnl_delta and validate all checked
3836        // ops BEFORE any mutation, preventing partial-mutation-on-error.
3837        // Does NOT call settle_side_effects (which interleaves mutations with
3838        // fallible checked_sub on stale_count).
3839        if self.accounts[i].position_basis_q != 0 {
3840            let basis = self.accounts[i].position_basis_q;
3841            let abs_basis = basis.unsigned_abs();
3842            let a_basis = self.accounts[i].adl_a_basis;
3843            let k_snap = self.accounts[i].adl_k_snap;
3844            let side = side_of_i128(basis).unwrap();
3845            let epoch_snap = self.accounts[i].adl_epoch_snap;
3846            let epoch_side = self.get_epoch_side(side);
3847
3848            // Reject corrupt ADL state (a_basis must be > 0 for any position)
3849            if a_basis == 0 {
3850                return Err(RiskError::CorruptState);
3851            }
3852
3853            // Phase 1: COMPUTE (no mutations)
3854            let k_end = if epoch_snap == epoch_side {
3855                self.get_k_side(side)
3856            } else {
3857                self.get_k_epoch_start(side)
3858            };
3859            let den = a_basis.checked_mul(POS_SCALE).ok_or(RiskError::Overflow)?;
3860            let pnl_delta = wide_signed_mul_div_floor_from_k_pair(abs_basis, k_snap, k_end, den);
3861
3862            // Phase 1b: VALIDATE (check all fallible ops before mutating)
3863            let new_pnl = self.accounts[i]
3864                .pnl
3865                .checked_add(pnl_delta)
3866                .ok_or(RiskError::Overflow)?;
3867            if new_pnl == i128::MIN {
3868                return Err(RiskError::Overflow);
3869            }
3870            // Compute OI decrement before any mutation.
3871            // In resolved-market force-close, OI may already be partially or
3872            // fully decremented by prior force-closes of the opposing side.
3873            // Use saturating_sub for both sides to handle this gracefully.
3874            let eff = self.effective_pos_q(i);
3875            let eff_abs = eff.unsigned_abs();
3876
3877            if epoch_snap != epoch_side {
3878                // Validate epoch adjacency (same check as settle_side_effects
3879                // minus the ResetPending mode check, which is relaxed for
3880                // resolved markets where the side may be in any mode)
3881                if epoch_snap.checked_add(1) != Some(epoch_side) {
3882                    return Err(RiskError::CorruptState);
3883                }
3884                let old_stale = self.get_stale_count(side);
3885                if old_stale == 0 {
3886                    return Err(RiskError::CorruptState);
3887                }
3888            }
3889
3890            // Phase 2: MUTATE (all validated, safe to commit)
3891            if pnl_delta != 0 {
3892                let old_r = self.accounts[i].reserved_pnl;
3893                self.set_pnl(i, new_pnl);
3894                if self.accounts[i].reserved_pnl > old_r {
3895                    self.restart_warmup_after_reserve_increase(i);
3896                }
3897            }
3898
3899            // Decrement stale count (pre-validated above)
3900            if epoch_snap != epoch_side {
3901                let old_stale = self.get_stale_count(side);
3902                self.set_stale_count(side, old_stale - 1);
3903            }
3904
3905            // Decrement OI bilaterally — saturating for both sides because
3906            // prior force-closes of the opposing side may have already zeroed OI.
3907            if eff_abs > 0 {
3908                self.oi_eff_long_q = self.oi_eff_long_q.saturating_sub(eff_abs);
3909                self.oi_eff_short_q = self.oi_eff_short_q.saturating_sub(eff_abs);
3910            }
3911
3912            // Account for same-epoch phantom dust before zeroing (same logic
3913            // as attach_effective_position detach path, spec §4.5/§4.6)
3914            if epoch_snap == epoch_side && a_basis != 0 {
3915                let a_side_val = self.get_a_side(side);
3916                let product = U256::from_u128(abs_basis).checked_mul(U256::from_u128(a_side_val));
3917                if let Some(p) = product {
3918                    let rem = p.checked_rem(U256::from_u128(a_basis));
3919                    if let Some(r) = rem {
3920                        if !r.is_zero() {
3921                            self.inc_phantom_dust_bound(side);
3922                        }
3923                    }
3924                }
3925            }
3926
3927            // Zero position
3928            self.set_position_basis_q(i, 0);
3929            self.accounts[i].adl_a_basis = ADL_ONE;
3930            self.accounts[i].adl_k_snap = 0;
3931            self.accounts[i].adl_epoch_snap = 0;
3932        }
3933
3934        // Step 2: Settle losses from principal (senior to fees)
3935        self.settle_losses(i);
3936
3937        // Step 3: Absorb any remaining flat negative PnL
3938        self.resolve_flat_negative(i);
3939
3940        // Step 3b: Realize recurring maintenance fees (spec §8.2).
3941        // After losses and flat-negative absorption, matching touch_account_full_not_atomic
3942        // ordering where fees are junior to trading losses.
3943        self.settle_maintenance_fee_internal(i, self.current_slot)?;
3944
3945        // Step 4: Convert positive PnL to capital (bypass warmup for resolved market).
3946        // Uses the same release-then-haircut order as do_profit_conversion and
3947        // convert_released_pnl_not_atomic. Sequential closers see progressively larger
3948        // pnl_matured_pos_tot denominators, which is the same behavior as normal
3949        // sequential profit conversion — this is inherent to the haircut model,
3950        // not a force_close-specific issue.
3951        if self.accounts[i].pnl > 0 {
3952            // Release all reserves unconditionally (bypass warmup)
3953            self.set_reserved_pnl(i, 0);
3954            // Convert using post-release haircut
3955            let released = self.released_pos(i);
3956            if released > 0 {
3957                let (h_num, h_den) = self.haircut_ratio();
3958                let y = if h_den == 0 {
3959                    released
3960                } else {
3961                    wide_mul_div_floor_u128(released, h_num, h_den)
3962                };
3963                self.consume_released_pnl(i, released);
3964                let new_cap = add_u128(self.accounts[i].capital.get(), y);
3965                self.set_capital(i, new_cap);
3966            }
3967        }
3968
3969        // Step 5: Sweep fee debt from capital
3970        self.fee_debt_sweep(i);
3971
3972        // Step 6: Forgive any remaining fee debt
3973        if self.accounts[i].fee_credits.get() < 0 {
3974            self.accounts[i].fee_credits = I128::ZERO;
3975        }
3976
3977        // Step 7: Return capital and free slot
3978        let capital = self.accounts[i].capital;
3979        if capital > self.vault {
3980            return Err(RiskError::InsufficientBalance);
3981        }
3982        self.vault = self.vault - capital;
3983        self.set_capital(i, 0);
3984
3985        self.free_slot(idx);
3986
3987        Ok(capital.get())
3988    }
3989
3990    // ========================================================================
3991    // Permissionless account reclamation (spec §10.7 + §2.6)
3992    // ========================================================================
3993
3994    /// reclaim_empty_account_not_atomic(i, now_slot) — permissionless O(1) empty/dust-account recycling.
3995    /// Spec §10.7: MUST NOT call accrue_market_to, MUST NOT mutate side state,
3996    /// MUST NOT materialize any account. Realizes recurring maintenance fees
3997    /// on the already-flat state before checking final reclaim eligibility.
3998    pub fn reclaim_empty_account_not_atomic(&mut self, idx: u16, now_slot: u64) -> Result<()> {
3999        if idx as usize >= MAX_ACCOUNTS || !self.is_used(idx as usize) {
4000            return Err(RiskError::AccountNotFound);
4001        }
4002        if now_slot < self.current_slot {
4003            return Err(RiskError::Overflow);
4004        }
4005
4006        // Step 3: Pre-realization flat-clean preconditions (spec §10.7 / §2.6)
4007        let account = &self.accounts[idx as usize];
4008        if account.position_basis_q != 0 {
4009            return Err(RiskError::Undercollateralized);
4010        }
4011        if account.pnl != 0 {
4012            return Err(RiskError::Undercollateralized);
4013        }
4014        if account.reserved_pnl != 0 {
4015            return Err(RiskError::Undercollateralized);
4016        }
4017        if account.fee_credits.get() > 0 {
4018            return Err(RiskError::Undercollateralized);
4019        }
4020
4021        // Step 4: anchor current_slot
4022        self.current_slot = now_slot;
4023
4024        // Step 5: realize recurring maintenance fees (spec §8.2.3 item 3)
4025        self.settle_maintenance_fee_internal(idx as usize, now_slot)?;
4026
4027        // Step 6: final reclaim-eligibility check (spec §2.6)
4028        // C_i must be 0 or dust (< MIN_INITIAL_DEPOSIT)
4029        if self.accounts[idx as usize].capital.get() >= self.params.min_initial_deposit.get()
4030            && !self.accounts[idx as usize].capital.is_zero()
4031        {
4032            return Err(RiskError::Undercollateralized);
4033        }
4034
4035        // Step 7: reclamation effects (spec §2.6)
4036        let dust_cap = self.accounts[idx as usize].capital.get();
4037        if dust_cap > 0 {
4038            self.set_capital(idx as usize, 0);
4039            self.insurance_fund.balance = self.insurance_fund.balance + dust_cap;
4040        }
4041
4042        // Forgive uncollectible fee debt (spec §2.6)
4043        if self.accounts[idx as usize].fee_credits.get() < 0 {
4044            self.accounts[idx as usize].fee_credits = I128::new(0);
4045        }
4046
4047        // Free the slot
4048        self.free_slot(idx);
4049
4050        Ok(())
4051    }
4052
4053    // ========================================================================
4054    // Garbage collection
4055    // ========================================================================
4056
4057    test_visible! {
4058    fn garbage_collect_dust(&mut self) -> u32 {
4059        let mut to_free: [u16; GC_CLOSE_BUDGET as usize] = [0; GC_CLOSE_BUDGET as usize];
4060        let mut num_to_free = 0usize;
4061
4062        let max_scan = (ACCOUNTS_PER_CRANK as usize).min(MAX_ACCOUNTS);
4063        let start = self.gc_cursor as usize;
4064
4065        let mut scanned: usize = 0;
4066        for offset in 0..max_scan {
4067            if num_to_free >= GC_CLOSE_BUDGET as usize {
4068                break;
4069            }
4070            scanned = offset + 1;
4071
4072            let idx = (start + offset) & ACCOUNT_IDX_MASK;
4073            let block = idx >> 6;
4074            let bit = idx & 63;
4075            if (self.used[block] & (1u64 << bit)) == 0 {
4076                continue;
4077            }
4078
4079            // Dust predicate: check flat-clean preconditions BEFORE fee realization
4080            // (matching reclaim_empty_account_not_atomic pattern — spec §8.2.3).
4081            let account = &self.accounts[idx];
4082            if account.position_basis_q != 0 {
4083                continue;
4084            }
4085            if account.pnl != 0 {
4086                continue;
4087            }
4088            if account.reserved_pnl != 0 {
4089                continue;
4090            }
4091            if account.fee_credits.get() > 0 {
4092                continue;
4093            }
4094
4095            // Realize recurring maintenance fees on already-flat state (spec §8.2.3).
4096            // Only called after flat-clean preconditions are verified.
4097            if self.settle_maintenance_fee_internal(idx, self.current_slot).is_err() {
4098                continue;
4099            }
4100
4101            // Re-check capital after fee realization (fee may have reduced it)
4102            if self.accounts[idx].capital.get() >= self.params.min_initial_deposit.get()
4103                && !self.accounts[idx].capital.is_zero() {
4104                continue;
4105            }
4106
4107            // Sweep dust capital into insurance (spec §2.6)
4108            let dust_cap = self.accounts[idx].capital.get();
4109            if dust_cap > 0 {
4110                self.set_capital(idx, 0);
4111                self.insurance_fund.balance = self.insurance_fund.balance + dust_cap;
4112            }
4113
4114            // Forgive uncollectible fee debt (spec §2.6)
4115            if self.accounts[idx].fee_credits.get() < 0 {
4116                self.accounts[idx].fee_credits = I128::new(0);
4117            }
4118
4119            to_free[num_to_free] = idx as u16;
4120            num_to_free += 1;
4121        }
4122
4123        // Advance cursor by actual number of offsets scanned, not max_scan.
4124        // Prevents skipping unscanned accounts on early break.
4125        self.gc_cursor = ((start + scanned) & ACCOUNT_IDX_MASK) as u16;
4126
4127        for i in 0..num_to_free {
4128            self.free_slot(to_free[i]);
4129        }
4130
4131        num_to_free as u32
4132    }
4133    }
4134
4135    // ========================================================================
4136    // Crank freshness
4137    // ========================================================================
4138
4139    fn require_fresh_crank(&self, now_slot: u64) -> Result<()> {
4140        if now_slot.saturating_sub(self.last_crank_slot) > self.max_crank_staleness_slots {
4141            return Err(RiskError::Unauthorized);
4142        }
4143        Ok(())
4144    }
4145
4146    fn require_recent_full_sweep(&self, now_slot: u64) -> Result<()> {
4147        if now_slot.saturating_sub(self.last_full_sweep_start_slot) > self.max_crank_staleness_slots
4148        {
4149            return Err(RiskError::Unauthorized);
4150        }
4151        Ok(())
4152    }
4153
4154    // ========================================================================
4155    // Insurance fund operations
4156    // ========================================================================
4157
4158    pub fn top_up_insurance_fund(&mut self, amount: u128, now_slot: u64) -> Result<bool> {
4159        // Spec §10.3.2: time monotonicity
4160        if now_slot < self.current_slot {
4161            return Err(RiskError::Overflow);
4162        }
4163        // Validate-then-mutate: all checks before any state change
4164        let new_vault = self
4165            .vault
4166            .get()
4167            .checked_add(amount)
4168            .ok_or(RiskError::Overflow)?;
4169        if new_vault > MAX_VAULT_TVL {
4170            return Err(RiskError::Overflow);
4171        }
4172        let new_ins = self
4173            .insurance_fund
4174            .balance
4175            .get()
4176            .checked_add(amount)
4177            .ok_or(RiskError::Overflow)?;
4178        // All checks passed — commit
4179        self.current_slot = now_slot;
4180        self.vault = U128::new(new_vault);
4181        self.insurance_fund.balance = U128::new(new_ins);
4182        Ok(self.insurance_fund.balance.get() > self.params.insurance_floor.get())
4183    }
4184
4185    // set_insurance_floor removed — configuration immutability (spec §2.2.1).
4186    // Insurance floor is fixed at initialization and cannot be changed at runtime.
4187
4188    // ========================================================================
4189    // Fee credits
4190    // ========================================================================
4191
4192    pub fn deposit_fee_credits(&mut self, idx: u16, amount: u128, now_slot: u64) -> Result<()> {
4193        if idx as usize >= MAX_ACCOUNTS || !self.is_used(idx as usize) {
4194            return Err(RiskError::Unauthorized);
4195        }
4196        if now_slot < self.current_slot {
4197            return Err(RiskError::Unauthorized);
4198        }
4199        // Cap at outstanding debt to enforce spec §2.1 invariant: fee_credits <= 0
4200        let debt = fee_debt_u128_checked(self.accounts[idx as usize].fee_credits.get());
4201        let capped = amount.min(debt);
4202        if capped == 0 {
4203            self.current_slot = now_slot;
4204            return Ok(()); // no debt to pay off
4205        }
4206        if capped > i128::MAX as u128 {
4207            return Err(RiskError::Overflow);
4208        }
4209        let new_vault = self
4210            .vault
4211            .get()
4212            .checked_add(capped)
4213            .ok_or(RiskError::Overflow)?;
4214        if new_vault > MAX_VAULT_TVL {
4215            return Err(RiskError::Overflow);
4216        }
4217        let new_ins = self
4218            .insurance_fund
4219            .balance
4220            .get()
4221            .checked_add(capped)
4222            .ok_or(RiskError::Overflow)?;
4223        let new_credits = self.accounts[idx as usize]
4224            .fee_credits
4225            .checked_add(capped as i128)
4226            .ok_or(RiskError::Overflow)?;
4227        // All checks passed — commit state
4228        self.current_slot = now_slot;
4229        self.vault = U128::new(new_vault);
4230        self.insurance_fund.balance = U128::new(new_ins);
4231        self.accounts[idx as usize].fee_credits = new_credits;
4232        Ok(())
4233    }
4234
4235    #[cfg(any(test, feature = "test", kani))]
4236    test_visible! {
4237    fn add_fee_credits(&mut self, idx: u16, amount: u128) -> Result<()> {
4238        if idx as usize >= MAX_ACCOUNTS || !self.is_used(idx as usize) {
4239            return Err(RiskError::Unauthorized);
4240        }
4241        self.accounts[idx as usize].fee_credits = self.accounts[idx as usize]
4242            .fee_credits.saturating_add(amount as i128);
4243        Ok(())
4244    }
4245    }
4246
4247    // ========================================================================
4248    // Recompute aggregates (test helper)
4249    // ========================================================================
4250
4251    test_visible! {
4252    fn recompute_aggregates(&mut self) {
4253        let mut c_tot = 0u128;
4254        let mut pnl_pos_tot = 0u128;
4255        let mut pnl_matured_pos_tot = 0u128;
4256        self.for_each_used(|_idx, account| {
4257            c_tot = c_tot.saturating_add(account.capital.get());
4258            let pos_pnl = i128_clamp_pos(account.pnl);
4259            pnl_pos_tot = pnl_pos_tot.saturating_add(pos_pnl);
4260            let released = pos_pnl.saturating_sub(account.reserved_pnl);
4261            pnl_matured_pos_tot = pnl_matured_pos_tot.saturating_add(released);
4262        });
4263        self.c_tot = U128::new(c_tot);
4264        self.pnl_pos_tot = pnl_pos_tot;
4265        self.pnl_matured_pos_tot = pnl_matured_pos_tot;
4266    }
4267    }
4268
4269    // ========================================================================
4270    // Utilities
4271    // ========================================================================
4272
4273    test_visible! {
4274    fn advance_slot(&mut self, slots: u64) {
4275        self.current_slot = self.current_slot.saturating_add(slots);
4276    }
4277    }
4278
4279    /// Count used accounts
4280    test_visible! {
4281    fn count_used(&self) -> u64 {
4282        let mut count = 0u64;
4283        self.for_each_used(|_, _| {
4284            count += 1;
4285        });
4286        count
4287    }
4288    }
4289}
4290
4291// ============================================================================
4292// Free-standing helpers
4293// ============================================================================
4294
4295/// Set pending reset on a side in the instruction context
4296fn set_pending_reset(ctx: &mut InstructionContext, side: Side) {
4297    match side {
4298        Side::Long => ctx.pending_reset_long = true,
4299        Side::Short => ctx.pending_reset_short = true,
4300    }
4301}
4302
4303/// Multiply a u128 by an i128 returning i128 (checked).
4304/// Computes u128 * i128 → i128. Used for A_side * delta_p in accrue_market_to.
4305pub fn checked_u128_mul_i128(a: u128, b: i128) -> Result<i128> {
4306    if a == 0 || b == 0 {
4307        return Ok(0i128);
4308    }
4309    let negative = b < 0;
4310    let abs_b = if b == i128::MIN {
4311        return Err(RiskError::Overflow);
4312    } else {
4313        b.unsigned_abs()
4314    };
4315    // a * abs_b may overflow u128, use wide arithmetic
4316    let product = U256::from_u128(a)
4317        .checked_mul(U256::from_u128(abs_b))
4318        .ok_or(RiskError::Overflow)?;
4319    // Bound to i128::MAX magnitude for both signs. Excludes i128::MIN (which is
4320    // forbidden throughout the engine) and avoids -(i128::MIN) negate panic.
4321    match product.try_into_u128() {
4322        Some(v) if v <= i128::MAX as u128 => {
4323            if negative {
4324                Ok(-(v as i128))
4325            } else {
4326                Ok(v as i128)
4327            }
4328        }
4329        _ => Err(RiskError::Overflow),
4330    }
4331}
4332
4333/// Compute trade PnL: floor_div_signed_conservative(size_q * price_diff, POS_SCALE)
4334/// Uses native i128 arithmetic (spec §1.5.1 shows trade slippage fits in i128).
4335pub fn compute_trade_pnl(size_q: i128, price_diff: i128) -> Result<i128> {
4336    if size_q == 0 || price_diff == 0 {
4337        return Ok(0i128);
4338    }
4339
4340    // Determine sign of result
4341    let neg_size = size_q < 0;
4342    let neg_price = price_diff < 0;
4343    let result_negative = neg_size != neg_price;
4344
4345    let abs_size = size_q.unsigned_abs();
4346    let abs_price = price_diff.unsigned_abs();
4347
4348    // Use wide_signed_mul_div_floor_from_k_pair style computation
4349    // abs_size * abs_price / POS_SCALE with signed floor rounding
4350    let abs_size_u256 = U256::from_u128(abs_size);
4351    let abs_price_u256 = U256::from_u128(abs_price);
4352    let ps_u256 = U256::from_u128(POS_SCALE);
4353
4354    // div_rem using mul_div_floor_u256_with_rem (internally computes wide product)
4355    let (q, r) = mul_div_floor_u256_with_rem(abs_size_u256, abs_price_u256, ps_u256);
4356
4357    if result_negative {
4358        // mag = q + 1 if r != 0, else q (floor toward -inf)
4359        let mag = if !r.is_zero() {
4360            q.checked_add(U256::ONE).ok_or(RiskError::Overflow)?
4361        } else {
4362            q
4363        };
4364        // Bound to i128::MAX magnitude to avoid -(i128::MIN) negate panic.
4365        // i128::MIN is forbidden throughout the engine.
4366        match mag.try_into_u128() {
4367            Some(v) if v <= i128::MAX as u128 => Ok(-(v as i128)),
4368            _ => Err(RiskError::Overflow),
4369        }
4370    } else {
4371        match q.try_into_u128() {
4372            Some(v) if v <= i128::MAX as u128 => Ok(v as i128),
4373            _ => Err(RiskError::Overflow),
4374        }
4375    }
4376}