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