repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
public Clawd ADK gateway launch mirror
stars
latest
clone command
git clone gitlawb://did:key:z6Mkq5mY...iFZ5/my-project-publ...git clone gitlawb://did:key:z6Mkq5mY.../my-project-publ...2fa351d6docs: add automaton and perps launch sources16d ago| #1 | //! Comprehensive Fuzzing Suite for the Risk Engine |
| #2 | //! |
| #3 | //! ## Running Tests |
| #4 | //! - Quick: `cargo test --features fuzz` (100 proptest cases, 200 deterministic seeds) |
| #5 | //! - Deep: `PROPTEST_CASES=1000 cargo test --features fuzz fuzz_deterministic_extended` |
| #6 | //! |
| #7 | //! ## Atomicity Model (Solana) |
| #8 | //! |
| #9 | //! This program relies on Solana transaction atomicity: if any instruction returns Err, |
| #10 | //! the entire transaction is aborted and no account state changes are committed. |
| #11 | //! Therefore we do not require "no mutation on Err" inside a single instruction. |
| #12 | //! |
| #13 | //! All functions must still propagate errors (never ignore a Result and continue). |
| #14 | //! The fuzz suite simulates Solana atomicity by cloning engine state before each op |
| #15 | //! and restoring on Err. Invariants are only asserted after successful (Ok) operations. |
| #16 | //! |
| #17 | //! ## Invariant Definitions |
| #18 | //! |
| #19 | //! ### Conservation (check_conservation) |
| #20 | //! vault >= C_tot + insurance |
| #21 | //! |
| #22 | //! With the ADL K-coefficient funding model, funding is applied via side-global |
| #23 | //! K coefficients and per-account snapshots. There is no lazy funding index. |
| #24 | //! Conservation is simply: vault >= c_tot + insurance. |
| #25 | //! |
| #26 | //! ## Suite Components |
| #27 | //! - Global invariants (conservation, aggregate consistency) |
| #28 | //! - Action-based state machine fuzzer with Solana rollback simulation |
| #29 | //! - Focused unit property tests |
| #30 | //! - Deterministic seeded fuzzer with logging |
| #31 | |
| #32 | #![cfg(feature = "fuzz")] |
| #33 | |
| #34 | use percolator::*; |
| #35 | use proptest::prelude::*; |
| #36 | |
| #37 | // ============================================================================ |
| #38 | // CONSTANTS |
| #39 | // ============================================================================ |
| #40 | |
| #41 | // Default oracle price for conservation checks |
| #42 | const DEFAULT_ORACLE: u64 = 1_000_000; |
| #43 | |
| #44 | // ============================================================================ |
| #45 | // SECTION 1: HELPER FUNCTIONS |
| #46 | // ============================================================================ |
| #47 | |
| #48 | /// Helper to check if an account slot is used by accessing the used bitmap |
| #49 | fn is_account_used(engine: &RiskEngine, idx: u16) -> bool { |
| #50 | let idx = idx as usize; |
| #51 | if idx >= engine.accounts.len() { |
| #52 | return false; |
| #53 | } |
| #54 | // Access the used bitmap directly: used[w] bit b |
| #55 | let w = idx >> 6; // word index (idx / 64) |
| #56 | let b = idx & 63; // bit index (idx % 64) |
| #57 | if w >= engine.used.len() { |
| #58 | return false; |
| #59 | } |
| #60 | ((engine.used[w] >> b) & 1) == 1 |
| #61 | } |
| #62 | |
| #63 | /// Helper to get the safe upper bound for account iteration |
| #64 | #[inline] |
| #65 | fn account_count(engine: &RiskEngine) -> usize { |
| #66 | core::cmp::min(engine.params.max_accounts as usize, engine.accounts.len()) |
| #67 | } |
| #68 | |
| #69 | // ============================================================================ |
| #70 | // SECTION 2: GLOBAL INVARIANTS HELPER |
| #71 | // ============================================================================ |
| #72 | |
| #73 | /// Assert all global invariants hold |
| #74 | /// IMPORTANT: This function is PURE - it does NOT mutate the engine. |
| #75 | /// Invariant checks must reflect on-chain semantics (funding is lazy). |
| #76 | fn assert_global_invariants(engine: &RiskEngine, context: &str) { |
| #77 | // 1. Primary conservation: vault >= C_tot + insurance |
| #78 | // This is oracle-independent (no mark PnL). The extended check with mark PnL |
| #79 | // requires a consistent oracle across all account entry_prices, which the fuzzer |
| #80 | // cannot guarantee when trades happen at different prices. |
| #81 | let vault = engine.vault.get(); |
| #82 | let c_tot = engine.c_tot.get(); |
| #83 | let insurance = engine.insurance_fund.balance.get(); |
| #84 | assert!( |
| #85 | vault >= c_tot.saturating_add(insurance), |
| #86 | "{}: Primary conservation violated: vault={} < c_tot={} + insurance={}", |
| #87 | context, |
| #88 | vault, |
| #89 | c_tot, |
| #90 | insurance, |
| #91 | ); |
| #92 | |
| #93 | // 2. Aggregate consistency: c_tot == sum(capital), pnl_pos_tot == sum(max(pnl,0)) |
| #94 | let mut sum_capital = 0u128; |
| #95 | let mut sum_pnl_pos = 0u128; |
| #96 | let n = account_count(engine); |
| #97 | for i in 0..n { |
| #98 | if is_account_used(engine, i as u16) { |
| #99 | let acc = &engine.accounts[i]; |
| #100 | sum_capital += acc.capital.get(); |
| #101 | let pnl = acc.pnl; |
| #102 | if pnl > 0 { |
| #103 | sum_pnl_pos += pnl as u128; |
| #104 | } |
| #105 | } |
| #106 | } |
| #107 | assert_eq!( |
| #108 | engine.c_tot.get(), |
| #109 | sum_capital, |
| #110 | "{}: c_tot={} != sum(capital)={}", |
| #111 | context, |
| #112 | engine.c_tot.get(), |
| #113 | sum_capital |
| #114 | ); |
| #115 | assert_eq!( |
| #116 | engine.pnl_pos_tot, |
| #117 | sum_pnl_pos, |
| #118 | "{}: pnl_pos_tot={} != sum(max(pnl,0))={}", |
| #119 | context, |
| #120 | engine.pnl_pos_tot, |
| #121 | sum_pnl_pos |
| #122 | ); |
| #123 | |
| #124 | // 3. Account local sanity (for each used account) |
| #125 | for i in 0..n { |
| #126 | if is_account_used(engine, i as u16) { |
| #127 | let acc = &engine.accounts[i]; |
| #128 | |
| #129 | // reserved_pnl <= max(0, pnl) |
| #130 | let pnl = acc.pnl; |
| #131 | let positive_pnl = if pnl > 0 { pnl as u128 } else { 0 }; |
| #132 | assert!( |
| #133 | acc.reserved_pnl <= positive_pnl, |
| #134 | "{}: Account {} has reserved_pnl={} > positive_pnl={}", |
| #135 | context, |
| #136 | i, |
| #137 | acc.reserved_pnl, |
| #138 | positive_pnl |
| #139 | ); |
| #140 | } |
| #141 | } |
| #142 | } |
| #143 | |
| #144 | // ============================================================================ |
| #145 | // SECTION 3: PARAMETER REGIMES |
| #146 | // ============================================================================ |
| #147 | |
| #148 | /// Regime A: Normal mode (small floors) |
| #149 | fn params_regime_a() -> RiskParams { |
| #150 | RiskParams { |
| #151 | warmup_period_slots: 100, |
| #152 | maintenance_margin_bps: 500, |
| #153 | initial_margin_bps: 1000, |
| #154 | trading_fee_bps: 10, |
| #155 | max_accounts: 32, // Small for speed |
| #156 | new_account_fee: U128::new(0), |
| #157 | maintenance_fee_per_slot: U128::new(0), |
| #158 | max_crank_staleness_slots: u64::MAX, |
| #159 | liquidation_fee_bps: 50, |
| #160 | liquidation_fee_cap: U128::new(100_000), |
| #161 | liquidation_buffer_bps: 100, |
| #162 | min_liquidation_abs: U128::new(100_000), |
| #163 | min_initial_deposit: U128::new(2), |
| #164 | min_nonzero_mm_req: 1, |
| #165 | min_nonzero_im_req: 2, |
| #166 | insurance_floor: U128::ZERO, |
| #167 | } |
| #168 | } |
| #169 | |
| #170 | /// Regime B: Floor + risk mode sensitivity (floor = 1000) |
| #171 | fn params_regime_b() -> RiskParams { |
| #172 | RiskParams { |
| #173 | warmup_period_slots: 100, |
| #174 | maintenance_margin_bps: 500, |
| #175 | initial_margin_bps: 1000, |
| #176 | trading_fee_bps: 10, |
| #177 | max_accounts: 32, // Small for speed |
| #178 | new_account_fee: U128::new(0), |
| #179 | maintenance_fee_per_slot: U128::new(0), |
| #180 | max_crank_staleness_slots: u64::MAX, |
| #181 | liquidation_fee_bps: 50, |
| #182 | liquidation_fee_cap: U128::new(100_000), |
| #183 | liquidation_buffer_bps: 100, |
| #184 | min_liquidation_abs: U128::new(100_000), |
| #185 | min_initial_deposit: U128::new(1000), |
| #186 | min_nonzero_mm_req: 1, |
| #187 | min_nonzero_im_req: 2, |
| #188 | insurance_floor: U128::ZERO, |
| #189 | } |
| #190 | } |
| #191 | |
| #192 | // ============================================================================ |
| #193 | // SECTION 4: SELECTOR-BASED ACTION ENUM AND STRATEGIES |
| #194 | // ============================================================================ |
| #195 | |
| #196 | /// Index selector - resolved at runtime against live state |
| #197 | /// This allows proptest to generate meaningful action sequences |
| #198 | /// even though it can't see runtime state during strategy generation. |
| #199 | #[derive(Clone, Debug)] |
| #200 | enum IdxSel { |
| #201 | /// Pick any account from live_accounts (fallback to Random if empty) |
| #202 | Existing, |
| #203 | /// Pick an account that is NOT the LP (fallback to Random if impossible) |
| #204 | ExistingNonLp, |
| #205 | /// Use the LP index (fallback to 0 if no LP) |
| #206 | Lp, |
| #207 | /// Random index 0..64 (to test AccountNotFound paths) |
| #208 | Random(u16), |
| #209 | } |
| #210 | |
| #211 | /// Actions use selectors instead of concrete indices |
| #212 | /// Selectors are resolved at runtime in execute() |
| #213 | #[derive(Clone, Debug)] |
| #214 | enum Action { |
| #215 | AddUser { |
| #216 | fee_payment: u128, |
| #217 | }, |
| #218 | AddLp { |
| #219 | fee_payment: u128, |
| #220 | }, |
| #221 | Deposit { |
| #222 | who: IdxSel, |
| #223 | amount: u128, |
| #224 | }, |
| #225 | Withdraw { |
| #226 | who: IdxSel, |
| #227 | amount: u128, |
| #228 | }, |
| #229 | AdvanceSlot { |
| #230 | dt: u64, |
| #231 | }, |
| #232 | AccrueFunding { |
| #233 | dt: u64, |
| #234 | oracle_price: u64, |
| #235 | rate_bps: i64, |
| #236 | }, |
| #237 | Touch { |
| #238 | who: IdxSel, |
| #239 | }, |
| #240 | ExecuteTrade { |
| #241 | lp: IdxSel, |
| #242 | user: IdxSel, |
| #243 | oracle_price: u64, |
| #244 | size: i128, |
| #245 | }, |
| #246 | TopUpInsurance { |
| #247 | amount: u128, |
| #248 | }, |
| #249 | } |
| #250 | |
| #251 | /// Strategy for generating index selectors |
| #252 | /// Weights: Existing=6, ExistingNonLp=2, Lp=1, Random=2 |
| #253 | /// This ensures most actions target valid accounts while still testing error paths |
| #254 | fn idx_sel_strategy() -> impl Strategy<Value = IdxSel> { |
| #255 | prop_oneof![ |
| #256 | 6 => Just(IdxSel::Existing), |
| #257 | 2 => Just(IdxSel::ExistingNonLp), |
| #258 | 1 => Just(IdxSel::Lp), |
| #259 | 2 => (0u16..64).prop_map(IdxSel::Random), |
| #260 | ] |
| #261 | } |
| #262 | |
| #263 | /// Strategy for generating actions |
| #264 | /// Actions use selectors that are resolved at runtime |
| #265 | fn action_strategy() -> impl Strategy<Value = Action> { |
| #266 | prop_oneof![ |
| #267 | // Account creation |
| #268 | 2 => (1u128..100).prop_map(|fee| Action::AddUser { fee_payment: fee }), |
| #269 | 1 => (1u128..100).prop_map(|fee| Action::AddLp { fee_payment: fee }), |
| #270 | // Deposits/Withdrawals |
| #271 | 10 => (idx_sel_strategy(), 0u128..50_000).prop_map(|(who, amount)| Action::Deposit { who, amount }), |
| #272 | 5 => (idx_sel_strategy(), 0u128..50_000).prop_map(|(who, amount)| Action::Withdraw { who, amount }), |
| #273 | // Time advancement |
| #274 | 5 => (0u64..10).prop_map(|dt| Action::AdvanceSlot { dt }), |
| #275 | // Funding |
| #276 | 3 => (1u64..50, 100_000u64..10_000_000, -100i64..100).prop_map(|(dt, price, rate)| { |
| #277 | Action::AccrueFunding { dt, oracle_price: price, rate_bps: rate } |
| #278 | }), |
| #279 | // Touch account |
| #280 | 5 => idx_sel_strategy().prop_map(|who| Action::Touch { who }), |
| #281 | // Trades (LP vs non-LP user) |
| #282 | 8 => (100_000u64..10_000_000, -5_000i128..5_000).prop_map(|(oracle_price, size)| { |
| #283 | Action::ExecuteTrade { lp: IdxSel::Lp, user: IdxSel::ExistingNonLp, oracle_price, size } |
| #284 | }), |
| #285 | // Top up insurance |
| #286 | 2 => (0u128..10_000).prop_map(|amount| Action::TopUpInsurance { amount }), |
| #287 | ] |
| #288 | } |
| #289 | |
| #290 | // ============================================================================ |
| #291 | // SECTION 5: STATE MACHINE FUZZER |
| #292 | // ============================================================================ |
| #293 | |
| #294 | /// State for tracking the fuzzer |
| #295 | struct FuzzState { |
| #296 | engine: Box<RiskEngine>, |
| #297 | live_accounts: Vec<u16>, |
| #298 | lp_idx: Option<u16>, |
| #299 | account_ids: Vec<u64>, // Track allocated account IDs for uniqueness |
| #300 | rng_state: u64, // For deterministic selector resolution |
| #301 | last_oracle_price: u64, // Track last oracle price for conservation checks with mark PnL |
| #302 | } |
| #303 | |
| #304 | impl FuzzState { |
| #305 | fn new(params: RiskParams) -> Self { |
| #306 | FuzzState { |
| #307 | engine: Box::new(RiskEngine::new(params)), |
| #308 | live_accounts: Vec::new(), |
| #309 | lp_idx: None, |
| #310 | account_ids: Vec::new(), |
| #311 | rng_state: 12345, |
| #312 | last_oracle_price: DEFAULT_ORACLE, |
| #313 | } |
| #314 | } |
| #315 | |
| #316 | /// Simple deterministic RNG for selector resolution |
| #317 | fn next_rng(&mut self) -> u64 { |
| #318 | self.rng_state ^= self.rng_state << 13; |
| #319 | self.rng_state ^= self.rng_state >> 7; |
| #320 | self.rng_state ^= self.rng_state << 17; |
| #321 | self.rng_state |
| #322 | } |
| #323 | |
| #324 | /// Resolve an index selector to a concrete index |
| #325 | fn resolve_selector(&mut self, sel: &IdxSel) -> u16 { |
| #326 | match sel { |
| #327 | IdxSel::Existing => { |
| #328 | if self.live_accounts.is_empty() { |
| #329 | // Fallback to random |
| #330 | (self.next_rng() % 64) as u16 |
| #331 | } else { |
| #332 | let idx = self.next_rng() as usize % self.live_accounts.len(); |
| #333 | self.live_accounts[idx] |
| #334 | } |
| #335 | } |
| #336 | IdxSel::ExistingNonLp => { |
| #337 | // Single-pass selection to avoid Vec allocation: |
| #338 | // 1. Count non-LP accounts |
| #339 | // 2. Pick kth candidate |
| #340 | let count = self |
| #341 | .live_accounts |
| #342 | .iter() |
| #343 | .filter(|&&x| Some(x) != self.lp_idx) |
| #344 | .count(); |
| #345 | if count == 0 { |
| #346 | // Fallback to random different from LP |
| #347 | let mut idx = (self.next_rng() % 64) as u16; |
| #348 | if Some(idx) == self.lp_idx && idx < 63 { |
| #349 | idx += 1; |
| #350 | } |
| #351 | idx |
| #352 | } else { |
| #353 | let k = self.next_rng() as usize % count; |
| #354 | self.live_accounts |
| #355 | .iter() |
| #356 | .copied() |
| #357 | .filter(|&x| Some(x) != self.lp_idx) |
| #358 | .nth(k) |
| #359 | .unwrap_or(0) |
| #360 | } |
| #361 | } |
| #362 | IdxSel::Lp => self.lp_idx.unwrap_or(0), |
| #363 | IdxSel::Random(idx) => *idx, |
| #364 | } |
| #365 | } |
| #366 | |
| #367 | /// Execute an action and verify invariants |
| #368 | /// Simulates Solana atomicity: clone before, restore on Err, only assert invariants on Ok |
| #369 | fn execute(&mut self, action: &Action, step: usize) { |
| #370 | let context = format!("Step {} ({:?})", step, action); |
| #371 | let oracle = self.last_oracle_price; // Track for mark PnL consistency |
| #372 | |
| #373 | match action { |
| #374 | Action::AddUser { fee_payment } => { |
| #375 | // Snapshot engine and harness state for rollback |
| #376 | let before = (*self.engine).clone(); |
| #377 | let live_before = self.live_accounts.clone(); |
| #378 | let ids_before = self.account_ids.clone(); |
| #379 | let num_used_before = self.count_used(); |
| #380 | let next_id_before = self.engine.next_account_id; |
| #381 | |
| #382 | let result = self.engine.add_user(*fee_payment); |
| #383 | |
| #384 | match result { |
| #385 | Ok(idx) => { |
| #386 | // Postconditions for Ok |
| #387 | assert!( |
| #388 | is_account_used(&self.engine, idx), |
| #389 | "{}: account not marked used", |
| #390 | context |
| #391 | ); |
| #392 | assert_eq!( |
| #393 | self.count_used(), |
| #394 | num_used_before + 1, |
| #395 | "{}: num_used didn't increment", |
| #396 | context |
| #397 | ); |
| #398 | assert_eq!( |
| #399 | self.engine.next_account_id, |
| #400 | next_id_before + 1, |
| #401 | "{}: next_account_id didn't increment", |
| #402 | context |
| #403 | ); |
| #404 | |
| #405 | // Account ID should be unique |
| #406 | let new_id = self.engine.accounts[idx as usize].account_id; |
| #407 | assert!( |
| #408 | !self.account_ids.contains(&new_id), |
| #409 | "{}: duplicate account_id {}", |
| #410 | context, |
| #411 | new_id |
| #412 | ); |
| #413 | self.account_ids.push(new_id); |
| #414 | self.live_accounts.push(idx); |
| #415 | assert_global_invariants(&self.engine, &context); |
| #416 | } |
| #417 | Err(_) => { |
| #418 | // Simulate Solana rollback - restore engine and harness state |
| #419 | *self.engine = before; |
| #420 | self.live_accounts = live_before; |
| #421 | self.account_ids = ids_before; |
| #422 | } |
| #423 | } |
| #424 | } |
| #425 | |
| #426 | Action::AddLp { fee_payment } => { |
| #427 | // Snapshot engine and harness state for rollback |
| #428 | let before = (*self.engine).clone(); |
| #429 | let live_before = self.live_accounts.clone(); |
| #430 | let ids_before = self.account_ids.clone(); |
| #431 | let lp_before = self.lp_idx; |
| #432 | let num_used_before = self.count_used(); |
| #433 | |
| #434 | let result = self.engine.add_lp([0u8; 32], [0u8; 32], *fee_payment); |
| #435 | |
| #436 | match result { |
| #437 | Ok(idx) => { |
| #438 | assert!( |
| #439 | is_account_used(&self.engine, idx), |
| #440 | "{}: LP not marked used", |
| #441 | context |
| #442 | ); |
| #443 | assert_eq!( |
| #444 | self.count_used(), |
| #445 | num_used_before + 1, |
| #446 | "{}: num_used didn't increment", |
| #447 | context |
| #448 | ); |
| #449 | |
| #450 | let new_id = self.engine.accounts[idx as usize].account_id; |
| #451 | assert!( |
| #452 | !self.account_ids.contains(&new_id), |
| #453 | "{}: duplicate LP account_id", |
| #454 | context |
| #455 | ); |
| #456 | self.account_ids.push(new_id); |
| #457 | self.live_accounts.push(idx); |
| #458 | if self.lp_idx.is_none() { |
| #459 | self.lp_idx = Some(idx); |
| #460 | } |
| #461 | assert_global_invariants(&self.engine, &context); |
| #462 | } |
| #463 | Err(_) => { |
| #464 | // Simulate Solana rollback - restore engine and harness state |
| #465 | *self.engine = before; |
| #466 | self.live_accounts = live_before; |
| #467 | self.account_ids = ids_before; |
| #468 | self.lp_idx = lp_before; |
| #469 | } |
| #470 | } |
| #471 | } |
| #472 | |
| #473 | Action::Deposit { who, amount } => { |
| #474 | let idx = self.resolve_selector(who); |
| #475 | let before = (*self.engine).clone(); |
| #476 | let vault_before = self.engine.vault; |
| #477 | |
| #478 | let result = self.engine.deposit(idx, *amount, oracle, 0); |
| #479 | |
| #480 | match result { |
| #481 | Ok(()) => { |
| #482 | // vault_after == vault_before + amount |
| #483 | assert_eq!( |
| #484 | self.engine.vault, |
| #485 | vault_before + *amount, |
| #486 | "{}: vault didn't increase correctly", |
| #487 | context |
| #488 | ); |
| #489 | assert_global_invariants(&self.engine, &context); |
| #490 | } |
| #491 | Err(_) => { |
| #492 | // Simulate Solana rollback |
| #493 | *self.engine = before; |
| #494 | } |
| #495 | } |
| #496 | } |
| #497 | |
| #498 | Action::Withdraw { who, amount } => { |
| #499 | let idx = self.resolve_selector(who); |
| #500 | let before = (*self.engine).clone(); |
| #501 | let vault_before = self.engine.vault; |
| #502 | |
| #503 | let now_slot = self.engine.current_slot; |
| #504 | let result = self.engine.withdraw(idx, *amount, oracle, now_slot); |
| #505 | |
| #506 | match result { |
| #507 | Ok(()) => { |
| #508 | // vault_after == vault_before - amount |
| #509 | assert_eq!( |
| #510 | self.engine.vault, |
| #511 | vault_before - *amount, |
| #512 | "{}: vault didn't decrease correctly", |
| #513 | context |
| #514 | ); |
| #515 | assert_global_invariants(&self.engine, &context); |
| #516 | } |
| #517 | Err(_) => { |
| #518 | // Simulate Solana rollback |
| #519 | *self.engine = before; |
| #520 | } |
| #521 | } |
| #522 | } |
| #523 | |
| #524 | Action::AdvanceSlot { dt } => { |
| #525 | // advance_slot is infallible - no rollback needed |
| #526 | let slot_before = self.engine.current_slot; |
| #527 | self.engine.advance_slot(*dt); |
| #528 | assert!( |
| #529 | self.engine.current_slot >= slot_before, |
| #530 | "{}: current_slot went backwards", |
| #531 | context |
| #532 | ); |
| #533 | assert_global_invariants(&self.engine, &context); |
| #534 | } |
| #535 | |
| #536 | Action::AccrueFunding { |
| #537 | dt, |
| #538 | oracle_price, |
| #539 | rate_bps, |
| #540 | } => { |
| #541 | let before = (*self.engine).clone(); |
| #542 | // Set funding rate for next accrue_market_to call |
| #543 | self.engine.funding_rate_bps_per_slot_last = *rate_bps; |
| #544 | let now_slot = self.engine.current_slot.saturating_add(*dt); |
| #545 | |
| #546 | let result = self |
| #547 | .engine |
| #548 | .accrue_market_to(now_slot, *oracle_price); |
| #549 | |
| #550 | match result { |
| #551 | Ok(()) => { |
| #552 | self.last_oracle_price = *oracle_price; |
| #553 | assert_global_invariants(&self.engine, &context); |
| #554 | } |
| #555 | Err(_) => { |
| #556 | // Simulate Solana rollback |
| #557 | *self.engine = before; |
| #558 | } |
| #559 | } |
| #560 | } |
| #561 | |
| #562 | Action::Touch { who } => { |
| #563 | let idx = self.resolve_selector(who); |
| #564 | let before = (*self.engine).clone(); |
| #565 | let now_slot = self.engine.current_slot; |
| #566 | |
| #567 | let result = self.engine.touch_account_full(idx as usize, oracle, now_slot); |
| #568 | |
| #569 | match result { |
| #570 | Ok(()) => { |
| #571 | assert_global_invariants(&self.engine, &context); |
| #572 | } |
| #573 | Err(_) => { |
| #574 | // Simulate Solana rollback |
| #575 | *self.engine = before; |
| #576 | } |
| #577 | } |
| #578 | } |
| #579 | |
| #580 | Action::ExecuteTrade { |
| #581 | lp, |
| #582 | user, |
| #583 | oracle_price, |
| #584 | size, |
| #585 | } => { |
| #586 | let lp_idx = self.resolve_selector(lp); |
| #587 | let user_idx = self.resolve_selector(user); |
| #588 | |
| #589 | // Skip if LP and user are the same account (invalid trade) |
| #590 | if lp_idx == user_idx { |
| #591 | return; |
| #592 | } |
| #593 | |
| #594 | let before = (*self.engine).clone(); |
| #595 | let now_slot = self.engine.current_slot; |
| #596 | |
| #597 | let result = |
| #598 | self.engine |
| #599 | .execute_trade(lp_idx, user_idx, *oracle_price, now_slot, *size, *oracle_price); |
| #600 | |
| #601 | match result { |
| #602 | Ok(_) => { |
| #603 | // Trade succeeded - update oracle price for mark PnL checks |
| #604 | self.last_oracle_price = *oracle_price; |
| #605 | assert_global_invariants(&self.engine, &context); |
| #606 | } |
| #607 | Err(_) => { |
| #608 | // Simulate Solana rollback |
| #609 | *self.engine = before; |
| #610 | } |
| #611 | } |
| #612 | } |
| #613 | |
| #614 | Action::TopUpInsurance { amount } => { |
| #615 | let before = (*self.engine).clone(); |
| #616 | let vault_before = self.engine.vault; |
| #617 | |
| #618 | let now_slot = self.engine.current_slot; |
| #619 | let result = self.engine.top_up_insurance_fund(*amount, now_slot); |
| #620 | |
| #621 | match result { |
| #622 | Ok(_above_threshold) => { |
| #623 | // vault should increase |
| #624 | assert_eq!( |
| #625 | self.engine.vault, |
| #626 | vault_before + *amount, |
| #627 | "{}: vault didn't increase", |
| #628 | context |
| #629 | ); |
| #630 | assert_global_invariants(&self.engine, &context); |
| #631 | } |
| #632 | Err(_) => { |
| #633 | // Simulate Solana rollback |
| #634 | *self.engine = before; |
| #635 | } |
| #636 | } |
| #637 | } |
| #638 | } |
| #639 | } |
| #640 | |
| #641 | fn count_used(&self) -> u32 { |
| #642 | let mut count = 0; |
| #643 | let n = account_count(&self.engine); |
| #644 | for i in 0..n { |
| #645 | if is_account_used(&self.engine, i as u16) { |
| #646 | count += 1; |
| #647 | } |
| #648 | } |
| #649 | count |
| #650 | } |
| #651 | } |
| #652 | |
| #653 | // State machine proptest |
| #654 | proptest! { |
| #655 | #![proptest_config(ProptestConfig::with_cases(100))] |
| #656 | |
| #657 | #[test] |
| #658 | fn fuzz_state_machine_regime_a( |
| #659 | initial_insurance in 0u128..50_000, |
| #660 | actions in prop::collection::vec(action_strategy(), 50..100) |
| #661 | ) { |
| #662 | let mut state = FuzzState::new(params_regime_a()); |
| #663 | |
| #664 | // Setup: Add initial LP and users |
| #665 | let lp_result = state.engine.add_lp([0u8; 32], [0u8; 32], 1); |
| #666 | if let Ok(idx) = lp_result { |
| #667 | state.live_accounts.push(idx); |
| #668 | state.lp_idx = Some(idx); |
| #669 | state.account_ids.push(state.engine.accounts[idx as usize].account_id); |
| #670 | } |
| #671 | |
| #672 | for _ in 0..2 { |
| #673 | if let Ok(idx) = state.engine.add_user(1) { |
| #674 | state.live_accounts.push(idx); |
| #675 | state.account_ids.push(state.engine.accounts[idx as usize].account_id); |
| #676 | } |
| #677 | } |
| #678 | |
| #679 | // Initial deposits |
| #680 | for &idx in &state.live_accounts.clone() { |
| #681 | let _ = state.engine.deposit(idx, 10_000, DEFAULT_ORACLE, 0); |
| #682 | } |
| #683 | |
| #684 | // Top up insurance using proper API (maintains conservation) |
| #685 | let current_insurance = state.engine.insurance_fund.balance.get(); |
| #686 | if initial_insurance > current_insurance { |
| #687 | let now_slot = state.engine.current_slot; |
| #688 | let _ = state.engine.top_up_insurance_fund(initial_insurance - current_insurance, now_slot); |
| #689 | } |
| #690 | |
| #691 | // Execute actions - selectors resolved at runtime against live state |
| #692 | for (step, action) in actions.iter().enumerate() { |
| #693 | state.execute(action, step); |
| #694 | } |
| #695 | } |
| #696 | |
| #697 | #[test] |
| #698 | fn fuzz_state_machine_regime_b( |
| #699 | initial_insurance in 1000u128..50_000, // Above floor |
| #700 | actions in prop::collection::vec(action_strategy(), 50..100) |
| #701 | ) { |
| #702 | let mut state = FuzzState::new(params_regime_b()); |
| #703 | |
| #704 | // Setup: Add initial LP and users |
| #705 | let lp_result = state.engine.add_lp([0u8; 32], [0u8; 32], 1); |
| #706 | if let Ok(idx) = lp_result { |
| #707 | state.live_accounts.push(idx); |
| #708 | state.lp_idx = Some(idx); |
| #709 | state.account_ids.push(state.engine.accounts[idx as usize].account_id); |
| #710 | } |
| #711 | |
| #712 | for _ in 0..2 { |
| #713 | if let Ok(idx) = state.engine.add_user(1) { |
| #714 | state.live_accounts.push(idx); |
| #715 | state.account_ids.push(state.engine.accounts[idx as usize].account_id); |
| #716 | } |
| #717 | } |
| #718 | |
| #719 | // Initial deposits |
| #720 | for &idx in &state.live_accounts.clone() { |
| #721 | let _ = state.engine.deposit(idx, 10_000, DEFAULT_ORACLE, 0); |
| #722 | } |
| #723 | |
| #724 | // Top up insurance using proper API (maintains conservation) |
| #725 | let floor = state.engine.insurance_floor; |
| #726 | let target_insurance = initial_insurance.max(floor + 100); |
| #727 | let current_insurance = state.engine.insurance_fund.balance.get(); |
| #728 | if target_insurance > current_insurance { |
| #729 | let now_slot = state.engine.current_slot; |
| #730 | let _ = state.engine.top_up_insurance_fund(target_insurance - current_insurance, now_slot); |
| #731 | } |
| #732 | |
| #733 | // Execute actions |
| #734 | for (step, action) in actions.iter().enumerate() { |
| #735 | state.execute(action, step); |
| #736 | } |
| #737 | } |
| #738 | } |
| #739 | |
| #740 | // ============================================================================ |
| #741 | // SECTION 6: UNIT PROPERTY FUZZ TESTS (FOCUSED) |
| #742 | // ============================================================================ |
| #743 | |
| #744 | proptest! { |
| #745 | #![proptest_config(ProptestConfig::with_cases(500))] |
| #746 | |
| #747 | // 10. add_user/add_lp fails when at max capacity |
| #748 | #[test] |
| #749 | fn fuzz_prop_add_fails_at_capacity(num_to_add in 1usize..10) { |
| #750 | let mut params = params_regime_a(); |
| #751 | params.max_accounts = 4; // Very small |
| #752 | let mut engine = Box::new(RiskEngine::new(params)); |
| #753 | |
| #754 | // Fill up |
| #755 | for _ in 0..4 { |
| #756 | let _ = engine.add_user(1); |
| #757 | } |
| #758 | |
| #759 | // Additional adds should fail |
| #760 | for _ in 0..num_to_add { |
| #761 | let result = engine.add_user(1); |
| #762 | prop_assert!(result.is_err(), "add_user should fail at capacity"); |
| #763 | } |
| #764 | } |
| #765 | } |
| #766 | |
| #767 | // ============================================================================ |
| #768 | // SECTION 7: DETERMINISTIC SEEDED FUZZER |
| #769 | // ============================================================================ |
| #770 | |
| #771 | /// xorshift64 PRNG for deterministic randomness |
| #772 | struct Rng { |
| #773 | state: u64, |
| #774 | } |
| #775 | |
| #776 | impl Rng { |
| #777 | fn new(seed: u64) -> Self { |
| #778 | Rng { |
| #779 | state: if seed == 0 { 1 } else { seed }, |
| #780 | } |
| #781 | } |
| #782 | |
| #783 | fn next(&mut self) -> u64 { |
| #784 | let mut x = self.state; |
| #785 | x ^= x << 13; |
| #786 | x ^= x >> 7; |
| #787 | x ^= x << 17; |
| #788 | self.state = x; |
| #789 | x |
| #790 | } |
| #791 | |
| #792 | fn u64(&mut self, lo: u64, hi: u64) -> u64 { |
| #793 | if lo >= hi { |
| #794 | return lo; |
| #795 | } |
| #796 | lo + (self.next() % (hi - lo + 1)) |
| #797 | } |
| #798 | |
| #799 | fn u128(&mut self, lo: u128, hi: u128) -> u128 { |
| #800 | if lo >= hi { |
| #801 | return lo; |
| #802 | } |
| #803 | lo + ((self.next() as u128) % (hi - lo + 1)) |
| #804 | } |
| #805 | |
| #806 | fn i128(&mut self, lo: i128, hi: i128) -> i128 { |
| #807 | if lo >= hi { |
| #808 | return lo; |
| #809 | } |
| #810 | // Avoid overflow: use u64 directly and cast safely |
| #811 | let range = (hi - lo + 1) as u128; |
| #812 | lo + ((self.next() as u128 % range) as i128) |
| #813 | } |
| #814 | |
| #815 | fn i64(&mut self, lo: i64, hi: i64) -> i64 { |
| #816 | if lo >= hi { |
| #817 | return lo; |
| #818 | } |
| #819 | // Avoid overflow: use u64 directly and cast safely |
| #820 | let range = (hi - lo + 1) as u64; |
| #821 | lo + ((self.next() % range) as i64) |
| #822 | } |
| #823 | |
| #824 | fn usize(&mut self, lo: usize, hi: usize) -> usize { |
| #825 | if lo >= hi { |
| #826 | return lo; |
| #827 | } |
| #828 | lo + ((self.next() as usize) % (hi - lo + 1)) |
| #829 | } |
| #830 | } |
| #831 | |
| #832 | /// Generate a random selector using RNG |
| #833 | fn random_selector(rng: &mut Rng) -> IdxSel { |
| #834 | match rng.usize(0, 3) { |
| #835 | 0 => IdxSel::Existing, |
| #836 | 1 => IdxSel::ExistingNonLp, |
| #837 | 2 => IdxSel::Lp, |
| #838 | _ => IdxSel::Random(rng.u64(0, 63) as u16), |
| #839 | } |
| #840 | } |
| #841 | |
| #842 | /// Generate a random action using the RNG (selector-based) |
| #843 | fn random_action(rng: &mut Rng) -> (Action, String) { |
| #844 | let action_type = rng.usize(0, 8); |
| #845 | |
| #846 | let action = match action_type { |
| #847 | 0 => Action::AddUser { |
| #848 | fee_payment: rng.u128(1, 100), |
| #849 | }, |
| #850 | 1 => Action::AddLp { |
| #851 | fee_payment: rng.u128(1, 100), |
| #852 | }, |
| #853 | 2 => Action::Deposit { |
| #854 | who: random_selector(rng), |
| #855 | amount: rng.u128(0, 50_000), |
| #856 | }, |
| #857 | 3 => Action::Withdraw { |
| #858 | who: random_selector(rng), |
| #859 | amount: rng.u128(0, 50_000), |
| #860 | }, |
| #861 | 4 => Action::AdvanceSlot { dt: rng.u64(0, 10) }, |
| #862 | 5 => Action::AccrueFunding { |
| #863 | dt: rng.u64(1, 50), |
| #864 | oracle_price: rng.u64(100_000, 10_000_000), |
| #865 | rate_bps: rng.i64(-100, 100), |
| #866 | }, |
| #867 | 6 => Action::Touch { |
| #868 | who: random_selector(rng), |
| #869 | }, |
| #870 | 7 => Action::ExecuteTrade { |
| #871 | lp: IdxSel::Lp, |
| #872 | user: IdxSel::ExistingNonLp, |
| #873 | oracle_price: rng.u64(100_000, 10_000_000), |
| #874 | size: rng.i128(-5_000, 5_000), |
| #875 | }, |
| #876 | _ => Action::TopUpInsurance { |
| #877 | amount: rng.u128(0, 10_000), |
| #878 | }, |
| #879 | }; |
| #880 | |
| #881 | let desc = format!("{:?}", action); |
| #882 | (action, desc) |
| #883 | } |
| #884 | |
| #885 | /// Compute conservation slack without panicking |
| #886 | /// Compute conservation slack: vault - (c_tot + insurance). |
| #887 | /// With the ADL K-coefficient funding model, there is no lazy funding index to settle. |
| #888 | fn compute_conservation_slack(engine: &RiskEngine) -> (i128, u128, i128, u128, u128) { |
| #889 | let total_capital = engine.c_tot.get(); |
| #890 | let insurance = engine.insurance_fund.balance.get(); |
| #891 | let base = total_capital + insurance; |
| #892 | let actual = engine.vault.get(); |
| #893 | let slack = actual as i128 - base as i128; |
| #894 | ( |
| #895 | slack, |
| #896 | total_capital, |
| #897 | 0i128, // net_settled_pnl no longer computed separately |
| #898 | insurance, |
| #899 | actual, |
| #900 | ) |
| #901 | } |
| #902 | |
| #903 | /// Run deterministic fuzzer for a single regime |
| #904 | fn run_deterministic_fuzzer( |
| #905 | params: RiskParams, |
| #906 | regime_name: &str, |
| #907 | seeds: std::ops::Range<u64>, |
| #908 | steps: usize, |
| #909 | ) { |
| #910 | for seed in seeds { |
| #911 | let mut rng = Rng::new(seed); |
| #912 | let mut state = FuzzState::new(params.clone()); |
| #913 | |
| #914 | // Track last N actions for repro |
| #915 | let mut action_history: Vec<String> = Vec::with_capacity(10); |
| #916 | |
| #917 | // Setup: create LP and 2 users |
| #918 | if let Ok(idx) = state.engine.add_lp([0u8; 32], [0u8; 32], 1) { |
| #919 | state.live_accounts.push(idx); |
| #920 | state.lp_idx = Some(idx); |
| #921 | state |
| #922 | .account_ids |
| #923 | .push(state.engine.accounts[idx as usize].account_id); |
| #924 | } |
| #925 | |
| #926 | for _ in 0..2 { |
| #927 | if let Ok(idx) = state.engine.add_user(1) { |
| #928 | state.live_accounts.push(idx); |
| #929 | state |
| #930 | .account_ids |
| #931 | .push(state.engine.accounts[idx as usize].account_id); |
| #932 | } |
| #933 | } |
| #934 | |
| #935 | // Initial deposits |
| #936 | for &idx in &state.live_accounts.clone() { |
| #937 | let _ = state.engine.deposit(idx, rng.u128(5_000, 50_000), DEFAULT_ORACLE, 0); |
| #938 | } |
| #939 | |
| #940 | // Top up insurance using proper API (maintains conservation) |
| #941 | let floor = state.engine.insurance_floor; |
| #942 | let target_ins = floor + rng.u128(5_000, 100_000); |
| #943 | let current_ins = state.engine.insurance_fund.balance.get(); |
| #944 | if target_ins > current_ins { |
| #945 | let now_slot = state.engine.current_slot; |
| #946 | let _ = state.engine.top_up_insurance_fund(target_ins - current_ins, now_slot); |
| #947 | } |
| #948 | |
| #949 | // Verify conservation after setup |
| #950 | if !state.engine.check_conservation() { |
| #951 | eprintln!("Conservation failed after setup for seed {}", seed); |
| #952 | eprintln!( |
| #953 | " vault={}, insurance={}", |
| #954 | state.engine.vault.get(), state.engine.insurance_fund.balance.get() |
| #955 | ); |
| #956 | eprintln!(" live_accounts={:?}", state.live_accounts); |
| #957 | let mut total_cap = 0u128; |
| #958 | for &idx in &state.live_accounts { |
| #959 | eprintln!( |
| #960 | " account[{}]: capital={}", |
| #961 | idx, state.engine.accounts[idx as usize].capital.get() |
| #962 | ); |
| #963 | total_cap += state.engine.accounts[idx as usize].capital.get(); |
| #964 | } |
| #965 | eprintln!(" total_capital={}", total_cap); |
| #966 | panic!("Conservation failed after setup"); |
| #967 | } |
| #968 | |
| #969 | // Track slack before starting |
| #970 | let mut _last_slack: i128 = 0; |
| #971 | let verbose = false; // Disable verbose for now |
| #972 | |
| #973 | // Run steps |
| #974 | for step in 0..steps { |
| #975 | let (slack_before, _, _, _, _) = compute_conservation_slack(&state.engine); |
| #976 | // Use selector-based random_action (no live/lp args needed) |
| #977 | let (action, desc) = random_action(&mut rng); |
| #978 | |
| #979 | // Keep last 10 actions |
| #980 | if action_history.len() >= 10 { |
| #981 | action_history.remove(0); |
| #982 | } |
| #983 | action_history.push(desc.clone()); |
| #984 | |
| #985 | // Execute with panic catching for better error messages |
| #986 | let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { |
| #987 | state.execute(&action, step); |
| #988 | })); |
| #989 | |
| #990 | // Track slack changes |
| #991 | let (slack_after, total_cap, net_pnl, ins, actual) = |
| #992 | compute_conservation_slack(&state.engine); |
| #993 | let slack_delta = slack_after - slack_before; |
| #994 | if verbose && slack_delta != 0 { |
| #995 | eprintln!( |
| #996 | "Step {}: {} -> slack delta={}, total slack={} (cap={}, pnl={}, ins={}, actual={})", |
| #997 | step, desc, slack_delta, slack_after, total_cap, net_pnl, ins, actual |
| #998 | ); |
| #999 | } |
| #1000 | _last_slack = slack_after; |
| #1001 | |
| #1002 | if result.is_err() { |
| #1003 | eprintln!("\n=== DETERMINISTIC FUZZER FAILURE ==="); |
| #1004 | eprintln!("Regime: {}", regime_name); |
| #1005 | eprintln!("Seed: {}", seed); |
| #1006 | eprintln!("Step: {}", step); |
| #1007 | eprintln!("Action: {}", desc); |
| #1008 | eprintln!("Slack before: {}, after: {}", slack_before, slack_after); |
| #1009 | eprintln!("\nLast 10 actions:"); |
| #1010 | for (i, act) in action_history.iter().enumerate() { |
| #1011 | eprintln!(" {}: {}", step.saturating_sub(9) + i, act); |
| #1012 | } |
| #1013 | eprintln!( |
| #1014 | "\nTo reproduce: run with seed={}, stop at step={}", |
| #1015 | seed, step |
| #1016 | ); |
| #1017 | panic!("Deterministic fuzzer failed - see above for repro"); |
| #1018 | } |
| #1019 | // Note: live_accounts tracking is now handled inside execute() via the returned idx |
| #1020 | // when AddUser/AddLp succeeds. No need for separate tracking here. |
| #1021 | } |
| #1022 | } |
| #1023 | } |
| #1024 | |
| #1025 | #[test] |
| #1026 | fn fuzz_deterministic_regime_a() { |
| #1027 | run_deterministic_fuzzer(params_regime_a(), "A (floor=0)", 1..501, 200); |
| #1028 | } |
| #1029 | |
| #1030 | #[test] |
| #1031 | fn fuzz_deterministic_regime_b() { |
| #1032 | run_deterministic_fuzzer(params_regime_b(), "B (floor=1000)", 1..501, 200); |
| #1033 | } |
| #1034 | |
| #1035 | // Extended deterministic test with more seeds |
| #1036 | #[test] |
| #1037 | #[ignore] // Run with: cargo test --features fuzz fuzz_deterministic_extended -- --ignored |
| #1038 | fn fuzz_deterministic_extended() { |
| #1039 | run_deterministic_fuzzer(params_regime_a(), "A extended", 1..2001, 500); |
| #1040 | run_deterministic_fuzzer(params_regime_b(), "B extended", 1..2001, 500); |
| #1041 | } |
| #1042 | |
| #1043 | // ============================================================================ |
| #1044 | // SECTION 8: LEGACY PROPTEST TESTS (PRESERVED FROM ORIGINAL) |
| #1045 | // ============================================================================ |
| #1046 | |
| #1047 | // Strategy helpers |
| #1048 | fn amount_strategy() -> impl Strategy<Value = u128> { |
| #1049 | 0u128..1_000_000 |
| #1050 | } |
| #1051 | |
| #1052 | proptest! { |
| #1053 | // Test that deposit always increases vault and principal |
| #1054 | #[test] |
| #1055 | fn fuzz_deposit_increases_balance(amount in amount_strategy()) { |
| #1056 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); |
| #1057 | let user_idx = engine.add_user(1).unwrap(); |
| #1058 | |
| #1059 | let vault_before = engine.vault; |
| #1060 | let principal_before = engine.accounts[user_idx as usize].capital; |
| #1061 | |
| #1062 | let _ = engine.deposit(user_idx, amount, DEFAULT_ORACLE, 0); |
| #1063 | |
| #1064 | prop_assert_eq!(engine.vault, vault_before + amount); |
| #1065 | prop_assert_eq!(engine.accounts[user_idx as usize].capital, principal_before + amount); |
| #1066 | } |
| #1067 | |
| #1068 | // Test that withdrawal never increases balance (uses Solana rollback simulation on Err) |
| #1069 | #[test] |
| #1070 | fn fuzz_withdraw_decreases_or_fails( |
| #1071 | deposit_amount in amount_strategy(), |
| #1072 | withdraw_amount in amount_strategy() |
| #1073 | ) { |
| #1074 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); |
| #1075 | let user_idx = engine.add_user(1).unwrap(); |
| #1076 | |
| #1077 | engine.deposit(user_idx, deposit_amount, DEFAULT_ORACLE, 0).unwrap(); |
| #1078 | |
| #1079 | // Snapshot for rollback simulation |
| #1080 | let before = (*engine).clone(); |
| #1081 | |
| #1082 | let result = engine.withdraw(user_idx, withdraw_amount, DEFAULT_ORACLE, 0); |
| #1083 | |
| #1084 | if result.is_ok() { |
| #1085 | prop_assert!(engine.vault <= before.vault); |
| #1086 | prop_assert!(engine.accounts[user_idx as usize].capital <= before.accounts[user_idx as usize].capital); |
| #1087 | } else { |
| #1088 | // Simulate Solana rollback then verify state is restored |
| #1089 | *engine = before.clone(); |
| #1090 | prop_assert_eq!(engine.vault, before.vault); |
| #1091 | prop_assert_eq!(engine.accounts[user_idx as usize].capital, before.accounts[user_idx as usize].capital); |
| #1092 | } |
| #1093 | } |
| #1094 | |
| #1095 | // Test conservation after operations |
| #1096 | #[test] |
| #1097 | fn fuzz_conservation_after_operations( |
| #1098 | deposits in prop::collection::vec(amount_strategy(), 1..10), |
| #1099 | withdrawals in prop::collection::vec(amount_strategy(), 1..10) |
| #1100 | ) { |
| #1101 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); |
| #1102 | let user_idx = engine.add_user(1).unwrap(); |
| #1103 | |
| #1104 | for amount in deposits { |
| #1105 | let _ = engine.deposit(user_idx, amount, DEFAULT_ORACLE, 0); |
| #1106 | } |
| #1107 | |
| #1108 | prop_assert!(engine.check_conservation()); |
| #1109 | |
| #1110 | for amount in withdrawals { |
| #1111 | let _ = engine.withdraw(user_idx, amount, DEFAULT_ORACLE, 0); |
| #1112 | } |
| #1113 | |
| #1114 | prop_assert!(engine.check_conservation()); |
| #1115 | } |
| #1116 | } |
| #1117 | |
| #1118 | // ============================================================================ |
| #1119 | // SECTION 9: CONSERVATION REGRESSION TESTS |
| #1120 | // These verify that conservation invariant holds under various conditions |
| #1121 | // ============================================================================ |
| #1122 | |
| #1123 | /// Verify check_conservation holds after trades and market accrual. |
| #1124 | /// Conservation: vault >= c_tot + insurance. |
| #1125 | #[test] |
| #1126 | fn conservation_after_trade_and_funding_regression() { |
| #1127 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); |
| #1128 | |
| #1129 | // Create LP and user with positions |
| #1130 | let lp_idx = engine.add_lp([0u8; 32], [0u8; 32], 1).unwrap(); |
| #1131 | let user_idx = engine.add_user(1).unwrap(); |
| #1132 | engine.deposit(lp_idx, 100_000, DEFAULT_ORACLE, 0).unwrap(); |
| #1133 | engine.deposit(user_idx, 100_000, DEFAULT_ORACLE, 0).unwrap(); |
| #1134 | |
| #1135 | // Make crank fresh |
| #1136 | engine.last_crank_slot = 0; |
| #1137 | engine.last_market_slot = 0; |
| #1138 | engine.last_oracle_price = DEFAULT_ORACLE; |
| #1139 | engine.funding_price_sample_last = DEFAULT_ORACLE; |
| #1140 | |
| #1141 | // Execute trade to create positions |
| #1142 | engine |
| #1143 | .execute_trade(lp_idx, user_idx, DEFAULT_ORACLE, 0, 1000, DEFAULT_ORACLE) |
| #1144 | .unwrap(); |
| #1145 | |
| #1146 | // Accrue market with funding |
| #1147 | engine.funding_rate_bps_per_slot_last = 500; |
| #1148 | engine.advance_slot(1000); |
| #1149 | let slot = engine.current_slot; |
| #1150 | engine.accrue_market_to(slot, DEFAULT_ORACLE).unwrap(); |
| #1151 | |
| #1152 | // Verify conservation |
| #1153 | assert!( |
| #1154 | engine.check_conservation(), |
| #1155 | "check_conservation failed after trade and market accrual" |
| #1156 | ); |
| #1157 | |
| #1158 | // Also verify manually: vault >= c_tot + insurance |
| #1159 | let vault = engine.vault.get(); |
| #1160 | let c_tot = engine.c_tot.get(); |
| #1161 | let insurance = engine.insurance_fund.balance.get(); |
| #1162 | assert!( |
| #1163 | vault >= c_tot + insurance, |
| #1164 | "Manual conservation check: vault={} < c_tot={} + insurance={}", |
| #1165 | vault, |
| #1166 | c_tot, |
| #1167 | insurance |
| #1168 | ); |
| #1169 | } |
| #1170 | |
| #1171 | /// Verify the test harness correctly simulates Solana atomicity |
| #1172 | /// When an operation returns Err, the harness must restore the engine to pre-call state |
| #1173 | /// This ensures the fuzz suite accurately models on-chain behavior |
| #1174 | #[test] |
| #1175 | fn harness_rollback_simulation_test() { |
| #1176 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); |
| #1177 | |
| #1178 | // Create user with some capital |
| #1179 | let user_idx = engine.add_user(1).unwrap(); |
| #1180 | engine.deposit(user_idx, 1000, DEFAULT_ORACLE, 0).unwrap(); |
| #1181 | |
| #1182 | // Accrue market to create state that could be mutated |
| #1183 | engine.last_oracle_price = DEFAULT_ORACLE; |
| #1184 | engine.funding_price_sample_last = DEFAULT_ORACLE; |
| #1185 | engine.funding_rate_bps_per_slot_last = 100; |
| #1186 | engine.advance_slot(100); |
| #1187 | let slot = engine.current_slot; |
| #1188 | engine.accrue_market_to(slot, DEFAULT_ORACLE).unwrap(); |
| #1189 | |
| #1190 | // Capture complete state before failed operation (deep clone of RiskEngine) |
| #1191 | let before = (*engine).clone(); |
| #1192 | |
| #1193 | // Capture expected values before any operation |
| #1194 | let expected_vault = engine.vault; |
| #1195 | let expected_capital = engine.accounts[user_idx as usize].capital; |
| #1196 | let expected_pnl = engine.accounts[user_idx as usize].pnl; |
| #1197 | |
| #1198 | // Try to withdraw more than available - will fail |
| #1199 | let result = engine.withdraw(user_idx, 999_999, DEFAULT_ORACLE, slot); |
| #1200 | assert!( |
| #1201 | result.is_err(), |
| #1202 | "Withdraw should fail with insufficient balance" |
| #1203 | ); |
| #1204 | |
| #1205 | // Simulate Solana rollback (this is what the harness does) |
| #1206 | // Deep restore of RiskEngine contents |
| #1207 | *engine = before; |
| #1208 | |
| #1209 | // Verify state is exactly restored |
| #1210 | assert_eq!(engine.vault, expected_vault, "vault must be restored"); |
| #1211 | assert_eq!( |
| #1212 | engine.accounts[user_idx as usize].capital, expected_capital, |
| #1213 | "capital must be restored" |
| #1214 | ); |
| #1215 | assert_eq!( |
| #1216 | engine.accounts[user_idx as usize].pnl, expected_pnl, |
| #1217 | "pnl must be restored" |
| #1218 | ); |
| #1219 | |
| #1220 | // Conservation must still hold after rollback |
| #1221 | assert!( |
| #1222 | engine.check_conservation(), |
| #1223 | "Conservation must hold after harness rollback" |
| #1224 | ); |
| #1225 | } |
| #1226 |