Skip to main content

percli_core/percolator/
engine.rs

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