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