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