├── .grok └── settings.json ├── .gitignore ├── Cargo.toml ├── audit.md ├── tests ├── fuzzing.proptest-regressions ├── amm_tests.rs └── fuzzing.rs ├── README.md ├── Cargo.lock └── src └── percolator.rs /.grok/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "model": "grok-code-fast-1" 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Rust/Cargo 2 | /target/ 3 | **/*.rs.bk 4 | *.pdb 5 | 6 | # Build artifacts 7 | *.o 8 | *.a 9 | *.dylib 10 | *.dll 11 | *.exe 12 | *.so 13 | *.rlib 14 | 15 | # IDE 16 | .vscode/ 17 | .idea/ 18 | *.swp 19 | *.swo 20 | *~ 21 | .DS_Store 22 | 23 | # Temporary files 24 | *.tmp 25 | *.bak 26 | .#* 27 | \#*\# 28 | 29 | # OS files 30 | .DS_Store 31 | Thumbs.db 32 | desktop.ini 33 | 34 | # Test outputs 35 | *.profraw 36 | *.profdata 37 | 38 | # Local configuration 39 | .env 40 | .env.local 41 | test-ledger/ 42 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "percolator" 3 | version = "0.1.0" 4 | edition = "2021" 5 | license = "Apache-2.0" 6 | 7 | [lib] 8 | name = "percolator" 9 | path = "src/percolator.rs" 10 | 11 | [dependencies] 12 | # No runtime dependencies - pure no_std compatible library 13 | 14 | [dev-dependencies] 15 | proptest = "1.4" 16 | 17 | [features] 18 | default = [] 19 | fuzz = [] 20 | 21 | [profile.release] 22 | lto = "fat" 23 | codegen-units = 1 24 | opt-level = 3 25 | overflow-checks = true 26 | 27 | [profile.sbf] 28 | inherits = "release" 29 | panic = "abort" 30 | 31 | [workspace.metadata.kani] 32 | flags = { tests = true } 33 | unstable = { stubbing = true } 34 | 35 | [[workspace.metadata.kani.proof]] 36 | harness = ".*" 37 | unwind = 70 38 | -------------------------------------------------------------------------------- /audit.md: -------------------------------------------------------------------------------- 1 | # Percolator Security Audit 2 | 3 | **Disclaimer:** This audit was performed by an AI assistant assuming an adversarial developer. It is not a substitute for a professional security audit. 4 | 5 | ## Summary 6 | 7 | The Percolator codebase is well-structured with strong security focus: 8 | - `saturating_*` arithmetic prevents overflow/underflow 9 | - Formal verification with Kani proofs 10 | - Comprehensive fuzz testing with invariant checks 11 | - Atomic operations (Err => no mutation) 12 | 13 | ## Issues 14 | 15 | ### High 16 | 17 | * **[H-01] ~~Unused `pinocchio` Dependency~~:** FIXED - Removed unused `pinocchio` and `pinocchio-log` dependencies from `Cargo.toml`. 18 | 19 | ### Medium 20 | 21 | * **[M-01] No Account Deallocation:** Once account slots are allocated, they cannot be freed. While the exponential fee mechanism (`account_fee_multiplier`) makes slot exhaustion expensive (fees double as capacity fills), a determined attacker with sufficient capital could still exhaust all slots permanently. Consider adding account deallocation for inactive/empty accounts. 22 | 23 | ### Low 24 | 25 | * **[L-01] force_realize_losses Must Be Called Explicitly:** The `force_realize_losses` function is not auto-triggered. Callers must explicitly invoke it when insurance drops to threshold. This is intentional for atomicity but should be documented clearly. 26 | 27 | ### Informational 28 | 29 | * **[I-01] NoOpMatcher is Test-Only:** The `NoOpMatcher` accepts any trade at oracle price. This is appropriate for testing but the trait design allows production matchers to enforce proper price/size validation. 30 | 31 | * **[I-02] Large Stack Allocation:** `RiskEngine` is ~6MB on stack (4096 accounts). Tests use `Box::new()` to heap-allocate. On-chain deployment would need similar handling. 32 | 33 | ## Recommendations 34 | 35 | * **[R-01] ~~Remove Unused Dependencies~~:** DONE - Removed `pinocchio` and `pinocchio-log` from `Cargo.toml`. 36 | 37 | * **[R-02] Consider Account Deallocation:** Add a mechanism to reclaim slots from accounts with zero capital, zero position, and zero PnL. Could require a waiting period to prevent abuse. 38 | 39 | * **[R-03] Document force_realize_losses Calling Convention:** Make clear in API docs that callers must check `insurance_fund.balance <= risk_reduction_threshold` and call `force_realize_losses` before attempting trades in that state. 40 | -------------------------------------------------------------------------------- /tests/fuzzing.proptest-regressions: -------------------------------------------------------------------------------- 1 | # Seeds for failure cases proptest has generated in the past. It is 2 | # automatically read and these particular cases re-run before any 3 | # novel cases are generated. 4 | # 5 | # It is recommended to check this file in to source control so that 6 | # everyone who runs the test benefits from these saved cases. 7 | cc 615b201a84a05de53e1068d074ba1e108f4eb48b64c0df88c0dc86d9d76bda4b # shrinks to user_capital = 1000, lp_capital = 1000, position = 1, entry_price = 100000, oracle_price = 100000 8 | cc c4626f4a64610bdf73ea8e2814e03a84bdfd43c0930297d4cad766b608bde810 # shrinks to user_capital = 1000, lp_capital = 1000, position = 1, entry_price = 100000, oracle_price = 100000, insurance = 3 9 | cc 30ce52a5bef631c7f06ca6d516d551ff17eecbde1b66c93aede1ef20e2f2bb18 # shrinks to initial_insurance = 1000, actions = [Touch { who: Existing }, Touch { who: Existing }, AddUser { fee_payment: 1 }, AddUser { fee_payment: 1 }, AddUser { fee_payment: 1 }, Touch { who: Existing }, Deposit { who: Existing, amount: 0 }, Deposit { who: Existing, amount: 0 }, AccrueFunding { dt: 1, oracle_price: 100145, rate_bps: -20 }, Withdraw { who: ExistingNonLp, amount: 36393 }, ExecuteTrade { lp: Lp, user: ExistingNonLp, oracle_price: 3576558, size: 402 }, Deposit { who: ExistingNonLp, amount: 39745 }, ExecuteTrade { lp: Lp, user: ExistingNonLp, oracle_price: 8647864, size: -2951 }, Touch { who: ExistingNonLp }, Deposit { who: Existing, amount: 5981 }, Deposit { who: Existing, amount: 203 }, AddUser { fee_payment: 38 }, Deposit { who: ExistingNonLp, amount: 24416 }, Withdraw { who: ExistingNonLp, amount: 40933 }, Deposit { who: ExistingNonLp, amount: 13285 }, Deposit { who: Existing, amount: 33485 }, Touch { who: Existing }, Deposit { who: Existing, amount: 43713 }, AccrueFunding { dt: 45, oracle_price: 7757524, rate_bps: -77 }, AdvanceSlot { dt: 4 }, Deposit { who: Existing, amount: 22640 }, Touch { who: ExistingNonLp }, Withdraw { who: ExistingNonLp, amount: 22950 }, ExecuteTrade { lp: Lp, user: ExistingNonLp, oracle_price: 855696, size: 2674 }, Withdraw { who: Existing, amount: 31100 }, Deposit { who: ExistingNonLp, amount: 48589 }, Deposit { who: ExistingNonLp, amount: 49262 }, Deposit { who: ExistingNonLp, amount: 43070 }, AccrueFunding { dt: 12, oracle_price: 1986820, rate_bps: 91 }, Touch { who: Existing }, Withdraw { who: Existing, amount: 34058 }, AdvanceSlot { dt: 6 }, Deposit { who: ExistingNonLp, amount: 2248 }, AdvanceSlot { dt: 5 }, AdvanceSlot { dt: 7 }, TopUpInsurance { amount: 3122 }, ExecuteTrade { lp: Lp, user: ExistingNonLp, oracle_price: 179213, size: 2576 }, Touch { who: Lp }, TopUpInsurance { amount: 9692 }, Deposit { who: ExistingNonLp, amount: 30485 }, Deposit { who: Lp, amount: 26623 }, TopUpInsurance { amount: 8522 }, Touch { who: Existing }, Deposit { who: ExistingNonLp, amount: 24582 }, ExecuteTrade { lp: Lp, user: ExistingNonLp, oracle_price: 4819190, size: 1844 }] 10 | cc 82f897ba9a251ca5f789fd4c0082e26a3cc6c9fcaa0a8d1877a423afd7772a77 # shrinks to initial_insurance = 0, actions = [AddUser { fee_payment: 1 }, ExecuteTrade { lp: Lp, user: ExistingNonLp, oracle_price: 100000, size: 1211 }, AdvanceSlot { dt: 0 }, Deposit { who: Existing, amount: 0 }, Deposit { who: Existing, amount: 0 }, Deposit { who: Existing, amount: 0 }, Deposit { who: Existing, amount: 0 }, Deposit { who: Existing, amount: 0 }, ExecuteTrade { lp: Lp, user: ExistingNonLp, oracle_price: 356502, size: -2478 }, Touch { who: ExistingNonLp }, Deposit { who: ExistingNonLp, amount: 44801 }, Touch { who: ExistingNonLp }, Withdraw { who: Lp, amount: 35524 }, Withdraw { who: ExistingNonLp, amount: 13112 }, ExecuteTrade { lp: Lp, user: ExistingNonLp, oracle_price: 189056, size: -3069 }, ExecuteTrade { lp: Lp, user: ExistingNonLp, oracle_price: 3538261, size: 546 }, Touch { who: ExistingNonLp }, Deposit { who: ExistingNonLp, amount: 23814 }, Withdraw { who: Existing, amount: 42756 }, Withdraw { who: Lp, amount: 68 }, ExecuteTrade { lp: Lp, user: ExistingNonLp, oracle_price: 7827496, size: -4557 }, AddUser { fee_payment: 17 }, AdvanceSlot { dt: 5 }, AdvanceSlot { dt: 9 }, AccrueFunding { dt: 41, oracle_price: 1786364, rate_bps: 62 }, ExecuteTrade { lp: Lp, user: ExistingNonLp, oracle_price: 1654926, size: 736 }, AdvanceSlot { dt: 1 }, ForceRealizeLosses { oracle_price: 8597286 }, AddLp { fee_payment: 72 }, Deposit { who: Existing, amount: 31146 }, ExecuteTrade { lp: Lp, user: ExistingNonLp, oracle_price: 2189184, size: 2445 }, Touch { who: Lp }, ExecuteTrade { lp: Lp, user: ExistingNonLp, oracle_price: 3119136, size: 1705 }, ExecuteTrade { lp: Lp, user: ExistingNonLp, oracle_price: 3798228, size: 347 }, Deposit { who: Existing, amount: 48147 }, AccrueFunding { dt: 30, oracle_price: 4624384, rate_bps: 50 }, AdvanceSlot { dt: 9 }, AdvanceSlot { dt: 6 }, AdvanceSlot { dt: 3 }, AddUser { fee_payment: 14 }, Deposit { who: Existing, amount: 25384 }, Deposit { who: Lp, amount: 7029 }, AddUser { fee_payment: 93 }, PanicSettleAll { oracle_price: 2134259 }, AddUser { fee_payment: 80 }, AccrueFunding { dt: 11, oracle_price: 5944552, rate_bps: 6 }, Deposit { who: Lp, amount: 14343 }, Withdraw { who: ExistingNonLp, amount: 16465 }, Deposit { who: ExistingNonLp, amount: 40761 }, PanicSettleAll { oracle_price: 7006324 }] 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Percolator: Risk Engine for Perpetual DEXs 2 | 3 | **Educational research project. NOT production ready. Do NOT use with real funds.** 4 | 5 | A formally verified risk engine for perpetual futures DEXs on Solana. Provides mathematical guarantees about fund safety under oracle manipulation. 6 | 7 | ## Design 8 | 9 | ### Core Insight 10 | 11 | Oracle manipulation allows attackers to create artificial profits. PNL warmup ensures profits cannot be withdrawn instantly - they "warm up" over time T. During ADL events, unwrapped PNL is haircutted first, protecting deposited capital. 12 | 13 | **Guarantee:** If oracle manipulation lasts at most time T, and PNL requires time T to vest, then deposited capital is always withdrawable (subject to margin requirements). 14 | 15 | ### Invariants 16 | 17 | | ID | Property | 18 | |----|----------| 19 | | **I1** | Account capital is NEVER reduced by ADL or socialization | 20 | | **I2** | Conservation: `vault + loss_accum >= sum(capital) + sum(pnl) + insurance` | 21 | | **I4** | ADL haircuts unwrapped PNL before insurance fund | 22 | | **I5** | PNL warmup is deterministic and monotonically increasing | 23 | | **I7** | Account isolation - operations on one account don't affect others | 24 | | **I8** | Insurance floor: insurance spending never reduces `insurance_fund.balance` below `I_min` | 25 | | **I9** | Threshold unstick: if `I <= I_min`, running the scan reduces total open interest to zero and forces loss payment from capital before ADL | 26 | 27 | ### Warmup Budget Invariant 28 | 29 | Warmup converts PnL into principal with sign: 30 | - **Positive PnL** can increase capital (profits become withdrawable principal) 31 | - **Negative PnL** can decrease capital (losses paid from principal, up to available capital) 32 | 33 | Two related invariants govern warmup: 34 | 35 | **Stable Invariant** (always holds): 36 | ``` 37 | W⁺ ≤ W⁻ + max(0, I - I_min) 38 | ``` 39 | 40 | **Budget Constraint** (limits future warmups): 41 | ``` 42 | Budget = W⁻ + S - W⁺ ≥ 0 43 | ``` 44 | 45 | where `S = max(0, I - I_min) - R` (saturating) = unreserved spendable insurance. 46 | 47 | **Definitions:** 48 | - `W⁺` = `warmed_pos_total` - cumulative positive PnL converted to capital 49 | - `W⁻` = `warmed_neg_total` - cumulative negative PnL paid from capital 50 | - `I` = `insurance_fund.balance` - current insurance fund balance 51 | - `I_min` = `risk_reduction_threshold` - minimum insurance floor (protected) 52 | - `R` = `warmup_insurance_reserved` - insurance above the floor committed to backing warmed profits (monotone counter) 53 | - `S` = `max(0, I - I_min) - R` (saturating) = unreserved spendable insurance 54 | 55 | `R` reserves part of the spendable insurance above the floor to back already-warmed profits. ADL can only spend unreserved insurance (`S`), leaving reserved insurance intact to back warmed profits. 56 | 57 | **Enforcement:** The invariant is enforced at the moment PnL would be converted into capital (warmup settlement), and losses are settled before gains. 58 | 59 | **Rationale:** This invariant prevents "profit maturation / withdrawal" from outrunning realized loss payments. Without this constraint, an attacker could create artificial profits via oracle manipulation, wait for warmup, and withdraw before corresponding losses are paid - effectively extracting value that doesn't exist in the vault. 60 | 61 | ### Key Operations 62 | 63 | **Trading:** Zero-sum PNL between user and LP. Fees go to insurance fund. 64 | 65 | **ADL (Auto-Deleveraging):** When losses must be covered: 66 | 1. Haircut unwrapped (young) PNL proportionally across all accounts 67 | 2. Spend only unreserved insurance above the protected floor: `max(0, I - I_min) - R` 68 | 3. Any remaining loss is added to `loss_accum` 69 | 70 | Insurance spending is capped: the engine will only spend unreserved insurance above `I_min` to cover losses. Reserved insurance (`R`) backs already-warmed profits and cannot be spent by ADL. 71 | 72 | **Risk-Reduction-Only Mode:** Triggered when insurance fund at or below threshold: 73 | - Warmup frozen (no more PNL vests) 74 | - Risk-increasing trades blocked 75 | - Capital withdrawals and position closing allowed 76 | - Exit via insurance fund top-up 77 | 78 | **Forced Loss Realization Scan (Threshold Unstick):** When `insurance_fund.balance <= I_min`, the engine can perform an atomic scan that settles all open positions at the oracle price and forces negative PnL to be paid from capital (up to available capital). Any unpaid remainder is socialized via ADL (unwrapped first, then spendable insurance above floor, then `loss_accum`). This prevents profit maturation/withdrawal from outrunning realized loss payments. 79 | 80 | **Funding:** O(1) cumulative index pattern. Settled lazily before any account operation. 81 | 82 | ### Conservation 83 | 84 | The conservation formula uses `>=` because negative rounding slippage leaves unclaimed value in the vault: 85 | 86 | ``` 87 | vault + loss_accum >= sum(capital) + sum(pnl) + insurance_fund.balance 88 | ``` 89 | 90 | Where: 91 | - `loss_accum` tracks uncovered losses after consuming unwrapped PnL and all spendable insurance above the floor 92 | 93 | This holds because: 94 | - Deposits/withdrawals adjust both vault and capital 95 | - Trading PNL is zero-sum between counterparties 96 | - Fees transfer from user PNL to insurance (net zero) 97 | - ADL redistributes PNL (net zero) 98 | - Integer rounding always rounds down, leaving surplus in vault (safe: vault has at least what's owed) 99 | 100 | ## Running Tests 101 | 102 | All tests require increased stack size due to fixed 4096-account array: 103 | 104 | ```bash 105 | # Unit tests (52 tests) 106 | RUST_MIN_STACK=16777216 cargo test 107 | 108 | # Fuzzing (property-based tests) 109 | RUST_MIN_STACK=16777216 cargo test --features fuzz 110 | 111 | # Formal verification (Kani) 112 | cargo install --locked kani-verifier 113 | cargo kani setup 114 | cargo kani 115 | ``` 116 | 117 | ## Formal Verification 118 | 119 | Kani proofs verify all critical invariants via bounded model checking. See `tests/kani.rs` for proof harnesses. 120 | 121 | ```bash 122 | # Run specific proof 123 | cargo kani --harness i1_adl_never_reduces_principal 124 | cargo kani --harness i2_deposit_preserves_conservation 125 | ``` 126 | 127 | Note: Kani proofs use 64-account arrays for tractability. Production uses 4096. 128 | 129 | ## Architecture 130 | 131 | - `#![no_std]` - no heap allocation 132 | - `#![forbid(unsafe_code)]` - safe Rust only 133 | - Fixed 4096-account slab (~664 KB) 134 | - Bitmap for O(1) slot allocation 135 | - O(N) ADL via bitmap scan 136 | 137 | ## Limitations 138 | 139 | - No signature verification (external concern) 140 | - No oracle implementation (external concern) 141 | - No account deallocation 142 | - Maximum 4096 accounts 143 | - Not audited for production 144 | 145 | ## License 146 | 147 | Apache-2.0 148 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "autocfg" 7 | version = "1.5.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 10 | 11 | [[package]] 12 | name = "bit-set" 13 | version = "0.8.0" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" 16 | dependencies = [ 17 | "bit-vec", 18 | ] 19 | 20 | [[package]] 21 | name = "bit-vec" 22 | version = "0.8.0" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" 25 | 26 | [[package]] 27 | name = "bitflags" 28 | version = "2.9.4" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" 31 | 32 | [[package]] 33 | name = "cfg-if" 34 | version = "1.0.4" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 37 | 38 | [[package]] 39 | name = "errno" 40 | version = "0.3.14" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 43 | dependencies = [ 44 | "libc", 45 | "windows-sys", 46 | ] 47 | 48 | [[package]] 49 | name = "fastrand" 50 | version = "2.3.0" 51 | source = "registry+https://github.com/rust-lang/crates.io-index" 52 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 53 | 54 | [[package]] 55 | name = "fnv" 56 | version = "1.0.7" 57 | source = "registry+https://github.com/rust-lang/crates.io-index" 58 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 59 | 60 | [[package]] 61 | name = "getrandom" 62 | version = "0.3.4" 63 | source = "registry+https://github.com/rust-lang/crates.io-index" 64 | checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 65 | dependencies = [ 66 | "cfg-if", 67 | "libc", 68 | "r-efi", 69 | "wasip2", 70 | ] 71 | 72 | [[package]] 73 | name = "lazy_static" 74 | version = "1.5.0" 75 | source = "registry+https://github.com/rust-lang/crates.io-index" 76 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 77 | 78 | [[package]] 79 | name = "libc" 80 | version = "0.2.177" 81 | source = "registry+https://github.com/rust-lang/crates.io-index" 82 | checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" 83 | 84 | [[package]] 85 | name = "linux-raw-sys" 86 | version = "0.11.0" 87 | source = "registry+https://github.com/rust-lang/crates.io-index" 88 | checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" 89 | 90 | [[package]] 91 | name = "num-traits" 92 | version = "0.2.19" 93 | source = "registry+https://github.com/rust-lang/crates.io-index" 94 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 95 | dependencies = [ 96 | "autocfg", 97 | ] 98 | 99 | [[package]] 100 | name = "once_cell" 101 | version = "1.21.3" 102 | source = "registry+https://github.com/rust-lang/crates.io-index" 103 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 104 | 105 | [[package]] 106 | name = "percolator" 107 | version = "0.1.0" 108 | dependencies = [ 109 | "proptest", 110 | ] 111 | 112 | [[package]] 113 | name = "ppv-lite86" 114 | version = "0.2.21" 115 | source = "registry+https://github.com/rust-lang/crates.io-index" 116 | checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 117 | dependencies = [ 118 | "zerocopy", 119 | ] 120 | 121 | [[package]] 122 | name = "proc-macro2" 123 | version = "1.0.101" 124 | source = "registry+https://github.com/rust-lang/crates.io-index" 125 | checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" 126 | dependencies = [ 127 | "unicode-ident", 128 | ] 129 | 130 | [[package]] 131 | name = "proptest" 132 | version = "1.8.0" 133 | source = "registry+https://github.com/rust-lang/crates.io-index" 134 | checksum = "2bb0be07becd10686a0bb407298fb425360a5c44a663774406340c59a22de4ce" 135 | dependencies = [ 136 | "bit-set", 137 | "bit-vec", 138 | "bitflags", 139 | "lazy_static", 140 | "num-traits", 141 | "rand", 142 | "rand_chacha", 143 | "rand_xorshift", 144 | "regex-syntax", 145 | "rusty-fork", 146 | "tempfile", 147 | "unarray", 148 | ] 149 | 150 | [[package]] 151 | name = "quick-error" 152 | version = "1.2.3" 153 | source = "registry+https://github.com/rust-lang/crates.io-index" 154 | checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" 155 | 156 | [[package]] 157 | name = "quote" 158 | version = "1.0.41" 159 | source = "registry+https://github.com/rust-lang/crates.io-index" 160 | checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" 161 | dependencies = [ 162 | "proc-macro2", 163 | ] 164 | 165 | [[package]] 166 | name = "r-efi" 167 | version = "5.3.0" 168 | source = "registry+https://github.com/rust-lang/crates.io-index" 169 | checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 170 | 171 | [[package]] 172 | name = "rand" 173 | version = "0.9.2" 174 | source = "registry+https://github.com/rust-lang/crates.io-index" 175 | checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 176 | dependencies = [ 177 | "rand_chacha", 178 | "rand_core", 179 | ] 180 | 181 | [[package]] 182 | name = "rand_chacha" 183 | version = "0.9.0" 184 | source = "registry+https://github.com/rust-lang/crates.io-index" 185 | checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 186 | dependencies = [ 187 | "ppv-lite86", 188 | "rand_core", 189 | ] 190 | 191 | [[package]] 192 | name = "rand_core" 193 | version = "0.9.3" 194 | source = "registry+https://github.com/rust-lang/crates.io-index" 195 | checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 196 | dependencies = [ 197 | "getrandom", 198 | ] 199 | 200 | [[package]] 201 | name = "rand_xorshift" 202 | version = "0.4.0" 203 | source = "registry+https://github.com/rust-lang/crates.io-index" 204 | checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" 205 | dependencies = [ 206 | "rand_core", 207 | ] 208 | 209 | [[package]] 210 | name = "regex-syntax" 211 | version = "0.8.8" 212 | source = "registry+https://github.com/rust-lang/crates.io-index" 213 | checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" 214 | 215 | [[package]] 216 | name = "rustix" 217 | version = "1.1.2" 218 | source = "registry+https://github.com/rust-lang/crates.io-index" 219 | checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" 220 | dependencies = [ 221 | "bitflags", 222 | "errno", 223 | "libc", 224 | "linux-raw-sys", 225 | "windows-sys", 226 | ] 227 | 228 | [[package]] 229 | name = "rusty-fork" 230 | version = "0.3.1" 231 | source = "registry+https://github.com/rust-lang/crates.io-index" 232 | checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" 233 | dependencies = [ 234 | "fnv", 235 | "quick-error", 236 | "tempfile", 237 | "wait-timeout", 238 | ] 239 | 240 | [[package]] 241 | name = "syn" 242 | version = "2.0.107" 243 | source = "registry+https://github.com/rust-lang/crates.io-index" 244 | checksum = "2a26dbd934e5451d21ef060c018dae56fc073894c5a7896f882928a76e6d081b" 245 | dependencies = [ 246 | "proc-macro2", 247 | "quote", 248 | "unicode-ident", 249 | ] 250 | 251 | [[package]] 252 | name = "tempfile" 253 | version = "3.23.0" 254 | source = "registry+https://github.com/rust-lang/crates.io-index" 255 | checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" 256 | dependencies = [ 257 | "fastrand", 258 | "getrandom", 259 | "once_cell", 260 | "rustix", 261 | "windows-sys", 262 | ] 263 | 264 | [[package]] 265 | name = "unarray" 266 | version = "0.1.4" 267 | source = "registry+https://github.com/rust-lang/crates.io-index" 268 | checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" 269 | 270 | [[package]] 271 | name = "unicode-ident" 272 | version = "1.0.19" 273 | source = "registry+https://github.com/rust-lang/crates.io-index" 274 | checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" 275 | 276 | [[package]] 277 | name = "wait-timeout" 278 | version = "0.2.1" 279 | source = "registry+https://github.com/rust-lang/crates.io-index" 280 | checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" 281 | dependencies = [ 282 | "libc", 283 | ] 284 | 285 | [[package]] 286 | name = "wasip2" 287 | version = "1.0.1+wasi-0.2.4" 288 | source = "registry+https://github.com/rust-lang/crates.io-index" 289 | checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" 290 | dependencies = [ 291 | "wit-bindgen", 292 | ] 293 | 294 | [[package]] 295 | name = "windows-link" 296 | version = "0.2.1" 297 | source = "registry+https://github.com/rust-lang/crates.io-index" 298 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 299 | 300 | [[package]] 301 | name = "windows-sys" 302 | version = "0.61.2" 303 | source = "registry+https://github.com/rust-lang/crates.io-index" 304 | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 305 | dependencies = [ 306 | "windows-link", 307 | ] 308 | 309 | [[package]] 310 | name = "wit-bindgen" 311 | version = "0.46.0" 312 | source = "registry+https://github.com/rust-lang/crates.io-index" 313 | checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" 314 | 315 | [[package]] 316 | name = "zerocopy" 317 | version = "0.8.27" 318 | source = "registry+https://github.com/rust-lang/crates.io-index" 319 | checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" 320 | dependencies = [ 321 | "zerocopy-derive", 322 | ] 323 | 324 | [[package]] 325 | name = "zerocopy-derive" 326 | version = "0.8.27" 327 | source = "registry+https://github.com/rust-lang/crates.io-index" 328 | checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" 329 | dependencies = [ 330 | "proc-macro2", 331 | "quote", 332 | "syn", 333 | ] 334 | -------------------------------------------------------------------------------- /tests/amm_tests.rs: -------------------------------------------------------------------------------- 1 | // End-to-end integration tests with realistic AMM matcher 2 | // Tests complete user journeys with multiple participants 3 | 4 | use percolator::*; 5 | 6 | fn default_params() -> RiskParams { 7 | RiskParams { 8 | warmup_period_slots: 100, 9 | maintenance_margin_bps: 500, // 5% 10 | initial_margin_bps: 1000, // 10% 11 | trading_fee_bps: 10, // 0.1% 12 | max_accounts: 1000, 13 | account_fee_bps: 10000, // 1% 14 | risk_reduction_threshold: 0, // Default: only trigger on full depletion 15 | } 16 | } 17 | 18 | // Simple AMM-style matcher that always succeeds 19 | // In production, this would perform actual matching logic or CPI 20 | struct AMMatcher; 21 | 22 | impl MatchingEngine for AMMatcher { 23 | fn execute_match( 24 | &self, 25 | _matching_engine_program: &[u8; 32], 26 | _matching_engine_context: &[u8; 32], 27 | _lp_account_id: u64, 28 | oracle_price: u64, 29 | size: i128, 30 | ) -> Result { 31 | // AMM always provides liquidity at requested price/size 32 | Ok(TradeExecution { 33 | price: oracle_price, 34 | size, 35 | }) 36 | } 37 | } 38 | 39 | const MATCHER: AMMatcher = AMMatcher; 40 | 41 | // Helper function to clamp to positive values 42 | fn clamp_pos_i128(val: i128) -> u128 { 43 | if val > 0 { 44 | val as u128 45 | } else { 46 | 0 47 | } 48 | } 49 | 50 | // ============================================================================ 51 | // E2E Test 1: Complete User Journey 52 | // ============================================================================ 53 | 54 | #[test] 55 | fn test_e2e_complete_user_journey() { 56 | // Scenario: Alice and Bob trade against LP, experience PNL, funding, warmup, withdrawal 57 | 58 | let mut engine = Box::new(RiskEngine::new(default_params())); 59 | 60 | // Initialize insurance fund 61 | engine.insurance_fund.balance = 50_000; 62 | 63 | // Add LP with capital (LP takes leveraged position opposite to users) 64 | let lp = engine.add_lp([1u8; 32], [2u8; 32], 10_000).unwrap(); 65 | engine.accounts[lp as usize].capital = 100_000; 66 | engine.vault = 100_000; 67 | 68 | // Add two users 69 | let alice = engine.add_user(10_000).unwrap(); 70 | let bob = engine.add_user(10_000).unwrap(); 71 | 72 | // Users deposit principal 73 | engine.deposit(alice, 10_000).unwrap(); 74 | engine.deposit(bob, 15_000).unwrap(); 75 | engine.vault = 125_000; // 100k LP + 10k Alice + 15k Bob 76 | 77 | // === Phase 1: Trading === 78 | 79 | // Alice opens long position at $1000 80 | let oracle_price = 1_000_000; // $1 in 6 decimal scale 81 | engine 82 | .execute_trade(&MATCHER, lp, alice, oracle_price, 5_000) 83 | .unwrap(); 84 | 85 | // Bob opens short position at $1000 86 | engine 87 | .execute_trade(&MATCHER, lp, bob, oracle_price, -3_000) 88 | .unwrap(); 89 | 90 | // Check positions 91 | assert_eq!(engine.accounts[alice as usize].position_size, 5_000); 92 | assert_eq!(engine.accounts[bob as usize].position_size, -3_000); 93 | assert_eq!(engine.accounts[lp as usize].position_size, -2_000); // Net opposite to users 94 | 95 | // === Phase 2: Price Movement & Unrealized PNL === 96 | 97 | // Price moves to $1.20 (+20%) 98 | let new_price = 1_200_000; 99 | 100 | // Alice closes half her position, realizing profit 101 | engine 102 | .execute_trade(&MATCHER, lp, alice, new_price, -2_500) 103 | .unwrap(); 104 | 105 | // Alice should have positive PNL from the closed portion 106 | // Profit = (1.20 - 1.00) × 2500 = 500 107 | assert!(engine.accounts[alice as usize].pnl > 0); 108 | let alice_pnl = engine.accounts[alice as usize].pnl; 109 | 110 | // === Phase 3: Funding Accrual === 111 | 112 | // Accrue funding rate (longs pay shorts) 113 | engine.advance_slot(10); 114 | engine 115 | .accrue_funding(engine.current_slot, new_price, 100) 116 | .unwrap(); // 100 bps/slot, longs pay 117 | 118 | // Settle funding for users 119 | engine.touch_account(alice).unwrap(); 120 | engine.touch_account(bob).unwrap(); 121 | 122 | // Alice (long) should have paid funding, Bob (short) should have received 123 | assert!(engine.accounts[alice as usize].pnl < alice_pnl); // PNL reduced by funding 124 | assert!(engine.accounts[bob as usize].pnl > 0); // Received funding 125 | 126 | // === Phase 4: PNL Warmup === 127 | 128 | // Check that Alice's PNL needs to warm up before withdrawal 129 | let alice_withdrawable = engine.withdrawable_pnl(&engine.accounts[alice as usize]); 130 | 131 | // Advance some slots 132 | engine.advance_slot(50); // Halfway through warmup 133 | 134 | let alice_warmed_halfway = engine.withdrawable_pnl(&engine.accounts[alice as usize]); 135 | assert!(alice_warmed_halfway > alice_withdrawable); 136 | 137 | // Advance to full warmup 138 | engine.advance_slot(100); 139 | 140 | let alice_fully_warmed = engine.withdrawable_pnl(&engine.accounts[alice as usize]); 141 | assert!(alice_fully_warmed >= alice_warmed_halfway); 142 | 143 | // === Phase 5: Withdrawal === 144 | 145 | // Alice closes her remaining position first 146 | engine 147 | .execute_trade( 148 | &MATCHER, 149 | lp, 150 | alice, 151 | new_price, 152 | -engine.accounts[alice as usize].position_size, 153 | ) 154 | .unwrap(); 155 | 156 | // Advance time for full warmup 157 | engine.advance_slot(100); 158 | 159 | // Now Alice can withdraw her warmed PNL + principal 160 | let alice_final_withdrawable = engine.withdrawable_pnl(&engine.accounts[alice as usize]); 161 | let alice_withdrawal = engine.accounts[alice as usize].capital + alice_final_withdrawable; 162 | 163 | if alice_withdrawal > 0 { 164 | engine.withdraw(alice, alice_withdrawal).unwrap(); 165 | 166 | // Alice should have minimal remaining balance 167 | assert!( 168 | engine.accounts[alice as usize].capital 169 | + clamp_pos_i128(engine.accounts[alice as usize].pnl) 170 | < 100 171 | ); 172 | } 173 | 174 | // === Phase 6: Panic Settle All === 175 | 176 | // Price moves to $2.00, causing significant losses 177 | let settle_price = 2_000_000; 178 | 179 | // Trigger panic settlement at the oracle price 180 | engine.panic_settle_all(settle_price).unwrap(); 181 | 182 | // All positions should be closed 183 | assert_eq!( 184 | engine.accounts[alice as usize].position_size, 0, 185 | "Alice position should be closed" 186 | ); 187 | assert_eq!( 188 | engine.accounts[bob as usize].position_size, 0, 189 | "Bob position should be closed" 190 | ); 191 | assert_eq!( 192 | engine.accounts[lp as usize].position_size, 0, 193 | "LP position should be closed" 194 | ); 195 | 196 | // System should be in risk-reduction mode 197 | assert!( 198 | engine.risk_reduction_only, 199 | "Should be in risk-reduction mode after panic settle" 200 | ); 201 | 202 | // All PNLs should be >= 0 (negative PNL clamped and socialized) 203 | assert!( 204 | engine.accounts[alice as usize].pnl >= 0, 205 | "Alice PNL should be >= 0" 206 | ); 207 | assert!( 208 | engine.accounts[bob as usize].pnl >= 0, 209 | "Bob PNL should be >= 0" 210 | ); 211 | assert!( 212 | engine.accounts[lp as usize].pnl >= 0, 213 | "LP PNL should be >= 0" 214 | ); 215 | 216 | println!("✅ E2E test passed: Complete user journey works correctly"); 217 | } 218 | 219 | // ============================================================================ 220 | // E2E Test 2: Multi-User Trading with ADL 221 | // ============================================================================ 222 | 223 | #[test] 224 | fn test_e2e_multi_user_with_adl() { 225 | // Scenario: Multiple users trade, one causes loss requiring ADL 226 | 227 | let mut engine = Box::new(RiskEngine::new(default_params())); 228 | engine.insurance_fund.balance = 10_000; 229 | 230 | let lp = engine.add_lp([1u8; 32], [2u8; 32], 10_000).unwrap(); 231 | engine.accounts[lp as usize].capital = 200_000; 232 | engine.vault = 200_000; 233 | 234 | // Add 5 users 235 | let mut users = Vec::new(); 236 | for _ in 0..5 { 237 | let user = engine.add_user(10_000).unwrap(); 238 | engine.deposit(user, 10_000).unwrap(); 239 | users.push(user); 240 | } 241 | engine.vault = 250_000; 242 | 243 | // Users 0-3 open long positions at $1000 244 | for i in 0..4 { 245 | engine 246 | .execute_trade(&MATCHER, lp, users[i], 1_000_000, 2_000) 247 | .unwrap(); 248 | } 249 | 250 | // User 4 opens short position 251 | engine 252 | .execute_trade(&MATCHER, lp, users[4], 1_000_000, -8_000) 253 | .unwrap(); 254 | 255 | // Price moves to $1.10 - all longs profit, short loses 256 | let new_price = 1_100_000; 257 | 258 | // Close some positions to realize PNL 259 | for i in 0..4 { 260 | engine 261 | .execute_trade(&MATCHER, lp, users[i], new_price, -2_000) 262 | .unwrap(); 263 | } 264 | 265 | // Users 0-3 should have positive PNL 266 | for i in 0..4 { 267 | assert!(engine.accounts[users[i] as usize].pnl > 0); 268 | } 269 | 270 | // Simulate a large loss event requiring ADL (e.g., LP underwater) 271 | let adl_loss = 5_000; 272 | engine.apply_adl(adl_loss).unwrap(); 273 | 274 | // Verify ADL haircutted unwrapped PNL first 275 | // Users should still have their principal intact 276 | for i in 0..4 { 277 | assert_eq!( 278 | engine.accounts[users[i] as usize].capital, 10_000, 279 | "Principal protected by I1" 280 | ); 281 | } 282 | 283 | // Total PNL should be reduced by ADL 284 | let total_pnl_after: i128 = users.iter().map(|&u| engine.accounts[u as usize].pnl).sum(); 285 | 286 | // Some PNL should remain (not all haircutted) 287 | println!("Total PNL after ADL: {}", total_pnl_after); 288 | 289 | println!("✅ E2E test passed: Multi-user ADL scenario works correctly"); 290 | } 291 | 292 | // ============================================================================ 293 | // E2E Test 3: Warmup Rate Limiting Under Stress 294 | // NOTE: Commented out - warmup rate limiting was removed in slab 4096 redesign 295 | // ============================================================================ 296 | 297 | /* 298 | #[test] 299 | fn test_e2e_warmup_rate_limiting_stress() { 300 | // Scenario: Many users with large PNL, warmup capacity gets constrained 301 | 302 | let mut engine = Box::new(RiskEngine::new(default_params())); 303 | 304 | // Small insurance fund to test capacity limits 305 | engine.insurance_fund.balance = 20_000; 306 | 307 | let lp = engine.add_lp([1u8; 32], [2u8; 32], 10_000).unwrap(); 308 | engine.accounts[lp as usize].capital = 500_000; 309 | engine.vault = 500_000; 310 | 311 | // Add 10 users 312 | let mut users = Vec::new(); 313 | for _ in 0..10 { 314 | let user = engine.add_user(10_000).unwrap(); 315 | engine.deposit(user, 5_000).unwrap(); 316 | users.push(user); 317 | } 318 | engine.vault = 550_000; 319 | 320 | // All users open large long positions 321 | for &user in &users { 322 | engine.execute_trade(&MATCHER, lp, user, 1_000_000, 10_000).unwrap(); 323 | } 324 | 325 | // Price moves up 50% - huge unrealized PNL 326 | let boom_price = 1_500_000; 327 | 328 | // Close all positions to realize massive PNL 329 | for &user in &users { 330 | engine.execute_trade(&MATCHER, lp, user, boom_price, -10_000).unwrap(); 331 | // execute_trade automatically calls update_warmup_slope() after PNL changes 332 | } 333 | 334 | // Each user should have large positive PNL (~5000 each = 50k total) 335 | let mut total_pnl = 0i128; 336 | for &user in &users { 337 | assert!(engine.accounts[user as usize].pnl > 1_000); 338 | total_pnl += engine.accounts[user as usize].pnl; 339 | } 340 | println!("Total realized PNL across all users: {}", total_pnl); 341 | 342 | // Verify warmup rate limiting is enforced 343 | // Max warmup rate = insurance_fund * 0.5 / (T/2) 344 | // Note: Insurance fund may have increased from fees, so max_rate may be slightly higher 345 | let max_rate = engine.insurance_fund.balance * 5000 / 50 / 10_000; 346 | assert!(max_rate >= 200, "Max rate should be at least 200"); 347 | 348 | println!("Insurance fund balance: {}", engine.insurance_fund.balance); 349 | println!("Calculated max warmup rate: {}", max_rate); 350 | println!("Actual total warmup rate: {}", engine.total_warmup_rate); 351 | 352 | // CRITICAL: Verify that warmup slopes were actually set by update_warmup_slope() 353 | // If total_warmup_rate is 0, it means update_warmup_slope() was never called 354 | assert!(engine.total_warmup_rate > 0, 355 | "Warmup slopes should be set after PNL changes (update_warmup_slope called by execute_trade)"); 356 | 357 | // Total warmup rate should not exceed this (allow small rounding tolerance) 358 | assert!(engine.total_warmup_rate <= max_rate + 5, 359 | "Warmup rate {} significantly exceeds limit {}", engine.total_warmup_rate, max_rate); 360 | 361 | // CRITICAL: Verify rate limiting is actually constraining the system 362 | // Calculate what the total would be WITHOUT rate limiting 363 | let total_pnl_u128 = total_pnl as u128; 364 | let ideal_total_slope = total_pnl_u128 / engine.params.warmup_period_slots as u128; 365 | println!("Ideal total slope (no limiting): {}", ideal_total_slope); 366 | 367 | // If ideal > max_rate, then rate limiting MUST be active 368 | if ideal_total_slope > max_rate { 369 | assert_eq!(engine.total_warmup_rate, max_rate, 370 | "Rate limiting should cap total slope at max_rate when demand exceeds capacity"); 371 | println!("✅ Rate limiting is ACTIVE: capped at {} (would be {} without limiting)", 372 | engine.total_warmup_rate, ideal_total_slope); 373 | } else { 374 | println!("ℹ️ Rate limiting not triggered: demand ({}) below capacity ({})", 375 | ideal_total_slope, max_rate); 376 | } 377 | 378 | // Users with higher PNL should get proportionally more capacity 379 | // But sum of all slopes should be capped 380 | let total_slope: u128 = users.iter() 381 | .map(|&u| engine.accounts[u as usize].warmup_slope_per_step) 382 | .sum(); 383 | 384 | assert_eq!(total_slope, engine.total_warmup_rate, 385 | "Sum of individual slopes must equal total_warmup_rate"); 386 | assert!(total_slope <= max_rate, 387 | "Total slope must not exceed max rate"); 388 | 389 | println!("✅ E2E test passed: Warmup rate limiting under stress works correctly"); 390 | println!(" Total slope: {}, Max rate: {}", total_slope, max_rate); 391 | } 392 | */ 393 | 394 | // ============================================================================ 395 | // E2E Test 4: Complete Cycle with Funding 396 | // ============================================================================ 397 | 398 | #[test] 399 | fn test_e2e_funding_complete_cycle() { 400 | // Scenario: Users trade, funding accrues over time, positions flip, funding reverses 401 | 402 | let mut engine = Box::new(RiskEngine::new(default_params())); 403 | engine.insurance_fund.balance = 50_000; 404 | 405 | let lp = engine.add_lp([1u8; 32], [2u8; 32], 10_000).unwrap(); 406 | engine.accounts[lp as usize].capital = 100_000; 407 | engine.vault = 100_000; 408 | 409 | let alice = engine.add_user(10_000).unwrap(); 410 | let bob = engine.add_user(10_000).unwrap(); 411 | 412 | engine.deposit(alice, 20_000).unwrap(); 413 | engine.deposit(bob, 20_000).unwrap(); 414 | engine.vault = 140_000; 415 | 416 | // Alice goes long, Bob goes short 417 | engine 418 | .execute_trade(&MATCHER, lp, alice, 1_000_000, 10_000) 419 | .unwrap(); 420 | engine 421 | .execute_trade(&MATCHER, lp, bob, 1_000_000, -10_000) 422 | .unwrap(); 423 | 424 | // Advance time and accrue funding (longs pay shorts) 425 | engine.advance_slot(20); 426 | engine 427 | .accrue_funding(engine.current_slot, 1_000_000, 50) 428 | .unwrap(); // 50 bps/slot 429 | 430 | // Settle funding 431 | engine.touch_account(alice).unwrap(); 432 | engine.touch_account(bob).unwrap(); 433 | 434 | let alice_pnl_after_funding = engine.accounts[alice as usize].pnl; 435 | let bob_pnl_after_funding = engine.accounts[bob as usize].pnl; 436 | 437 | // Alice (long) paid, Bob (short) received 438 | assert!(alice_pnl_after_funding < 0); // Paid funding 439 | assert!(bob_pnl_after_funding > 0); // Received funding 440 | 441 | // Verify zero-sum property (approximately, minus rounding) 442 | let total_funding = alice_pnl_after_funding + bob_pnl_after_funding; 443 | assert!( 444 | total_funding.abs() < 100, 445 | "Funding should be approximately zero-sum" 446 | ); 447 | 448 | // === Positions Flip === 449 | 450 | // Alice closes long and opens short 451 | engine 452 | .execute_trade(&MATCHER, lp, alice, 1_000_000, -20_000) 453 | .unwrap(); 454 | 455 | // Bob closes short and opens long 456 | engine 457 | .execute_trade(&MATCHER, lp, bob, 1_000_000, 20_000) 458 | .unwrap(); 459 | 460 | // Now Alice is short and Bob is long 461 | assert!(engine.accounts[alice as usize].position_size < 0); 462 | assert!(engine.accounts[bob as usize].position_size > 0); 463 | 464 | // Advance time and accrue more funding (now Alice receives, Bob pays) 465 | engine.advance_slot(20); 466 | engine 467 | .accrue_funding(engine.current_slot, 1_000_000, 50) 468 | .unwrap(); 469 | 470 | engine.touch_account(alice).unwrap(); 471 | engine.touch_account(bob).unwrap(); 472 | 473 | // Now funding should have reversed 474 | let alice_final = engine.accounts[alice as usize].pnl; 475 | let bob_final = engine.accounts[bob as usize].pnl; 476 | 477 | // Alice (now short) should have received some funding back 478 | assert!(alice_final > alice_pnl_after_funding); 479 | 480 | // Bob (now long) should have paid 481 | assert!(bob_final < bob_pnl_after_funding); 482 | 483 | println!("✅ E2E test passed: Funding complete cycle works correctly"); 484 | } 485 | 486 | // ============================================================================ 487 | // E2E Test 5: Oracle Manipulation Attack Scenario 488 | // NOTE: Partially commented out - warmup rate limiting was removed in slab 4096 redesign 489 | // ============================================================================ 490 | 491 | /* 492 | #[test] 493 | fn test_e2e_oracle_attack_protection() { 494 | // Scenario: Attacker tries to exploit oracle manipulation but gets limited by warmup + ADL 495 | 496 | let mut engine = Box::new(RiskEngine::new(default_params())); 497 | engine.insurance_fund.balance = 30_000; 498 | 499 | let lp = engine.add_lp([1u8; 32], [2u8; 32], 10_000).unwrap(); 500 | engine.accounts[lp as usize].capital = 200_000; 501 | engine.vault = 200_000; 502 | 503 | // Honest user 504 | let honest_user = engine.add_user(10_000).unwrap(); 505 | engine.deposit(honest_user, 20_000).unwrap(); 506 | 507 | // Attacker 508 | let attacker = engine.add_user(10_000).unwrap(); 509 | engine.deposit(attacker, 10_000).unwrap(); 510 | engine.vault = 230_000; 511 | 512 | // === Phase 1: Normal Trading === 513 | 514 | // Honest user opens long position 515 | engine.execute_trade(&MATCHER, lp, honest_user, 1_000_000, 5_000).unwrap(); 516 | 517 | // === Phase 2: Oracle Manipulation Attempt === 518 | 519 | // Attacker opens large position during manipulation 520 | engine.execute_trade(&MATCHER, lp, attacker, 1_000_000, 20_000).unwrap(); 521 | 522 | // Oracle gets manipulated to $2 (fake 100% gain) 523 | let fake_price = 2_000_000; 524 | 525 | // Attacker tries to close and realize fake profit 526 | engine.execute_trade(&MATCHER, lp, attacker, fake_price, -20_000).unwrap(); 527 | // execute_trade automatically calls update_warmup_slope() after realizing PNL 528 | 529 | // Attacker has massive fake PNL 530 | let attacker_fake_pnl = clamp_pos_i128(engine.accounts[attacker as usize].pnl); 531 | assert!(attacker_fake_pnl > 10_000); // Huge profit from manipulation 532 | 533 | // === Phase 3: Warmup Limiting === 534 | 535 | // Due to warmup rate limiting, attacker's PNL warms up slowly 536 | // Max warmup rate = insurance_fund * 0.5 / (T/2) 537 | let expected_max_rate = engine.insurance_fund.balance * 5000 / 50 / 10_000; 538 | 539 | println!("Attacker fake PNL: {}", attacker_fake_pnl); 540 | println!("Insurance fund: {}", engine.insurance_fund.balance); 541 | println!("Expected max warmup rate: {}", expected_max_rate); 542 | println!("Actual warmup rate: {}", engine.total_warmup_rate); 543 | println!("Attacker slope: {}", engine.accounts[attacker as usize].warmup_slope_per_step); 544 | 545 | // Verify that warmup slope was actually set 546 | assert!(engine.accounts[attacker as usize].warmup_slope_per_step > 0, 547 | "Attacker's warmup slope should be set after realizing PNL"); 548 | 549 | // Verify rate limiting is working (attacker's slope should be constrained) 550 | // In a stressed system, individual slope may be less than ideal due to capacity limits 551 | let ideal_slope = attacker_fake_pnl / engine.params.warmup_period_slots as u128; 552 | println!("Ideal slope (no limiting): {}", ideal_slope); 553 | println!("Actual slope (with limiting): {}", engine.accounts[attacker as usize].warmup_slope_per_step); 554 | 555 | // Advance only 10 slots (manipulation is detected quickly) 556 | engine.advance_slot(10); 557 | 558 | let attacker_warmed = engine.withdrawable_pnl(&engine.accounts[attacker as usize]); 559 | println!("Attacker withdrawable after 10 slots: {}", attacker_warmed); 560 | 561 | // Only a small fraction should be withdrawable 562 | // Expected: slope was capped by warmup rate limiting + only 10 slots elapsed 563 | assert!(attacker_warmed < attacker_fake_pnl / 5, 564 | "Most fake PNL should still be warming up (got {} out of {})", attacker_warmed, attacker_fake_pnl); 565 | 566 | // === Phase 4: Oracle Reverts, ADL Triggered === 567 | 568 | // Oracle reverts to true price, creating loss 569 | // ADL is triggered to socialize the loss 570 | 571 | engine.apply_adl(attacker_fake_pnl).unwrap(); 572 | 573 | // Attacker's unwrapped (still warming) PNL gets haircutted 574 | let attacker_after_adl = clamp_pos_i128(engine.accounts[attacker as usize].pnl); 575 | 576 | // Most of the fake PNL should be gone 577 | assert!(attacker_after_adl < attacker_fake_pnl / 2, 578 | "ADL should haircut most of the unwrapped PNL"); 579 | 580 | // === Phase 5: Honest User Protected === 581 | 582 | // Honest user's principal should be intact 583 | assert_eq!(engine.accounts[honest_user as usize].capital, 20_000, "I1: Principal never reduced"); 584 | 585 | // Insurance fund took some hit, but limited 586 | assert!(engine.insurance_fund.balance >= 20_000, 587 | "Insurance fund protected by warmup rate limiting"); 588 | 589 | println!("✅ E2E test passed: Oracle manipulation attack protection works correctly"); 590 | println!(" Attacker fake PNL: {}", attacker_fake_pnl); 591 | println!(" Attacker after ADL: {}", attacker_after_adl); 592 | println!(" Attack mitigation: {}%", (attacker_fake_pnl - attacker_after_adl) * 100 / attacker_fake_pnl); 593 | } 594 | */ 595 | -------------------------------------------------------------------------------- /src/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 | #![no_std] 19 | #![forbid(unsafe_code)] 20 | 21 | #[cfg(kani)] 22 | extern crate kani; 23 | 24 | // ============================================================================ 25 | // Constants 26 | // ============================================================================ 27 | 28 | // Use smaller array size for Kani verification, fuzz testing, and debug builds 29 | // to avoid stack overflow (RiskEngine is ~6MB at 4096 accounts). Production (release) uses 4096. 30 | #[cfg(kani)] 31 | pub const MAX_ACCOUNTS: usize = 8; // Small for fast formal verification 32 | 33 | #[cfg(all(any(feature = "fuzz", debug_assertions), not(kani)))] 34 | pub const MAX_ACCOUNTS: usize = 64; // Small to avoid stack overflow in tests 35 | 36 | #[cfg(all(not(kani), not(feature = "fuzz"), not(debug_assertions)))] 37 | pub const MAX_ACCOUNTS: usize = 4096; 38 | 39 | // Ceiling division ensures at least 1 word even when MAX_ACCOUNTS < 64 40 | pub const BITMAP_WORDS: usize = (MAX_ACCOUNTS + 63) / 64; 41 | 42 | /// Maximum allowed rounding slack in conservation check. 43 | /// Each integer division can lose at most 1 unit of quote currency. 44 | /// With MAX_ACCOUNTS positions, worst-case rounding loss is MAX_ACCOUNTS units. 45 | /// This bounds how much "dust" can accumulate in the vault from truncation. 46 | pub const MAX_ROUNDING_SLACK: u128 = MAX_ACCOUNTS as u128; 47 | 48 | // ============================================================================ 49 | // Core Data Structures 50 | // ============================================================================ 51 | 52 | #[repr(u8)] 53 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 54 | pub enum AccountKind { 55 | User = 0, 56 | LP = 1, 57 | } 58 | 59 | /// Unified account - can be user or LP 60 | /// 61 | /// LPs are distinguished by having kind = LP and matcher_program/context set. 62 | /// Users have kind = User and matcher arrays zeroed. 63 | /// 64 | /// This unification ensures LPs receive the same risk management protections as users: 65 | /// - PNL warmup 66 | /// - ADL (Auto-Deleveraging) 67 | /// - Liquidations 68 | #[repr(C)] 69 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 70 | pub struct Account { 71 | pub kind: AccountKind, 72 | 73 | /// Unique account ID (monotonically increasing, never recycled) 74 | pub account_id: u64, 75 | 76 | // ======================================== 77 | // Capital & PNL (universal) 78 | // ======================================== 79 | /// Deposited capital (user principal or LP capital) 80 | /// NEVER reduced by ADL/socialization (Invariant I1) 81 | pub capital: u128, 82 | 83 | /// Realized PNL from trading (can be positive or negative) 84 | pub pnl: i128, 85 | 86 | /// PNL reserved for pending withdrawals 87 | pub reserved_pnl: u128, 88 | 89 | // ======================================== 90 | // Warmup (embedded, no separate struct) 91 | // ======================================== 92 | /// Slot when warmup started 93 | pub warmup_started_at_slot: u64, 94 | 95 | /// Linear vesting rate per slot 96 | pub warmup_slope_per_step: u128, 97 | 98 | // ======================================== 99 | // Position (universal) 100 | // ======================================== 101 | /// Current position size (+ long, - short) 102 | pub position_size: i128, 103 | 104 | /// Average entry price for position 105 | pub entry_price: u64, 106 | 107 | // ======================================== 108 | // Funding (universal) 109 | // ======================================== 110 | /// Funding index snapshot (quote per base, 1e6 scale) 111 | pub funding_index: i128, 112 | 113 | // ======================================== 114 | // LP-specific (only meaningful for LP kind) 115 | // ======================================== 116 | /// Matching engine program ID (zero for user accounts) 117 | pub matcher_program: [u8; 32], 118 | 119 | /// Matching engine context account (zero for user accounts) 120 | pub matcher_context: [u8; 32], 121 | } 122 | 123 | impl Account { 124 | /// Check if this account is an LP 125 | pub fn is_lp(&self) -> bool { 126 | self.kind == AccountKind::LP 127 | } 128 | 129 | /// Check if this account is a regular user 130 | pub fn is_user(&self) -> bool { 131 | self.kind == AccountKind::User 132 | } 133 | } 134 | 135 | /// Helper to create empty account 136 | fn empty_account() -> Account { 137 | Account { 138 | kind: AccountKind::User, 139 | account_id: 0, 140 | capital: 0, 141 | pnl: 0, 142 | reserved_pnl: 0, 143 | warmup_started_at_slot: 0, 144 | warmup_slope_per_step: 0, 145 | position_size: 0, 146 | entry_price: 0, 147 | funding_index: 0, 148 | matcher_program: [0; 32], 149 | matcher_context: [0; 32], 150 | } 151 | } 152 | 153 | /// Insurance fund state 154 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 155 | pub struct InsuranceFund { 156 | /// Insurance fund balance 157 | pub balance: u128, 158 | 159 | /// Accumulated fees from trades 160 | pub fee_revenue: u128, 161 | } 162 | 163 | /// Risk engine parameters 164 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 165 | pub struct RiskParams { 166 | /// Warmup period in slots (time T) 167 | pub warmup_period_slots: u64, 168 | 169 | /// Maintenance margin ratio in basis points (e.g., 500 = 5%) 170 | pub maintenance_margin_bps: u64, 171 | 172 | /// Initial margin ratio in basis points 173 | pub initial_margin_bps: u64, 174 | 175 | /// Trading fee in basis points 176 | pub trading_fee_bps: u64, 177 | 178 | /// Maximum number of accounts 179 | pub max_accounts: u64, 180 | 181 | /// Base account creation fee in basis points (e.g., 10000 = 1%) 182 | /// Actual fee = (account_fee_bps * capacity_multiplier) / 10000 183 | /// The multiplier increases as the system approaches max capacity 184 | pub account_fee_bps: u64, 185 | 186 | /// Insurance fund threshold for entering risk-reduction-only mode 187 | /// If insurance fund balance drops below this, risk-reduction mode activates 188 | pub risk_reduction_threshold: u128, 189 | } 190 | 191 | /// Main risk engine state - fixed slab with bitmap 192 | #[repr(C)] 193 | #[derive(Clone, Debug, PartialEq, Eq)] 194 | pub struct RiskEngine { 195 | /// Total vault balance (all deposited funds) 196 | pub vault: u128, 197 | 198 | /// Insurance fund 199 | pub insurance_fund: InsuranceFund, 200 | 201 | /// Risk parameters 202 | pub params: RiskParams, 203 | 204 | /// Current slot (for warmup calculations) 205 | pub current_slot: u64, 206 | 207 | /// Global funding index (quote per 1 base, scaled by 1e6) 208 | pub funding_index_qpb_e6: i128, 209 | 210 | /// Last slot when funding was accrued 211 | pub last_funding_slot: u64, 212 | 213 | /// Loss accumulator for socialization 214 | pub loss_accum: u128, 215 | 216 | /// 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. 217 | pub risk_reduction_only: bool, 218 | 219 | /// Total amount withdrawn during risk-reduction-only mode 220 | /// Used to maintain fair haircut ratio during unwinding 221 | pub risk_reduction_mode_withdrawn: u128, 222 | 223 | /// Warmup pause flag 224 | pub warmup_paused: bool, 225 | 226 | /// Slot when warmup was paused 227 | pub warmup_pause_slot: u64, 228 | 229 | // ======================================== 230 | // Warmup Budget Tracking 231 | // ======================================== 232 | /// Cumulative positive PnL converted to capital (W+) 233 | pub warmed_pos_total: u128, 234 | 235 | /// Cumulative negative PnL paid from capital (W-) 236 | pub warmed_neg_total: u128, 237 | 238 | /// Insurance above the floor that has been committed to backing warmed profits (monotone) 239 | pub warmup_insurance_reserved: u128, 240 | 241 | // ======================================== 242 | // Slab Management 243 | // ======================================== 244 | /// Occupancy bitmap (4096 bits = 64 u64 words) 245 | pub used: [u64; BITMAP_WORDS], 246 | 247 | /// Number of used accounts (O(1) counter, fixes H2: fee bypass TOCTOU) 248 | pub num_used_accounts: u16, 249 | 250 | /// Next account ID to assign (monotonically increasing, never recycled) 251 | pub next_account_id: u64, 252 | 253 | /// Freelist head (u16::MAX = none) 254 | pub free_head: u16, 255 | 256 | /// Freelist next pointers 257 | pub next_free: [u16; MAX_ACCOUNTS], 258 | 259 | /// Account slab (4096 accounts) 260 | pub accounts: [Account; MAX_ACCOUNTS], 261 | } 262 | 263 | // ============================================================================ 264 | // Error Types 265 | // ============================================================================ 266 | 267 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 268 | pub enum RiskError { 269 | /// Insufficient balance for operation 270 | InsufficientBalance, 271 | 272 | /// Account would become undercollateralized 273 | Undercollateralized, 274 | 275 | /// Unauthorized operation 276 | Unauthorized, 277 | 278 | /// Invalid matching engine 279 | InvalidMatchingEngine, 280 | 281 | /// PNL not yet warmed up 282 | PnlNotWarmedUp, 283 | 284 | /// Arithmetic overflow 285 | Overflow, 286 | 287 | /// Account not found 288 | AccountNotFound, 289 | 290 | /// Account is not an LP account 291 | NotAnLPAccount, 292 | 293 | /// Position size mismatch 294 | PositionSizeMismatch, 295 | 296 | /// System in withdrawal-only mode (deposits and trading blocked) 297 | RiskReductionOnlyMode, 298 | 299 | /// Account kind mismatch 300 | AccountKindMismatch, 301 | } 302 | 303 | pub type Result = core::result::Result; 304 | 305 | /// Operation classification for risk-reduction-only mode gating 306 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 307 | pub enum OpClass { 308 | RiskIncrease, 309 | RiskNeutral, 310 | RiskReduce, 311 | } 312 | 313 | // ============================================================================ 314 | // Math Helpers (Saturating Arithmetic for Safety) 315 | // ============================================================================ 316 | 317 | #[inline] 318 | fn add_u128(a: u128, b: u128) -> u128 { 319 | a.saturating_add(b) 320 | } 321 | 322 | #[inline] 323 | fn sub_u128(a: u128, b: u128) -> u128 { 324 | a.saturating_sub(b) 325 | } 326 | 327 | #[inline] 328 | fn mul_u128(a: u128, b: u128) -> u128 { 329 | a.saturating_mul(b) 330 | } 331 | 332 | #[inline] 333 | fn div_u128(a: u128, b: u128) -> Result { 334 | if b == 0 { 335 | Err(RiskError::Overflow) // Division by zero 336 | } else { 337 | Ok(a / b) 338 | } 339 | } 340 | 341 | #[inline] 342 | fn clamp_pos_i128(val: i128) -> u128 { 343 | if val > 0 { 344 | val as u128 345 | } else { 346 | 0 347 | } 348 | } 349 | 350 | #[inline] 351 | fn clamp_neg_i128(val: i128) -> u128 { 352 | if val < 0 { 353 | (-val) as u128 354 | } else { 355 | 0 356 | } 357 | } 358 | 359 | /// Saturating absolute value for i128 (handles i128::MIN without overflow) 360 | #[inline] 361 | fn saturating_abs_i128(val: i128) -> i128 { 362 | if val == i128::MIN { 363 | i128::MAX 364 | } else { 365 | val.abs() 366 | } 367 | } 368 | 369 | // ============================================================================ 370 | // Matching Engine Trait 371 | // ============================================================================ 372 | 373 | /// Result of a successful trade execution from the matching engine 374 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 375 | pub struct TradeExecution { 376 | /// Actual execution price (may differ from oracle/requested price) 377 | pub price: u64, 378 | /// Actual executed size (may be partial fill) 379 | pub size: i128, 380 | } 381 | 382 | /// Trait for pluggable matching engines 383 | /// 384 | /// Implementers can provide custom order matching logic via CPI. 385 | /// The matching engine is responsible for validating and executing trades 386 | /// according to its own rules (CLOB, AMM, RFQ, etc). 387 | pub trait MatchingEngine { 388 | /// Execute a trade between LP and user 389 | /// 390 | /// # Arguments 391 | /// * `lp_program` - The LP's matching engine program ID 392 | /// * `lp_context` - The LP's matching engine context account 393 | /// * `lp_account_id` - Unique ID of the LP account (never recycled) 394 | /// * `oracle_price` - Current oracle price for reference 395 | /// * `size` - Requested position size (positive = long, negative = short) 396 | /// 397 | /// # Returns 398 | /// * `Ok(TradeExecution)` with actual executed price and size 399 | /// * `Err(RiskError)` if the trade is rejected 400 | /// 401 | /// # Safety 402 | /// The matching engine MUST verify user authorization before approving trades. 403 | /// The risk engine will check solvency after the trade executes. 404 | fn execute_match( 405 | &self, 406 | lp_program: &[u8; 32], 407 | lp_context: &[u8; 32], 408 | lp_account_id: u64, 409 | oracle_price: u64, 410 | size: i128, 411 | ) -> Result; 412 | } 413 | 414 | /// No-op matching engine (for testing) 415 | /// Returns the requested price and size as-is 416 | pub struct NoOpMatcher; 417 | 418 | impl MatchingEngine for NoOpMatcher { 419 | fn execute_match( 420 | &self, 421 | _lp_program: &[u8; 32], 422 | _lp_context: &[u8; 32], 423 | _lp_account_id: u64, 424 | oracle_price: u64, 425 | size: i128, 426 | ) -> Result { 427 | // Return requested price/size unchanged (no actual matching logic) 428 | Ok(TradeExecution { 429 | price: oracle_price, 430 | size, 431 | }) 432 | } 433 | } 434 | 435 | // ============================================================================ 436 | // Core Implementation 437 | // ============================================================================ 438 | 439 | impl RiskEngine { 440 | /// Create a new risk engine 441 | pub fn new(params: RiskParams) -> Self { 442 | let mut engine = Self { 443 | vault: 0, 444 | insurance_fund: InsuranceFund { 445 | balance: 0, 446 | fee_revenue: 0, 447 | }, 448 | params, 449 | current_slot: 0, 450 | funding_index_qpb_e6: 0, 451 | last_funding_slot: 0, 452 | loss_accum: 0, 453 | risk_reduction_only: false, 454 | risk_reduction_mode_withdrawn: 0, 455 | warmup_paused: false, 456 | warmup_pause_slot: 0, 457 | warmed_pos_total: 0, 458 | warmed_neg_total: 0, 459 | warmup_insurance_reserved: 0, 460 | used: [0; BITMAP_WORDS], 461 | num_used_accounts: 0, 462 | next_account_id: 0, 463 | free_head: 0, 464 | next_free: [0; MAX_ACCOUNTS], 465 | accounts: [empty_account(); MAX_ACCOUNTS], 466 | }; 467 | 468 | // Initialize freelist: 0 -> 1 -> 2 -> ... -> 4095 -> NONE 469 | for i in 0..MAX_ACCOUNTS - 1 { 470 | engine.next_free[i] = (i + 1) as u16; 471 | } 472 | engine.next_free[MAX_ACCOUNTS - 1] = u16::MAX; // Sentinel 473 | 474 | engine 475 | } 476 | 477 | // ======================================== 478 | // Bitmap Helpers 479 | // ======================================== 480 | 481 | fn is_used(&self, idx: usize) -> bool { 482 | let w = idx >> 6; 483 | let b = idx & 63; 484 | ((self.used[w] >> b) & 1) == 1 485 | } 486 | 487 | fn set_used(&mut self, idx: usize) { 488 | let w = idx >> 6; 489 | let b = idx & 63; 490 | self.used[w] |= 1u64 << b; 491 | } 492 | 493 | fn clear_used(&mut self, idx: usize) { 494 | let w = idx >> 6; 495 | let b = idx & 63; 496 | self.used[w] &= !(1u64 << b); 497 | } 498 | 499 | fn for_each_used_mut(&mut self, mut f: F) { 500 | for (block, word) in self.used.iter().copied().enumerate() { 501 | let mut w = word; 502 | while w != 0 { 503 | let bit = w.trailing_zeros() as usize; 504 | let idx = block * 64 + bit; 505 | w &= w - 1; // Clear lowest bit 506 | f(idx, &mut self.accounts[idx]); 507 | } 508 | } 509 | } 510 | 511 | fn for_each_used(&self, mut f: F) { 512 | for (block, word) in self.used.iter().copied().enumerate() { 513 | let mut w = word; 514 | while w != 0 { 515 | let bit = w.trailing_zeros() as usize; 516 | let idx = block * 64 + bit; 517 | w &= w - 1; // Clear lowest bit 518 | f(idx, &self.accounts[idx]); 519 | } 520 | } 521 | } 522 | 523 | // ======================================== 524 | // Account Allocation 525 | // ======================================== 526 | 527 | fn alloc_slot(&mut self) -> Result { 528 | if self.free_head == u16::MAX { 529 | return Err(RiskError::Overflow); // Slab full 530 | } 531 | let idx = self.free_head; 532 | self.free_head = self.next_free[idx as usize]; 533 | self.set_used(idx as usize); 534 | // Increment O(1) counter atomically (fixes H2: TOCTOU fee bypass) 535 | self.num_used_accounts = self.num_used_accounts.saturating_add(1); 536 | Ok(idx) 537 | } 538 | 539 | /// Calculate account creation fee multiplier 540 | fn account_fee_multiplier(max: u64, used: u64) -> u128 { 541 | if used >= max { 542 | return 0; // Cannot add 543 | } 544 | let remaining = max - used; 545 | if remaining == 0 { 546 | 0 547 | } else { 548 | // 2^(floor(log2(max / remaining))) 549 | let ratio = max / remaining; 550 | if ratio == 0 { 551 | 1 552 | } else { 553 | 1 << (64 - ratio.leading_zeros() - 1) 554 | } 555 | } 556 | } 557 | 558 | /// Count used accounts 559 | fn count_used(&self) -> u64 { 560 | let mut count = 0u64; 561 | self.for_each_used(|_, _| { 562 | count += 1; 563 | }); 564 | count 565 | } 566 | 567 | // ======================================== 568 | // Risk-Reduction-Only Mode Helpers 569 | // ======================================== 570 | 571 | /// Central gate for operation enforcement in risk-reduction-only mode 572 | #[inline] 573 | fn enforce_op(&self, op: OpClass) -> Result<()> { 574 | if !self.risk_reduction_only { 575 | return Ok(()); 576 | } 577 | match op { 578 | OpClass::RiskIncrease => Err(RiskError::RiskReductionOnlyMode), 579 | OpClass::RiskNeutral | OpClass::RiskReduce => Ok(()), 580 | } 581 | } 582 | 583 | /// Enter risk-reduction-only mode and freeze warmups 584 | pub fn enter_risk_reduction_only_mode(&mut self) { 585 | self.risk_reduction_only = true; 586 | if !self.warmup_paused { 587 | self.warmup_paused = true; 588 | self.warmup_pause_slot = self.current_slot; 589 | } 590 | } 591 | 592 | /// Exit risk-reduction-only mode if system is safe (loss fully covered AND above threshold) 593 | pub fn exit_risk_reduction_only_mode_if_safe(&mut self) { 594 | if self.loss_accum == 0 { 595 | // Check if insurance fund is back above configured threshold 596 | if self.insurance_fund.balance >= self.params.risk_reduction_threshold { 597 | self.risk_reduction_only = false; 598 | self.risk_reduction_mode_withdrawn = 0; 599 | self.warmup_paused = false; 600 | } 601 | } 602 | } 603 | 604 | // ======================================== 605 | // Account Management 606 | // ======================================== 607 | 608 | /// Add a new user account 609 | pub fn add_user(&mut self, fee_payment: u128) -> Result { 610 | // Use O(1) counter instead of O(N) count_used() (fixes H2: TOCTOU fee bypass) 611 | let used_count = self.num_used_accounts as u64; 612 | if used_count >= self.params.max_accounts { 613 | return Err(RiskError::Overflow); 614 | } 615 | 616 | let multiplier = Self::account_fee_multiplier(self.params.max_accounts, used_count); 617 | let required_fee = mul_u128(self.params.account_fee_bps as u128, multiplier) / 10_000; 618 | if fee_payment < required_fee { 619 | return Err(RiskError::InsufficientBalance); 620 | } 621 | 622 | // Pay fee to insurance (fee tokens are deposited into vault) 623 | self.vault = add_u128(self.vault, required_fee); 624 | self.insurance_fund.balance = add_u128(self.insurance_fund.balance, required_fee); 625 | self.insurance_fund.fee_revenue = add_u128(self.insurance_fund.fee_revenue, required_fee); 626 | 627 | // Allocate slot and assign unique ID 628 | let idx = self.alloc_slot()?; 629 | let account_id = self.next_account_id; 630 | self.next_account_id = self.next_account_id.saturating_add(1); 631 | 632 | // Initialize account 633 | self.accounts[idx as usize] = Account { 634 | kind: AccountKind::User, 635 | account_id, 636 | capital: 0, 637 | pnl: 0, 638 | reserved_pnl: 0, 639 | warmup_started_at_slot: self.current_slot, 640 | warmup_slope_per_step: 0, 641 | position_size: 0, 642 | entry_price: 0, 643 | funding_index: self.funding_index_qpb_e6, 644 | matcher_program: [0; 32], 645 | matcher_context: [0; 32], 646 | }; 647 | 648 | Ok(idx) 649 | } 650 | 651 | /// Add a new LP account 652 | pub fn add_lp( 653 | &mut self, 654 | matching_engine_program: [u8; 32], 655 | matching_engine_context: [u8; 32], 656 | fee_payment: u128, 657 | ) -> Result { 658 | // Use O(1) counter instead of O(N) count_used() (fixes H2: TOCTOU fee bypass) 659 | let used_count = self.num_used_accounts as u64; 660 | if used_count >= self.params.max_accounts { 661 | return Err(RiskError::Overflow); 662 | } 663 | 664 | let multiplier = Self::account_fee_multiplier(self.params.max_accounts, used_count); 665 | let required_fee = mul_u128(self.params.account_fee_bps as u128, multiplier) / 10_000; 666 | if fee_payment < required_fee { 667 | return Err(RiskError::InsufficientBalance); 668 | } 669 | 670 | // Pay fee to insurance (fee tokens are deposited into vault) 671 | self.vault = add_u128(self.vault, required_fee); 672 | self.insurance_fund.balance = add_u128(self.insurance_fund.balance, required_fee); 673 | self.insurance_fund.fee_revenue = add_u128(self.insurance_fund.fee_revenue, required_fee); 674 | 675 | // Allocate slot and assign unique ID 676 | let idx = self.alloc_slot()?; 677 | let account_id = self.next_account_id; 678 | self.next_account_id = self.next_account_id.saturating_add(1); 679 | 680 | // Initialize account 681 | self.accounts[idx as usize] = Account { 682 | kind: AccountKind::LP, 683 | account_id, 684 | capital: 0, 685 | pnl: 0, 686 | reserved_pnl: 0, 687 | warmup_started_at_slot: self.current_slot, 688 | warmup_slope_per_step: 0, 689 | position_size: 0, 690 | entry_price: 0, 691 | funding_index: self.funding_index_qpb_e6, 692 | matcher_program: matching_engine_program, 693 | matcher_context: matching_engine_context, 694 | }; 695 | 696 | Ok(idx) 697 | } 698 | 699 | // ======================================== 700 | // Warmup 701 | // ======================================== 702 | 703 | /// Calculate withdrawable PNL for an account after warmup 704 | pub fn withdrawable_pnl(&self, account: &Account) -> u128 { 705 | // Only positive PNL can be withdrawn 706 | let positive_pnl = clamp_pos_i128(account.pnl); 707 | 708 | // Available = positive PNL - reserved 709 | let available_pnl = sub_u128(positive_pnl, account.reserved_pnl); 710 | 711 | // Apply warmup pause - when paused, warmup cannot progress beyond pause_slot 712 | let effective_slot = if self.warmup_paused { 713 | core::cmp::min(self.current_slot, self.warmup_pause_slot) 714 | } else { 715 | self.current_slot 716 | }; 717 | 718 | // Calculate elapsed slots 719 | let elapsed_slots = effective_slot.saturating_sub(account.warmup_started_at_slot); 720 | 721 | // Calculate warmed up cap: slope * elapsed_slots 722 | let warmed_up_cap = mul_u128(account.warmup_slope_per_step, elapsed_slots as u128); 723 | 724 | // Return minimum of available and warmed up 725 | core::cmp::min(available_pnl, warmed_up_cap) 726 | } 727 | 728 | /// Update warmup slope for an account 729 | /// NOTE: No warmup rate cap (removed for simplicity) 730 | pub fn update_warmup_slope(&mut self, idx: u16) -> Result<()> { 731 | if !self.is_used(idx as usize) { 732 | return Err(RiskError::AccountNotFound); 733 | } 734 | 735 | let account = &mut self.accounts[idx as usize]; 736 | 737 | // Calculate positive PNL that needs to warm up 738 | let positive_pnl = clamp_pos_i128(account.pnl); 739 | 740 | // Calculate slope: pnl / warmup_period 741 | let slope = if self.params.warmup_period_slots > 0 { 742 | positive_pnl / (self.params.warmup_period_slots as u128) 743 | } else { 744 | positive_pnl // Instant warmup if period is 0 745 | }; 746 | 747 | // Update slope 748 | account.warmup_slope_per_step = slope; 749 | 750 | // Don't update started_at_slot if warmup is paused 751 | if !self.warmup_paused { 752 | account.warmup_started_at_slot = self.current_slot; 753 | } 754 | 755 | Ok(()) 756 | } 757 | 758 | // ======================================== 759 | // Funding 760 | // ======================================== 761 | 762 | /// Accrue funding globally in O(1) 763 | pub fn accrue_funding( 764 | &mut self, 765 | now_slot: u64, 766 | oracle_price: u64, 767 | funding_rate_bps_per_slot: i64, 768 | ) -> Result<()> { 769 | // Funding accrual is risk-neutral (allowed in risk mode) 770 | self.enforce_op(OpClass::RiskNeutral)?; 771 | 772 | let dt = now_slot.saturating_sub(self.last_funding_slot); 773 | if dt == 0 { 774 | return Ok(()); 775 | } 776 | 777 | // Input validation to prevent overflow 778 | if oracle_price == 0 || oracle_price > 1_000_000_000_000 { 779 | return Err(RiskError::Overflow); 780 | } 781 | 782 | // Cap funding rate at 10000 bps (100%) per slot as sanity bound 783 | // Real-world funding rates should be much smaller (typically < 1 bps/slot) 784 | if funding_rate_bps_per_slot.abs() > 10_000 { 785 | return Err(RiskError::Overflow); 786 | } 787 | 788 | if dt > 31_536_000 { 789 | return Err(RiskError::Overflow); 790 | } 791 | 792 | // Use checked math to prevent silent overflow 793 | let price = oracle_price as i128; 794 | let rate = funding_rate_bps_per_slot as i128; 795 | let dt_i = dt as i128; 796 | 797 | // ΔF = price × rate × dt / 10,000 798 | let delta = price 799 | .checked_mul(rate) 800 | .ok_or(RiskError::Overflow)? 801 | .checked_mul(dt_i) 802 | .ok_or(RiskError::Overflow)? 803 | .checked_div(10_000) 804 | .ok_or(RiskError::Overflow)?; 805 | 806 | self.funding_index_qpb_e6 = self 807 | .funding_index_qpb_e6 808 | .checked_add(delta) 809 | .ok_or(RiskError::Overflow)?; 810 | 811 | self.last_funding_slot = now_slot; 812 | Ok(()) 813 | } 814 | 815 | /// Settle funding for an account (lazy update) 816 | fn settle_account_funding(account: &mut Account, global_funding_index: i128) -> Result<()> { 817 | let delta_f = global_funding_index 818 | .checked_sub(account.funding_index) 819 | .ok_or(RiskError::Overflow)?; 820 | 821 | if delta_f != 0 && account.position_size != 0 { 822 | // payment = position × ΔF / 1e6 823 | // Round UP for positive payments (account pays), truncate for negative (account receives) 824 | // This ensures vault always has at least what's owed (one-sided conservation slack). 825 | let raw = account 826 | .position_size 827 | .checked_mul(delta_f) 828 | .ok_or(RiskError::Overflow)?; 829 | 830 | let payment = if raw > 0 { 831 | // Account is paying: round UP to ensure vault gets at least theoretical amount 832 | raw.checked_add(999_999) 833 | .ok_or(RiskError::Overflow)? 834 | .checked_div(1_000_000) 835 | .ok_or(RiskError::Overflow)? 836 | } else { 837 | // Account is receiving: truncate towards zero to give at most theoretical amount 838 | raw.checked_div(1_000_000).ok_or(RiskError::Overflow)? 839 | }; 840 | 841 | // Longs pay when funding positive: pnl -= payment 842 | account.pnl = account 843 | .pnl 844 | .checked_sub(payment) 845 | .ok_or(RiskError::Overflow)?; 846 | } 847 | 848 | account.funding_index = global_funding_index; 849 | Ok(()) 850 | } 851 | 852 | /// Touch an account (settle funding before operations) 853 | pub fn touch_account(&mut self, idx: u16) -> Result<()> { 854 | // Funding settlement is risk-neutral (allowed in risk mode) 855 | self.enforce_op(OpClass::RiskNeutral)?; 856 | 857 | if !self.is_used(idx as usize) { 858 | return Err(RiskError::AccountNotFound); 859 | } 860 | 861 | let account = &mut self.accounts[idx as usize]; 862 | Self::settle_account_funding(account, self.funding_index_qpb_e6) 863 | } 864 | 865 | /// Settle funding for all accounts (ensures funding is zero-sum for conservation checks) 866 | #[cfg(any(test, feature = "fuzz"))] 867 | pub fn settle_all_funding(&mut self) -> Result<()> { 868 | let global_index = self.funding_index_qpb_e6; 869 | for block in 0..BITMAP_WORDS { 870 | let mut w = self.used[block]; 871 | while w != 0 { 872 | let bit = w.trailing_zeros() as usize; 873 | let idx = block * 64 + bit; 874 | w &= w - 1; 875 | 876 | Self::settle_account_funding(&mut self.accounts[idx], global_index)?; 877 | } 878 | } 879 | Ok(()) 880 | } 881 | 882 | // ======================================== 883 | // Deposits and Withdrawals 884 | // ======================================== 885 | 886 | /// Deposit funds to account 887 | pub fn deposit(&mut self, idx: u16, amount: u128) -> Result<()> { 888 | // Deposits reduce risk (allowed in risk mode) 889 | self.enforce_op(OpClass::RiskReduce)?; 890 | 891 | if !self.is_used(idx as usize) { 892 | return Err(RiskError::AccountNotFound); 893 | } 894 | 895 | self.accounts[idx as usize].capital = add_u128(self.accounts[idx as usize].capital, amount); 896 | self.vault = add_u128(self.vault, amount); 897 | 898 | // Settle warmup after deposit (allows losses to be paid promptly if underwater) 899 | self.settle_warmup_to_capital(idx)?; 900 | 901 | Ok(()) 902 | } 903 | 904 | /// Withdraw capital from an account. 905 | /// Relies on Solana transaction atomicity: if this returns Err, the entire TX aborts. 906 | pub fn withdraw(&mut self, idx: u16, amount: u128) -> Result<()> { 907 | // Withdrawals are neutral in risk mode (allowed) 908 | self.enforce_op(OpClass::RiskNeutral)?; 909 | 910 | // Validate account exists 911 | if !self.is_used(idx as usize) { 912 | return Err(RiskError::AccountNotFound); 913 | } 914 | 915 | // Settle funding and warmup (propagate errors) 916 | self.touch_account(idx)?; 917 | self.settle_warmup_to_capital(idx)?; 918 | 919 | let account = &self.accounts[idx as usize]; 920 | 921 | // Check we have enough capital 922 | if account.capital < amount { 923 | return Err(RiskError::InsufficientBalance); 924 | } 925 | 926 | // Calculate new state after withdrawal 927 | let new_capital = sub_u128(account.capital, amount); 928 | let new_collateral = add_u128(new_capital, clamp_pos_i128(account.pnl)); 929 | 930 | // If account has position, must maintain initial margin 931 | if account.position_size != 0 { 932 | let position_notional = mul_u128( 933 | saturating_abs_i128(account.position_size) as u128, 934 | account.entry_price as u128, 935 | ) / 1_000_000; 936 | 937 | let initial_margin_required = 938 | mul_u128(position_notional, self.params.initial_margin_bps as u128) / 10_000; 939 | 940 | if new_collateral < initial_margin_required { 941 | return Err(RiskError::Undercollateralized); 942 | } 943 | } 944 | 945 | // Commit the withdrawal 946 | self.accounts[idx as usize].capital = new_capital; 947 | self.vault = sub_u128(self.vault, amount); 948 | 949 | Ok(()) 950 | } 951 | 952 | // ======================================== 953 | // Trading 954 | // ======================================== 955 | 956 | /// Calculate account's collateral (capital + positive PNL) 957 | pub fn account_collateral(&self, account: &Account) -> u128 { 958 | add_u128(account.capital, clamp_pos_i128(account.pnl)) 959 | } 960 | 961 | /// Check if account is above maintenance margin 962 | pub fn is_above_maintenance_margin(&self, account: &Account, oracle_price: u64) -> bool { 963 | let collateral = self.account_collateral(account); 964 | 965 | // Calculate position value at current price 966 | let position_value = mul_u128( 967 | saturating_abs_i128(account.position_size) as u128, 968 | oracle_price as u128, 969 | ) / 1_000_000; 970 | 971 | // Maintenance margin requirement 972 | let margin_required = 973 | mul_u128(position_value, self.params.maintenance_margin_bps as u128) / 10_000; 974 | 975 | collateral > margin_required 976 | } 977 | 978 | /// 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. 979 | /// Execute a trade between LP and user. 980 | /// Relies on Solana transaction atomicity: if this returns Err, the entire TX aborts. 981 | pub fn execute_trade( 982 | &mut self, 983 | matcher: &M, 984 | lp_idx: u16, 985 | user_idx: u16, 986 | oracle_price: u64, 987 | size: i128, 988 | ) -> Result<()> { 989 | // Validate indices 990 | if !self.is_used(lp_idx as usize) || !self.is_used(user_idx as usize) { 991 | return Err(RiskError::AccountNotFound); 992 | } 993 | 994 | // Validate account kinds 995 | if self.accounts[lp_idx as usize].kind != AccountKind::LP { 996 | return Err(RiskError::AccountKindMismatch); 997 | } 998 | if self.accounts[user_idx as usize].kind != AccountKind::User { 999 | return Err(RiskError::AccountKindMismatch); 1000 | } 1001 | 1002 | // Check if trade increases risk (absolute exposure for either party) 1003 | let old_user_pos = self.accounts[user_idx as usize].position_size; 1004 | let old_lp_pos = self.accounts[lp_idx as usize].position_size; 1005 | let new_user_pos = old_user_pos.saturating_add(size); 1006 | let new_lp_pos = old_lp_pos.saturating_sub(size); 1007 | 1008 | let user_inc = saturating_abs_i128(new_user_pos) > saturating_abs_i128(old_user_pos); 1009 | let lp_inc = saturating_abs_i128(new_lp_pos) > saturating_abs_i128(old_lp_pos); 1010 | 1011 | if user_inc || lp_inc { 1012 | self.enforce_op(OpClass::RiskIncrease)?; 1013 | } else { 1014 | self.enforce_op(OpClass::RiskReduce)?; 1015 | } 1016 | 1017 | // Call matching engine 1018 | let lp = &self.accounts[lp_idx as usize]; 1019 | let execution = matcher.execute_match( 1020 | &lp.matcher_program, 1021 | &lp.matcher_context, 1022 | lp.account_id, 1023 | oracle_price, 1024 | size, 1025 | )?; 1026 | 1027 | // Settle funding for both accounts (propagate errors) 1028 | self.touch_account(user_idx)?; 1029 | self.touch_account(lp_idx)?; 1030 | 1031 | let exec_price = execution.price; 1032 | let exec_size = execution.size; 1033 | 1034 | // Calculate fee 1035 | let notional = 1036 | mul_u128(saturating_abs_i128(exec_size) as u128, exec_price as u128) / 1_000_000; 1037 | let fee = mul_u128(notional, self.params.trading_fee_bps as u128) / 10_000; 1038 | 1039 | // Access both accounts 1040 | let (user, lp) = if user_idx < lp_idx { 1041 | let (left, right) = self.accounts.split_at_mut(lp_idx as usize); 1042 | (&mut left[user_idx as usize], &mut right[0]) 1043 | } else { 1044 | let (left, right) = self.accounts.split_at_mut(user_idx as usize); 1045 | (&mut right[0], &mut left[lp_idx as usize]) 1046 | }; 1047 | 1048 | // Calculate PNL impact from closing existing position 1049 | let mut user_pnl_delta = 0i128; 1050 | let mut lp_pnl_delta = 0i128; 1051 | 1052 | if user.position_size != 0 { 1053 | let old_position = user.position_size; 1054 | let old_entry = user.entry_price; 1055 | 1056 | if (old_position > 0 && exec_size < 0) || (old_position < 0 && exec_size > 0) { 1057 | let close_size = core::cmp::min( 1058 | saturating_abs_i128(old_position), 1059 | saturating_abs_i128(exec_size), 1060 | ); 1061 | let price_diff = if old_position > 0 { 1062 | (exec_price as i128).saturating_sub(old_entry as i128) 1063 | } else { 1064 | (old_entry as i128).saturating_sub(exec_price as i128) 1065 | }; 1066 | 1067 | // Use saturating arithmetic (no overflow errors needed with Solana atomicity) 1068 | let pnl = price_diff 1069 | .saturating_mul(close_size) 1070 | .saturating_div(1_000_000); 1071 | user_pnl_delta = pnl; 1072 | lp_pnl_delta = -pnl; 1073 | } 1074 | } 1075 | 1076 | // Calculate new positions 1077 | let new_user_position = user.position_size.saturating_add(exec_size); 1078 | let new_lp_position = lp.position_size.saturating_sub(exec_size); 1079 | 1080 | // Calculate new entry prices 1081 | let mut new_user_entry = user.entry_price; 1082 | let mut new_lp_entry = lp.entry_price; 1083 | 1084 | // Update user entry price 1085 | if (user.position_size > 0 && exec_size > 0) || (user.position_size < 0 && exec_size < 0) { 1086 | let old_notional = mul_u128( 1087 | saturating_abs_i128(user.position_size) as u128, 1088 | user.entry_price as u128, 1089 | ); 1090 | let new_notional = mul_u128(saturating_abs_i128(exec_size) as u128, exec_price as u128); 1091 | let total_notional = add_u128(old_notional, new_notional); 1092 | let total_size = saturating_abs_i128(user.position_size) 1093 | .saturating_add(saturating_abs_i128(exec_size)); 1094 | if total_size != 0 { 1095 | new_user_entry = div_u128(total_notional, total_size as u128)? as u64; 1096 | } 1097 | } else if saturating_abs_i128(user.position_size) < saturating_abs_i128(exec_size) { 1098 | new_user_entry = exec_price; 1099 | } 1100 | 1101 | // Update LP entry price 1102 | if lp.position_size == 0 { 1103 | new_lp_entry = exec_price; 1104 | } else if (lp.position_size > 0 && new_lp_position > lp.position_size) 1105 | || (lp.position_size < 0 && new_lp_position < lp.position_size) 1106 | { 1107 | let old_notional = mul_u128( 1108 | saturating_abs_i128(lp.position_size) as u128, 1109 | lp.entry_price as u128, 1110 | ); 1111 | let new_notional = mul_u128(saturating_abs_i128(exec_size) as u128, exec_price as u128); 1112 | let total_notional = add_u128(old_notional, new_notional); 1113 | let total_size = saturating_abs_i128(lp.position_size) 1114 | .saturating_add(saturating_abs_i128(exec_size)); 1115 | if total_size != 0 { 1116 | new_lp_entry = div_u128(total_notional, total_size as u128)? as u64; 1117 | } 1118 | } else if saturating_abs_i128(lp.position_size) < saturating_abs_i128(new_lp_position) 1119 | && ((lp.position_size > 0 && new_lp_position < 0) 1120 | || (lp.position_size < 0 && new_lp_position > 0)) 1121 | { 1122 | new_lp_entry = exec_price; 1123 | } 1124 | 1125 | // Compute final PNL values 1126 | let new_user_pnl = user 1127 | .pnl 1128 | .saturating_add(user_pnl_delta) 1129 | .saturating_sub(fee as i128); 1130 | let new_lp_pnl = lp.pnl.saturating_add(lp_pnl_delta); 1131 | 1132 | // Check user maintenance margin 1133 | if new_user_position != 0 { 1134 | let user_collateral = add_u128(user.capital, clamp_pos_i128(new_user_pnl)); 1135 | let position_value = mul_u128( 1136 | saturating_abs_i128(new_user_position) as u128, 1137 | oracle_price as u128, 1138 | ) / 1_000_000; 1139 | let margin_required = 1140 | mul_u128(position_value, self.params.maintenance_margin_bps as u128) / 10_000; 1141 | if user_collateral <= margin_required { 1142 | return Err(RiskError::Undercollateralized); 1143 | } 1144 | } 1145 | 1146 | // Check LP maintenance margin 1147 | if new_lp_position != 0 { 1148 | let lp_collateral = add_u128(lp.capital, clamp_pos_i128(new_lp_pnl)); 1149 | let position_value = mul_u128( 1150 | saturating_abs_i128(new_lp_position) as u128, 1151 | oracle_price as u128, 1152 | ) / 1_000_000; 1153 | let margin_required = 1154 | mul_u128(position_value, self.params.maintenance_margin_bps as u128) / 10_000; 1155 | if lp_collateral <= margin_required { 1156 | return Err(RiskError::Undercollateralized); 1157 | } 1158 | } 1159 | 1160 | // Commit all state changes 1161 | self.insurance_fund.fee_revenue = add_u128(self.insurance_fund.fee_revenue, fee); 1162 | self.insurance_fund.balance = add_u128(self.insurance_fund.balance, fee); 1163 | 1164 | user.pnl = new_user_pnl; 1165 | user.position_size = new_user_position; 1166 | user.entry_price = new_user_entry; 1167 | 1168 | lp.pnl = new_lp_pnl; 1169 | lp.position_size = new_lp_position; 1170 | lp.entry_price = new_lp_entry; 1171 | 1172 | // Update warmup slopes after PNL changes 1173 | self.update_warmup_slope(user_idx)?; 1174 | self.update_warmup_slope(lp_idx)?; 1175 | 1176 | // Settle warmup for both accounts (at the very end of trade) 1177 | self.settle_warmup_to_capital(user_idx)?; 1178 | self.settle_warmup_to_capital(lp_idx)?; 1179 | 1180 | Ok(()) 1181 | } 1182 | 1183 | // ======================================== 1184 | // ADL (Auto-Deleveraging) - Scan-Based 1185 | // ======================================== 1186 | 1187 | /// Calculate unwrapped PNL for an account (inline helper for ADL) 1188 | /// Unwrapped = positive_pnl - withdrawable - reserved 1189 | #[inline] 1190 | fn compute_unwrapped_pnl(&self, account: &Account) -> u128 { 1191 | if account.pnl <= 0 { 1192 | return 0; 1193 | } 1194 | 1195 | let positive_pnl = account.pnl as u128; 1196 | let available_pnl = positive_pnl.saturating_sub(account.reserved_pnl); 1197 | 1198 | // Apply warmup pause - when paused, warmup cannot progress beyond pause_slot 1199 | let effective_slot = if self.warmup_paused { 1200 | core::cmp::min(self.current_slot, self.warmup_pause_slot) 1201 | } else { 1202 | self.current_slot 1203 | }; 1204 | 1205 | // Calculate withdrawable inline 1206 | let elapsed_slots = effective_slot.saturating_sub(account.warmup_started_at_slot); 1207 | let warmed_up_cap = mul_u128(account.warmup_slope_per_step, elapsed_slots as u128); 1208 | let withdrawable = core::cmp::min(available_pnl, warmed_up_cap); 1209 | 1210 | // Unwrapped = positive_pnl - withdrawable - reserved 1211 | positive_pnl 1212 | .saturating_sub(withdrawable) 1213 | .saturating_sub(account.reserved_pnl) 1214 | } 1215 | 1216 | /// Returns insurance balance above the floor (raw spendable, before reservations) 1217 | #[inline] 1218 | pub fn insurance_spendable_raw(&self) -> u128 { 1219 | let floor = self.params.risk_reduction_threshold; 1220 | if self.insurance_fund.balance > floor { 1221 | self.insurance_fund.balance - floor 1222 | } else { 1223 | 0 1224 | } 1225 | } 1226 | 1227 | /// Returns insurance spendable for ADL and warmup budget (raw - reserved) 1228 | #[inline] 1229 | pub fn insurance_spendable_unreserved(&self) -> u128 { 1230 | self.insurance_spendable_raw() 1231 | .saturating_sub(self.warmup_insurance_reserved) 1232 | } 1233 | 1234 | /// Returns remaining warmup budget for converting positive PnL to capital 1235 | /// Budget = max(0, warmed_neg_total + unreserved_spendable_insurance - warmed_pos_total) 1236 | #[inline] 1237 | pub fn warmup_budget_remaining(&self) -> u128 { 1238 | let rhs = self 1239 | .warmed_neg_total 1240 | .saturating_add(self.insurance_spendable_unreserved()); 1241 | rhs.saturating_sub(self.warmed_pos_total) 1242 | } 1243 | 1244 | /// Settle warmup: convert PnL to capital with global budget constraint 1245 | /// 1246 | /// This function settles matured PnL into capital: 1247 | /// - Negative PnL: reduces capital (losses paid from principal) 1248 | /// - Positive PnL: increases capital (profits become principal, clamped by budget) 1249 | /// 1250 | /// The warmup budget invariant ensures: 1251 | /// warmed_pos_total <= warmed_neg_total + insurance_spendable_unreserved() 1252 | pub fn settle_warmup_to_capital(&mut self, idx: u16) -> Result<()> { 1253 | if !self.is_used(idx as usize) { 1254 | return Err(RiskError::AccountNotFound); 1255 | } 1256 | 1257 | // 3.1 Compute per-account warmup capacity with pause semantics 1258 | let effective_slot = if self.warmup_paused { 1259 | core::cmp::min(self.current_slot, self.warmup_pause_slot) 1260 | } else { 1261 | self.current_slot 1262 | }; 1263 | 1264 | let started_at = self.accounts[idx as usize].warmup_started_at_slot; 1265 | let elapsed = effective_slot.saturating_sub(started_at); 1266 | let slope = self.accounts[idx as usize].warmup_slope_per_step; 1267 | let mut cap = mul_u128(slope, elapsed as u128); 1268 | 1269 | // 3.2 Settle losses first (negative PnL → reduce capital) 1270 | let pnl = self.accounts[idx as usize].pnl; 1271 | if pnl < 0 { 1272 | let need = (-pnl) as u128; 1273 | let capital = self.accounts[idx as usize].capital; 1274 | let x = core::cmp::min(cap, core::cmp::min(need, capital)); 1275 | 1276 | if x > 0 { 1277 | self.accounts[idx as usize].pnl = 1278 | self.accounts[idx as usize].pnl.saturating_add(x as i128); 1279 | self.accounts[idx as usize].capital = sub_u128(capital, x); 1280 | self.warmed_neg_total = add_u128(self.warmed_neg_total, x); 1281 | cap = cap.saturating_sub(x); 1282 | } 1283 | } 1284 | 1285 | // 3.3 Compute budget from losses (how much positive PnL can warm without insurance) 1286 | let losses_budget = self.warmed_neg_total.saturating_sub(self.warmed_pos_total); 1287 | 1288 | // 3.4 Settle gains with budget clamp (positive PnL → increase capital) 1289 | let pnl = self.accounts[idx as usize].pnl; 1290 | if pnl > 0 && cap > 0 { 1291 | let positive_pnl = pnl as u128; 1292 | let reserved = self.accounts[idx as usize].reserved_pnl; 1293 | let avail = positive_pnl.saturating_sub(reserved); 1294 | 1295 | if avail > 0 { 1296 | let budget = self.warmup_budget_remaining(); 1297 | let x = core::cmp::min(cap, core::cmp::min(avail, budget)); 1298 | 1299 | if x > 0 { 1300 | // Portion of x that is not covered by matured-loss budget must be backed by insurance. 1301 | // Reserve that insurance so it cannot later be spent in ADL. 1302 | let covered_by_losses = core::cmp::min(x, losses_budget); 1303 | let needs_insurance = x - covered_by_losses; 1304 | 1305 | if needs_insurance > 0 { 1306 | // Reserve from unreserved spendable insurance (must be available by construction of x) 1307 | self.warmup_insurance_reserved = self 1308 | .warmup_insurance_reserved 1309 | .saturating_add(needs_insurance); 1310 | } 1311 | 1312 | self.accounts[idx as usize].pnl = 1313 | self.accounts[idx as usize].pnl.saturating_sub(x as i128); 1314 | self.accounts[idx as usize].capital = 1315 | add_u128(self.accounts[idx as usize].capital, x); 1316 | self.warmed_pos_total = add_u128(self.warmed_pos_total, x); 1317 | } 1318 | } 1319 | } 1320 | 1321 | // 3.5 Always advance start marker to prevent double-settling the same matured amount. 1322 | // This is safe even when paused: effective_slot==warmup_pause_slot, so further elapsed==0. 1323 | self.accounts[idx as usize].warmup_started_at_slot = effective_slot; 1324 | 1325 | // 3.6 Hard invariant assert in debug/kani 1326 | // W+ ≤ W- + raw_spendable (reserved insurance backs warmed profits) 1327 | // Also: reserved ≤ raw_spendable (can't reserve more than exists) 1328 | // Also: insurance >= floor + reserved (reserved portion protected) 1329 | #[cfg(any(test, kani))] 1330 | { 1331 | let raw = self.insurance_spendable_raw(); 1332 | let floor = self.params.risk_reduction_threshold; 1333 | debug_assert!( 1334 | self.warmed_pos_total <= self.warmed_neg_total.saturating_add(raw), 1335 | "Warmup budget invariant violated: W+ > W- + raw" 1336 | ); 1337 | debug_assert!( 1338 | self.warmup_insurance_reserved <= raw, 1339 | "Reserved exceeds raw spendable" 1340 | ); 1341 | debug_assert!( 1342 | self.insurance_fund.balance >= floor.saturating_add(self.warmup_insurance_reserved), 1343 | "Insurance fell below floor+reserved" 1344 | ); 1345 | } 1346 | 1347 | Ok(()) 1348 | } 1349 | 1350 | /// Apply ADL haircut using two-pass bitmap scan (stack-safe, no caching) 1351 | /// 1352 | /// Pass 1: Compute total unwrapped PNL across all accounts 1353 | /// Pass 2: Recompute each account's unwrapped PNL and apply proportional haircut 1354 | /// 1355 | /// Waterfall: unwrapped PNL first, then insurance fund, then loss_accum 1356 | pub fn apply_adl(&mut self, total_loss: u128) -> Result<()> { 1357 | // ADL reduces risk (allowed in risk mode) 1358 | self.enforce_op(OpClass::RiskReduce)?; 1359 | 1360 | if total_loss == 0 { 1361 | return Ok(()); 1362 | } 1363 | 1364 | // Pass 1: Compute total unwrapped PNL (no caching - deterministic recomputation) 1365 | let mut total_unwrapped = 0u128; 1366 | 1367 | for (block, word) in self.used.iter().copied().enumerate() { 1368 | let mut w = word; 1369 | while w != 0 { 1370 | let bit = w.trailing_zeros() as usize; 1371 | let idx = block * 64 + bit; 1372 | w &= w - 1; 1373 | 1374 | let unwrapped = self.compute_unwrapped_pnl(&self.accounts[idx]); 1375 | total_unwrapped = total_unwrapped.saturating_add(unwrapped); 1376 | } 1377 | } 1378 | 1379 | // Determine how much loss can be socialized via unwrapped PNL 1380 | let loss_to_socialize = core::cmp::min(total_loss, total_unwrapped); 1381 | 1382 | // Pass 2: Apply proportional haircuts by recomputing unwrapped PNL 1383 | if loss_to_socialize > 0 && total_unwrapped > 0 { 1384 | // Must use manual iteration since we need self for compute_unwrapped_pnl 1385 | for block in 0..BITMAP_WORDS { 1386 | let mut w = self.used[block]; 1387 | while w != 0 { 1388 | let bit = w.trailing_zeros() as usize; 1389 | let idx = block * 64 + bit; 1390 | w &= w - 1; 1391 | 1392 | // Recompute unwrapped (deterministic - same value as pass 1) 1393 | let account = &self.accounts[idx]; 1394 | if account.pnl > 0 { 1395 | let unwrapped = self.compute_unwrapped_pnl(account); 1396 | 1397 | if unwrapped > 0 { 1398 | let haircut = (loss_to_socialize * unwrapped) / total_unwrapped; 1399 | self.accounts[idx].pnl = 1400 | self.accounts[idx].pnl.saturating_sub(haircut as i128); 1401 | } 1402 | } 1403 | } 1404 | } 1405 | } 1406 | 1407 | // Handle remaining loss with insurance fund (respecting floor) 1408 | let remaining_loss = total_loss.saturating_sub(loss_to_socialize); 1409 | 1410 | if remaining_loss > 0 { 1411 | // Insurance can only spend unreserved amount above the floor 1412 | let spendable = self.insurance_spendable_unreserved(); 1413 | let spend = core::cmp::min(remaining_loss, spendable); 1414 | 1415 | // Deduct from insurance fund 1416 | self.insurance_fund.balance = sub_u128(self.insurance_fund.balance, spend); 1417 | 1418 | // Any remaining loss goes to loss_accum 1419 | let uncovered = remaining_loss.saturating_sub(spend); 1420 | if uncovered > 0 { 1421 | self.loss_accum = add_u128(self.loss_accum, uncovered); 1422 | } 1423 | 1424 | // Enter risk-reduction-only mode if we've hit the floor or have uncovered losses 1425 | if uncovered > 0 || self.insurance_fund.balance <= self.params.risk_reduction_threshold 1426 | { 1427 | self.enter_risk_reduction_only_mode(); 1428 | } 1429 | } 1430 | 1431 | Ok(()) 1432 | } 1433 | 1434 | // ======================================== 1435 | // Panic Settlement (Atomic Global Settle) 1436 | // ======================================== 1437 | 1438 | /// Atomic global settlement at oracle price 1439 | /// 1440 | /// This is a single-tx emergency instruction that: 1441 | /// 1. Enters risk-reduction-only mode and freezes warmups 1442 | /// 2. Settles all open positions at the given oracle price 1443 | /// 3. Clamps negative PNL to zero and accumulates system loss 1444 | /// 4. Applies ADL to socialize the loss (unwrapped PNL first, then insurance, then loss_accum) 1445 | /// 1446 | /// No funding settlement is performed - this is purely position settlement. 1447 | pub fn panic_settle_all(&mut self, oracle_price: u64) -> Result<()> { 1448 | // Panic settle is a risk-reducing operation 1449 | self.enforce_op(OpClass::RiskReduce)?; 1450 | 1451 | // Always enter risk-reduction-only mode (freezes warmups) 1452 | self.enter_risk_reduction_only_mode(); 1453 | 1454 | // Accumulate total system loss from negative PNL after settlement 1455 | let mut total_loss = 0u128; 1456 | // Track sum of mark PNL to compensate for integer division rounding 1457 | let mut total_mark_pnl: i128 = 0; 1458 | 1459 | // Single pass: settle funding and positions, clamp negative PNL 1460 | let global_funding_index = self.funding_index_qpb_e6; 1461 | for block in 0..BITMAP_WORDS { 1462 | let mut w = self.used[block]; 1463 | while w != 0 { 1464 | let bit = w.trailing_zeros() as usize; 1465 | let idx = block * 64 + bit; 1466 | w &= w - 1; 1467 | 1468 | let account = &mut self.accounts[idx]; 1469 | 1470 | // Settle funding first (required for correct PNL accounting) 1471 | Self::settle_account_funding(account, global_funding_index)?; 1472 | 1473 | // Skip accounts with no position 1474 | if account.position_size == 0 { 1475 | continue; 1476 | } 1477 | 1478 | // Compute mark PNL at oracle price 1479 | let pos = account.position_size; 1480 | let abs_pos = saturating_abs_i128(pos) as u128; 1481 | 1482 | let diff: i128 = if pos > 0 { 1483 | // Long: profit when oracle > entry 1484 | (oracle_price as i128).saturating_sub(account.entry_price as i128) 1485 | } else { 1486 | // Short: profit when entry > oracle 1487 | (account.entry_price as i128).saturating_sub(oracle_price as i128) 1488 | }; 1489 | 1490 | // mark_pnl = diff * abs_pos / 1_000_000 1491 | let mark_pnl = diff 1492 | .checked_mul(abs_pos as i128) 1493 | .ok_or(RiskError::Overflow)? 1494 | .checked_div(1_000_000) 1495 | .ok_or(RiskError::Overflow)?; 1496 | 1497 | // Track total mark PNL for rounding compensation 1498 | total_mark_pnl = total_mark_pnl.saturating_add(mark_pnl); 1499 | 1500 | // Apply mark PNL to account 1501 | account.pnl = account.pnl.saturating_add(mark_pnl); 1502 | 1503 | // Close position 1504 | account.position_size = 0; 1505 | account.entry_price = oracle_price; // Set to oracle for determinism 1506 | 1507 | // Clamp negative PNL and accumulate system loss 1508 | if account.pnl < 0 { 1509 | // Convert negative PNL to system loss 1510 | let loss = (-account.pnl) as u128; 1511 | total_loss = total_loss.saturating_add(loss); 1512 | account.pnl = 0; 1513 | } 1514 | } 1515 | } 1516 | 1517 | // Compensate for non-zero-sum mark PNL. 1518 | // Mark PNL may not sum to zero due to: 1519 | // 1. Integer division rounding slippage 1520 | // 2. Entry price discrepancies from weighted averaging 1521 | // 1522 | // If positive: treat as additional loss to socialize 1523 | // If negative: the vault has "extra" money that should go to insurance 1524 | // to maintain conservation (otherwise slack increases) 1525 | if total_mark_pnl > 0 { 1526 | total_loss = total_loss.saturating_add(total_mark_pnl as u128); 1527 | } else if total_mark_pnl < 0 { 1528 | // Vault has surplus funds - add to insurance to maintain conservation 1529 | let surplus = (-total_mark_pnl) as u128; 1530 | self.insurance_fund.balance = add_u128(self.insurance_fund.balance, surplus); 1531 | } 1532 | 1533 | // Socialize the accumulated loss via ADL waterfall BEFORE settle_warmup 1534 | // This allows apply_adl to haircut positive PNL before it gets converted to capital 1535 | if total_loss > 0 { 1536 | self.apply_adl(total_loss)?; 1537 | } 1538 | 1539 | // Second pass: settle warmup for all used accounts after ADL 1540 | // This converts any remaining positive PNL to capital with proper budget tracking 1541 | for block in 0..BITMAP_WORDS { 1542 | let mut w = self.used[block]; 1543 | while w != 0 { 1544 | let bit = w.trailing_zeros() as usize; 1545 | let idx = block * 64 + bit; 1546 | w &= w - 1; 1547 | 1548 | // settle_warmup_to_capital handles the budget invariant 1549 | self.settle_warmup_to_capital(idx as u16)?; 1550 | } 1551 | } 1552 | 1553 | Ok(()) 1554 | } 1555 | 1556 | /// Force realize losses to unstick the exchange at insurance floor 1557 | /// 1558 | /// When insurance is at/below the threshold, the exchange can get "stuck" 1559 | /// because positive PnL cannot warm (no budget). This instruction forces 1560 | /// loss realization which increases warmed_neg_total, creating budget for 1561 | /// positive PnL to warm and withdrawals to proceed. 1562 | /// 1563 | /// This instruction: 1564 | /// 1. Requires insurance_fund.balance <= risk_reduction_threshold 1565 | /// 2. Enters risk_reduction_only mode and freezes warmup 1566 | /// 3. Scans all accounts with positions and realizes mark PnL at oracle_price 1567 | /// 4. For losers: pays losses from capital, incrementing warmed_neg_total 1568 | /// 5. Does NOT warm any positive PnL (keeps it young, subject to ADL) 1569 | /// 6. Unpaid losses (capital exhausted) go through apply_adl waterfall 1570 | pub fn force_realize_losses(&mut self, oracle_price: u64) -> Result<()> { 1571 | // Force realize is a risk-reducing operation 1572 | self.enforce_op(OpClass::RiskReduce)?; 1573 | 1574 | // Gate: only allowed when insurance is at or below floor 1575 | if self.insurance_fund.balance > self.params.risk_reduction_threshold { 1576 | return Err(RiskError::Unauthorized); 1577 | } 1578 | 1579 | // Enter risk-reduction-only mode (freezes warmups) 1580 | self.enter_risk_reduction_only_mode(); 1581 | 1582 | // Accumulate unpaid losses (when capital is exhausted) 1583 | let mut total_unpaid_loss = 0u128; 1584 | // Track sum of mark PNL for rounding compensation 1585 | let mut total_mark_pnl: i128 = 0; 1586 | 1587 | // Single pass: settle funding, realize mark PnL, and settle negative PnL into capital 1588 | let global_funding_index = self.funding_index_qpb_e6; 1589 | for block in 0..BITMAP_WORDS { 1590 | let mut w = self.used[block]; 1591 | while w != 0 { 1592 | let bit = w.trailing_zeros() as usize; 1593 | let idx = block * 64 + bit; 1594 | w &= w - 1; 1595 | 1596 | let account = &mut self.accounts[idx]; 1597 | 1598 | // Settle funding first (required for correct PNL accounting) 1599 | Self::settle_account_funding(account, global_funding_index)?; 1600 | 1601 | // Skip accounts with no position 1602 | if account.position_size == 0 { 1603 | continue; 1604 | } 1605 | 1606 | // Compute mark PNL at oracle price 1607 | let pos = account.position_size; 1608 | let abs_pos = saturating_abs_i128(pos) as u128; 1609 | 1610 | let diff: i128 = if pos > 0 { 1611 | // Long: profit when oracle > entry 1612 | (oracle_price as i128).saturating_sub(account.entry_price as i128) 1613 | } else { 1614 | // Short: profit when entry > oracle 1615 | (account.entry_price as i128).saturating_sub(oracle_price as i128) 1616 | }; 1617 | 1618 | // mark_pnl = diff * abs_pos / 1_000_000 1619 | let mark_pnl = diff 1620 | .checked_mul(abs_pos as i128) 1621 | .ok_or(RiskError::Overflow)? 1622 | .checked_div(1_000_000) 1623 | .ok_or(RiskError::Overflow)?; 1624 | 1625 | // Track total mark PNL for rounding compensation 1626 | total_mark_pnl = total_mark_pnl.saturating_add(mark_pnl); 1627 | 1628 | // Apply mark PNL to account 1629 | account.pnl = account.pnl.saturating_add(mark_pnl); 1630 | 1631 | // Close position 1632 | account.position_size = 0; 1633 | account.entry_price = oracle_price; 1634 | 1635 | // Force settle losses only (not positive PnL) 1636 | if account.pnl < 0 { 1637 | let need = (-account.pnl) as u128; 1638 | let pay = core::cmp::min(need, account.capital); 1639 | 1640 | // Pay from capital 1641 | account.capital = sub_u128(account.capital, pay); 1642 | account.pnl = account.pnl.saturating_add(pay as i128); // toward 0 1643 | 1644 | // Track in warmed_neg_total (losses realized) 1645 | self.warmed_neg_total = add_u128(self.warmed_neg_total, pay); 1646 | 1647 | // Accumulate unpaid portion (capital exhausted) 1648 | if need > pay { 1649 | let unpaid = need - pay; 1650 | total_unpaid_loss = total_unpaid_loss.saturating_add(unpaid); 1651 | // Clamp remaining negative PnL to zero 1652 | account.pnl = 0; 1653 | } 1654 | } 1655 | // Positive PnL is left as-is (young, subject to ADL, warmup frozen) 1656 | 1657 | // Update warmup start marker to effective_slot to prevent later 1658 | // settle_warmup_to_capital() from "re-paying" based on old elapsed time. 1659 | // Since we called enter_risk_reduction_only_mode(), warmup is paused, 1660 | // so effective_slot = warmup_pause_slot. 1661 | let effective_slot = core::cmp::min(self.current_slot, self.warmup_pause_slot); 1662 | account.warmup_started_at_slot = effective_slot; 1663 | } 1664 | } 1665 | 1666 | // Compensate for non-zero-sum mark PNL. 1667 | // If positive: treat as additional unpaid loss to socialize 1668 | // If negative: the vault has "extra" money that should go to insurance 1669 | if total_mark_pnl > 0 { 1670 | total_unpaid_loss = total_unpaid_loss.saturating_add(total_mark_pnl as u128); 1671 | } else if total_mark_pnl < 0 { 1672 | // Vault has surplus funds - add to insurance to maintain conservation 1673 | let surplus = (-total_mark_pnl) as u128; 1674 | self.insurance_fund.balance = add_u128(self.insurance_fund.balance, surplus); 1675 | } 1676 | 1677 | // Socialize any unpaid losses via ADL waterfall 1678 | if total_unpaid_loss > 0 { 1679 | self.apply_adl(total_unpaid_loss)?; 1680 | } 1681 | 1682 | Ok(()) 1683 | } 1684 | 1685 | /// Top up insurance fund to cover losses 1686 | pub fn top_up_insurance_fund(&mut self, amount: u128) -> Result { 1687 | // Insurance top-ups reduce risk (allowed in risk mode) 1688 | self.enforce_op(OpClass::RiskReduce)?; 1689 | 1690 | // Add to vault 1691 | self.vault = add_u128(self.vault, amount); 1692 | 1693 | // Apply contribution to loss_accum first (if any) 1694 | if self.loss_accum > 0 { 1695 | let loss_coverage = core::cmp::min(amount, self.loss_accum); 1696 | self.loss_accum = sub_u128(self.loss_accum, loss_coverage); 1697 | let remaining = sub_u128(amount, loss_coverage); 1698 | 1699 | // Add remaining to insurance fund balance 1700 | self.insurance_fund.balance = add_u128(self.insurance_fund.balance, remaining); 1701 | 1702 | // Exit risk-reduction-only mode if loss is fully covered and above threshold 1703 | let was_in_mode = self.risk_reduction_only; 1704 | self.exit_risk_reduction_only_mode_if_safe(); 1705 | if was_in_mode && !self.risk_reduction_only { 1706 | Ok(true) // Exited risk-reduction-only mode 1707 | } else { 1708 | Ok(false) // Still in risk-reduction-only mode 1709 | } 1710 | } else { 1711 | // No loss - just add to insurance fund 1712 | self.insurance_fund.balance = add_u128(self.insurance_fund.balance, amount); 1713 | 1714 | // Check if we can exit risk-reduction mode (may have been triggered by threshold, not loss) 1715 | let was_in_mode = self.risk_reduction_only; 1716 | self.exit_risk_reduction_only_mode_if_safe(); 1717 | if was_in_mode && !self.risk_reduction_only { 1718 | Ok(true) // Exited risk-reduction-only mode 1719 | } else { 1720 | Ok(false) 1721 | } 1722 | } 1723 | } 1724 | 1725 | // ======================================== 1726 | // Utilities 1727 | // ======================================== 1728 | 1729 | /// Check conservation invariant (I2) 1730 | /// 1731 | /// Conservation formula: vault + loss_accum = sum(capital) + sum(pnl) + insurance_fund.balance 1732 | /// 1733 | /// This accounts for: 1734 | /// - Deposits add to both vault and capital 1735 | /// - Withdrawals subtract from both vault and capital 1736 | /// - Trading PNL is zero-sum between counterparties 1737 | /// - Trading fees transfer from user PNL to insurance fund (net zero) 1738 | /// - ADL transfers from user PNL to cover losses (net zero within system) 1739 | /// - loss_accum represents value that was "lost" from the vault (clamped negative PNL 1740 | /// that couldn't be socialized), so vault + loss_accum = original value 1741 | /// 1742 | /// # Rounding Slack 1743 | /// 1744 | /// We require `actual >= expected` (vault has at least what is owed) and 1745 | /// `(actual - expected) <= MAX_ROUNDING_SLACK` (bounded dust). Funding payments 1746 | /// are rounded UP when accounts pay, ensuring the vault never has less than 1747 | /// what's owed. The bounded dust check catches accidental minting bugs. 1748 | pub fn check_conservation(&self) -> bool { 1749 | let mut total_capital = 0u128; 1750 | let mut net_pnl: i128 = 0; 1751 | let global_index = self.funding_index_qpb_e6; 1752 | 1753 | self.for_each_used(|_idx, account| { 1754 | total_capital = add_u128(total_capital, account.capital); 1755 | 1756 | // Compute "would-be settled" PNL for this account 1757 | // This accounts for lazy funding settlement with same rounding as settle_account_funding 1758 | let mut settled_pnl = account.pnl; 1759 | if account.position_size != 0 { 1760 | let delta_f = global_index.saturating_sub(account.funding_index); 1761 | if delta_f != 0 { 1762 | // payment = position × ΔF / 1e6 1763 | // Round UP for positive (account pays), truncate for negative (account receives) 1764 | let raw = account.position_size.saturating_mul(delta_f); 1765 | let payment = if raw > 0 { 1766 | raw.saturating_add(999_999).saturating_div(1_000_000) 1767 | } else { 1768 | raw.saturating_div(1_000_000) 1769 | }; 1770 | settled_pnl = settled_pnl.saturating_sub(payment); 1771 | } 1772 | } 1773 | net_pnl = net_pnl.saturating_add(settled_pnl); 1774 | }); 1775 | 1776 | // Conservation formula: 1777 | // vault + loss_accum >= sum(capital) + sum(settled_pnl) + insurance 1778 | // 1779 | // Where: 1780 | // - loss_accum: value that "left" the system (unrecoverable losses) 1781 | // - settled_pnl: pnl after accounting for unsettled funding 1782 | // 1783 | // Funding payments are rounded UP when accounts pay, so the vault always has 1784 | // at least what's owed. The slack (dust) is bounded by MAX_ROUNDING_SLACK. 1785 | let base = add_u128(total_capital, self.insurance_fund.balance); 1786 | 1787 | let expected = if net_pnl >= 0 { 1788 | add_u128(base, net_pnl as u128) 1789 | } else { 1790 | base.saturating_sub((-net_pnl) as u128) 1791 | }; 1792 | 1793 | let actual = add_u128(self.vault, self.loss_accum); 1794 | 1795 | // One-sided conservation check: 1796 | // actual >= expected (vault has at least what is owed) 1797 | // (actual - expected) <= MAX_ROUNDING_SLACK (bounded dust) 1798 | if actual < expected { 1799 | return false; 1800 | } 1801 | let slack = actual - expected; 1802 | slack <= MAX_ROUNDING_SLACK 1803 | } 1804 | 1805 | /// Advance to next slot (for testing warmup) 1806 | pub fn advance_slot(&mut self, slots: u64) { 1807 | self.current_slot = self.current_slot.saturating_add(slots); 1808 | } 1809 | } 1810 | -------------------------------------------------------------------------------- /tests/fuzzing.rs: -------------------------------------------------------------------------------- 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 + loss_accum >= sum(capital) + sum(settled_pnl) + insurance 21 | //! 22 | //! Where settled_pnl accounts for lazy funding: 23 | //! settled_pnl = account.pnl - (global_funding_index - account.funding_index) * position / 1e6 24 | //! 25 | //! Slack rule: actual >= expected, and (actual - expected) <= MAX_ROUNDING_SLACK 26 | //! This ensures vault has at least what is owed, with bounded dust. 27 | //! 28 | //! ### Warmup Budget 29 | //! - W+ <= W- + raw_spendable (positive warmup bounded by losses + available insurance) 30 | //! - reserved <= raw_spendable (reservations backed by insurance) 31 | //! 32 | //! ## Suite Components 33 | //! - Global invariants (conservation, warmup budget, risk reduction mode) 34 | //! - Action-based state machine fuzzer with Solana rollback simulation 35 | //! - Focused unit property tests 36 | //! - Deterministic seeded fuzzer with logging 37 | 38 | #![cfg(feature = "fuzz")] 39 | 40 | use percolator::*; 41 | use proptest::prelude::*; 42 | 43 | // ============================================================================ 44 | // CONSTANTS AND MATCHER 45 | // ============================================================================ 46 | 47 | const MATCHER: NoOpMatcher = NoOpMatcher; 48 | 49 | // ============================================================================ 50 | // SECTION 1: HELPER FUNCTIONS 51 | // ============================================================================ 52 | 53 | /// Helper to check if an account slot is used by accessing the used bitmap 54 | fn is_account_used(engine: &RiskEngine, idx: u16) -> bool { 55 | let idx = idx as usize; 56 | if idx >= engine.accounts.len() { 57 | return false; 58 | } 59 | // Access the used bitmap directly: used[w] bit b 60 | let w = idx >> 6; // word index (idx / 64) 61 | let b = idx & 63; // bit index (idx % 64) 62 | if w >= engine.used.len() { 63 | return false; 64 | } 65 | ((engine.used[w] >> b) & 1) == 1 66 | } 67 | 68 | /// Helper to get the safe upper bound for account iteration 69 | #[inline] 70 | fn account_count(engine: &RiskEngine) -> usize { 71 | core::cmp::min(engine.params.max_accounts as usize, engine.accounts.len()) 72 | } 73 | 74 | /// Compute funding payment with vault-favoring rounding. 75 | /// Round UP when account pays (raw > 0), truncate when account receives (raw < 0). 76 | /// This matches the engine's settle_account_funding and ensures one-sided conservation. 77 | #[inline] 78 | fn funding_payment(position: i128, delta_f: i128) -> i128 { 79 | let raw = position.saturating_mul(delta_f); 80 | if raw > 0 { 81 | raw.saturating_add(999_999).saturating_div(1_000_000) 82 | } else { 83 | raw.saturating_div(1_000_000) 84 | } 85 | } 86 | 87 | // ============================================================================ 88 | // SECTION 2: GLOBAL INVARIANTS HELPER 89 | // ============================================================================ 90 | 91 | /// Assert all global invariants hold 92 | /// IMPORTANT: This function is PURE - it does NOT mutate the engine. 93 | /// Invariant checks must reflect on-chain semantics (funding is lazy). 94 | fn assert_global_invariants(engine: &RiskEngine, context: &str) { 95 | // 1. Conservation 96 | // Note: check_conservation now accounts for lazy funding internally 97 | if !engine.check_conservation() { 98 | // Compute details for debugging (using settled PNL like check_conservation does) 99 | let mut total_capital = 0u128; 100 | let mut net_settled_pnl: i128 = 0; 101 | let mut account_details = Vec::new(); 102 | let global_index = engine.funding_index_qpb_e6; 103 | 104 | let n = account_count(engine); 105 | for i in 0..n { 106 | if is_account_used(engine, i as u16) { 107 | let acc = &engine.accounts[i]; 108 | total_capital += acc.capital; 109 | 110 | // Compute settled PNL using shared helper (matches engine rounding) 111 | let mut settled_pnl = acc.pnl; 112 | if acc.position_size != 0 { 113 | let delta_f = global_index.saturating_sub(acc.funding_index); 114 | if delta_f != 0 { 115 | let payment = funding_payment(acc.position_size, delta_f); 116 | settled_pnl = settled_pnl.saturating_sub(payment); 117 | } 118 | } 119 | net_settled_pnl = net_settled_pnl.saturating_add(settled_pnl); 120 | 121 | account_details.push(format!( 122 | " acc[{}]: capital={}, pnl={}, settled_pnl={}, pos={}, fidx={}", 123 | i, acc.capital, acc.pnl, settled_pnl, acc.position_size, acc.funding_index 124 | )); 125 | } 126 | } 127 | let base = total_capital + engine.insurance_fund.balance; 128 | let expected = if net_settled_pnl >= 0 { 129 | base + net_settled_pnl as u128 130 | } else { 131 | base.saturating_sub((-net_settled_pnl) as u128) 132 | }; 133 | let actual = engine.vault + engine.loss_accum; 134 | 135 | let slack: i128 = if actual >= expected { 136 | (actual - expected) as i128 137 | } else { 138 | -((expected - actual) as i128) 139 | }; 140 | panic!( 141 | "{}: Conservation invariant violated!\n\ 142 | vault={}, loss_accum={}, actual={}\n\ 143 | total_capital={}, insurance={}, net_settled_pnl={}, expected={}\n\ 144 | global_funding_index={}, slack={}\n\ 145 | Accounts:\n{}", 146 | context, 147 | engine.vault, 148 | engine.loss_accum, 149 | actual, 150 | total_capital, 151 | engine.insurance_fund.balance, 152 | net_settled_pnl, 153 | expected, 154 | global_index, 155 | slack, 156 | account_details.join("\n") 157 | ); 158 | } 159 | 160 | // 2. Warmup budget & reservation invariants 161 | let raw_spendable = engine.insurance_spendable_raw(); 162 | 163 | // W+ <= W- + raw_spendable 164 | assert!( 165 | engine.warmed_pos_total <= engine.warmed_neg_total.saturating_add(raw_spendable), 166 | "{}: Warmup budget invariant violated: W+={} > W-={} + raw_spendable={}", 167 | context, 168 | engine.warmed_pos_total, 169 | engine.warmed_neg_total, 170 | raw_spendable 171 | ); 172 | 173 | // reserved <= raw_spendable 174 | assert!( 175 | engine.warmup_insurance_reserved <= raw_spendable, 176 | "{}: Reserved {} exceeds raw_spendable {}", 177 | context, 178 | engine.warmup_insurance_reserved, 179 | raw_spendable 180 | ); 181 | 182 | // insurance_balance >= floor + reserved (with rounding tolerance) 183 | let floor = engine.params.risk_reduction_threshold; 184 | let min_balance = floor.saturating_add(engine.warmup_insurance_reserved); 185 | // Allow 1 unit rounding tolerance 186 | assert!( 187 | engine.insurance_fund.balance + 1 >= min_balance, 188 | "{}: Insurance {} below floor+reserved={}", 189 | context, 190 | engine.insurance_fund.balance, 191 | min_balance 192 | ); 193 | 194 | // 3. Risk reduction mode semantics 195 | if engine.risk_reduction_only { 196 | assert!( 197 | engine.warmup_paused, 198 | "{}: risk_reduction_only=true but warmup_paused=false", 199 | context 200 | ); 201 | } 202 | 203 | if engine.warmup_paused { 204 | assert!( 205 | engine.warmup_pause_slot <= engine.current_slot, 206 | "{}: warmup_pause_slot {} > current_slot {}", 207 | context, 208 | engine.warmup_pause_slot, 209 | engine.current_slot 210 | ); 211 | } 212 | 213 | // 4. Account local sanity (for each used account) 214 | let n = account_count(engine); 215 | for i in 0..n { 216 | if is_account_used(engine, i as u16) { 217 | let acc = &engine.accounts[i]; 218 | 219 | // reserved_pnl <= max(0, pnl) 220 | let positive_pnl = if acc.pnl > 0 { acc.pnl as u128 } else { 0 }; 221 | assert!( 222 | acc.reserved_pnl <= positive_pnl, 223 | "{}: Account {} has reserved_pnl={} > positive_pnl={}", 224 | context, 225 | i, 226 | acc.reserved_pnl, 227 | positive_pnl 228 | ); 229 | } 230 | } 231 | } 232 | 233 | // ============================================================================ 234 | // SECTION 3: PARAMETER REGIMES 235 | // ============================================================================ 236 | 237 | /// Regime A: Normal mode (floor = 0 or small) 238 | fn params_regime_a() -> RiskParams { 239 | RiskParams { 240 | warmup_period_slots: 100, 241 | maintenance_margin_bps: 500, 242 | initial_margin_bps: 1000, 243 | trading_fee_bps: 10, 244 | max_accounts: 32, // Small for speed 245 | account_fee_bps: 10000, 246 | risk_reduction_threshold: 0, 247 | } 248 | } 249 | 250 | /// Regime B: Floor + risk mode sensitivity (floor = 1000) 251 | fn params_regime_b() -> RiskParams { 252 | RiskParams { 253 | warmup_period_slots: 100, 254 | maintenance_margin_bps: 500, 255 | initial_margin_bps: 1000, 256 | trading_fee_bps: 10, 257 | max_accounts: 32, // Small for speed 258 | account_fee_bps: 10000, 259 | risk_reduction_threshold: 1000, 260 | } 261 | } 262 | 263 | // ============================================================================ 264 | // SECTION 4: SELECTOR-BASED ACTION ENUM AND STRATEGIES 265 | // ============================================================================ 266 | 267 | /// Index selector - resolved at runtime against live state 268 | /// This allows proptest to generate meaningful action sequences 269 | /// even though it can't see runtime state during strategy generation. 270 | #[derive(Clone, Debug)] 271 | enum IdxSel { 272 | /// Pick any account from live_accounts (fallback to Random if empty) 273 | Existing, 274 | /// Pick an account that is NOT the LP (fallback to Random if impossible) 275 | ExistingNonLp, 276 | /// Use the LP index (fallback to 0 if no LP) 277 | Lp, 278 | /// Random index 0..64 (to test AccountNotFound paths) 279 | Random(u16), 280 | } 281 | 282 | /// Actions use selectors instead of concrete indices 283 | /// Selectors are resolved at runtime in execute() 284 | #[derive(Clone, Debug)] 285 | enum Action { 286 | AddUser { 287 | fee_payment: u128, 288 | }, 289 | AddLp { 290 | fee_payment: u128, 291 | }, 292 | Deposit { 293 | who: IdxSel, 294 | amount: u128, 295 | }, 296 | Withdraw { 297 | who: IdxSel, 298 | amount: u128, 299 | }, 300 | AdvanceSlot { 301 | dt: u64, 302 | }, 303 | AccrueFunding { 304 | dt: u64, 305 | oracle_price: u64, 306 | rate_bps: i64, 307 | }, 308 | Touch { 309 | who: IdxSel, 310 | }, 311 | ExecuteTrade { 312 | lp: IdxSel, 313 | user: IdxSel, 314 | oracle_price: u64, 315 | size: i128, 316 | }, 317 | // Note: ApplyAdl removed - it's internal and tested via PanicSettleAll/ForceRealizeLosses 318 | PanicSettleAll { 319 | oracle_price: u64, 320 | }, 321 | ForceRealizeLosses { 322 | oracle_price: u64, 323 | }, 324 | TopUpInsurance { 325 | amount: u128, 326 | }, 327 | } 328 | 329 | /// Strategy for generating index selectors 330 | /// Weights: Existing=6, ExistingNonLp=2, Lp=1, Random=2 331 | /// This ensures most actions target valid accounts while still testing error paths 332 | fn idx_sel_strategy() -> impl Strategy { 333 | prop_oneof![ 334 | 6 => Just(IdxSel::Existing), 335 | 2 => Just(IdxSel::ExistingNonLp), 336 | 1 => Just(IdxSel::Lp), 337 | 2 => (0u16..64).prop_map(IdxSel::Random), 338 | ] 339 | } 340 | 341 | /// Strategy for generating actions 342 | /// Actions use selectors that are resolved at runtime 343 | fn action_strategy() -> impl Strategy { 344 | prop_oneof![ 345 | // Account creation 346 | 2 => (1u128..100).prop_map(|fee| Action::AddUser { fee_payment: fee }), 347 | 1 => (1u128..100).prop_map(|fee| Action::AddLp { fee_payment: fee }), 348 | // Deposits/Withdrawals 349 | 10 => (idx_sel_strategy(), 0u128..50_000).prop_map(|(who, amount)| Action::Deposit { who, amount }), 350 | 5 => (idx_sel_strategy(), 0u128..50_000).prop_map(|(who, amount)| Action::Withdraw { who, amount }), 351 | // Time advancement 352 | 5 => (0u64..10).prop_map(|dt| Action::AdvanceSlot { dt }), 353 | // Funding 354 | 3 => (1u64..50, 100_000u64..10_000_000, -100i64..100).prop_map(|(dt, price, rate)| { 355 | Action::AccrueFunding { dt, oracle_price: price, rate_bps: rate } 356 | }), 357 | // Touch account 358 | 5 => idx_sel_strategy().prop_map(|who| Action::Touch { who }), 359 | // Trades (LP vs non-LP user) 360 | 8 => (100_000u64..10_000_000, -5_000i128..5_000).prop_map(|(oracle_price, size)| { 361 | Action::ExecuteTrade { lp: IdxSel::Lp, user: IdxSel::ExistingNonLp, oracle_price, size } 362 | }), 363 | // Panic settle 364 | 1 => (100_000u64..10_000_000).prop_map(|price| Action::PanicSettleAll { oracle_price: price }), 365 | // Force realize 366 | 1 => (100_000u64..10_000_000).prop_map(|price| Action::ForceRealizeLosses { oracle_price: price }), 367 | // Top up insurance 368 | 2 => (0u128..10_000).prop_map(|amount| Action::TopUpInsurance { amount }), 369 | ] 370 | } 371 | 372 | // ============================================================================ 373 | // SECTION 5: STATE MACHINE FUZZER 374 | // ============================================================================ 375 | 376 | /// State for tracking the fuzzer 377 | struct FuzzState { 378 | engine: Box, 379 | live_accounts: Vec, 380 | lp_idx: Option, 381 | account_ids: Vec, // Track allocated account IDs for uniqueness 382 | rng_state: u64, // For deterministic selector resolution 383 | } 384 | 385 | impl FuzzState { 386 | fn new(params: RiskParams) -> Self { 387 | FuzzState { 388 | engine: Box::new(RiskEngine::new(params)), 389 | live_accounts: Vec::new(), 390 | lp_idx: None, 391 | account_ids: Vec::new(), 392 | rng_state: 12345, 393 | } 394 | } 395 | 396 | /// Simple deterministic RNG for selector resolution 397 | fn next_rng(&mut self) -> u64 { 398 | self.rng_state ^= self.rng_state << 13; 399 | self.rng_state ^= self.rng_state >> 7; 400 | self.rng_state ^= self.rng_state << 17; 401 | self.rng_state 402 | } 403 | 404 | /// Resolve an index selector to a concrete index 405 | fn resolve_selector(&mut self, sel: &IdxSel) -> u16 { 406 | match sel { 407 | IdxSel::Existing => { 408 | if self.live_accounts.is_empty() { 409 | // Fallback to random 410 | (self.next_rng() % 64) as u16 411 | } else { 412 | let idx = self.next_rng() as usize % self.live_accounts.len(); 413 | self.live_accounts[idx] 414 | } 415 | } 416 | IdxSel::ExistingNonLp => { 417 | // Single-pass selection to avoid Vec allocation: 418 | // 1. Count non-LP accounts 419 | // 2. Pick kth candidate 420 | let count = self 421 | .live_accounts 422 | .iter() 423 | .filter(|&&x| Some(x) != self.lp_idx) 424 | .count(); 425 | if count == 0 { 426 | // Fallback to random different from LP 427 | let mut idx = (self.next_rng() % 64) as u16; 428 | if Some(idx) == self.lp_idx && idx < 63 { 429 | idx += 1; 430 | } 431 | idx 432 | } else { 433 | let k = self.next_rng() as usize % count; 434 | self.live_accounts 435 | .iter() 436 | .copied() 437 | .filter(|&x| Some(x) != self.lp_idx) 438 | .nth(k) 439 | .unwrap_or(0) 440 | } 441 | } 442 | IdxSel::Lp => self.lp_idx.unwrap_or(0), 443 | IdxSel::Random(idx) => *idx, 444 | } 445 | } 446 | 447 | /// Execute an action and verify invariants 448 | /// Simulates Solana atomicity: clone before, restore on Err, only assert invariants on Ok 449 | fn execute(&mut self, action: &Action, step: usize) { 450 | let context = format!("Step {} ({:?})", step, action); 451 | 452 | match action { 453 | Action::AddUser { fee_payment } => { 454 | // Snapshot engine and harness state for rollback 455 | let before = (*self.engine).clone(); 456 | let live_before = self.live_accounts.clone(); 457 | let ids_before = self.account_ids.clone(); 458 | let num_used_before = self.count_used(); 459 | let next_id_before = self.engine.next_account_id; 460 | 461 | let result = self.engine.add_user(*fee_payment); 462 | 463 | match result { 464 | Ok(idx) => { 465 | // Postconditions for Ok 466 | assert!( 467 | is_account_used(&self.engine, idx), 468 | "{}: account not marked used", 469 | context 470 | ); 471 | assert_eq!( 472 | self.count_used(), 473 | num_used_before + 1, 474 | "{}: num_used didn't increment", 475 | context 476 | ); 477 | assert_eq!( 478 | self.engine.next_account_id, 479 | next_id_before + 1, 480 | "{}: next_account_id didn't increment", 481 | context 482 | ); 483 | 484 | // Account ID should be unique 485 | let new_id = self.engine.accounts[idx as usize].account_id; 486 | assert!( 487 | !self.account_ids.contains(&new_id), 488 | "{}: duplicate account_id {}", 489 | context, 490 | new_id 491 | ); 492 | self.account_ids.push(new_id); 493 | self.live_accounts.push(idx); 494 | assert_global_invariants(&self.engine, &context); 495 | } 496 | Err(_) => { 497 | // Simulate Solana rollback - restore engine and harness state 498 | *self.engine = before; 499 | self.live_accounts = live_before; 500 | self.account_ids = ids_before; 501 | } 502 | } 503 | } 504 | 505 | Action::AddLp { fee_payment } => { 506 | // Snapshot engine and harness state for rollback 507 | let before = (*self.engine).clone(); 508 | let live_before = self.live_accounts.clone(); 509 | let ids_before = self.account_ids.clone(); 510 | let lp_before = self.lp_idx; 511 | let num_used_before = self.count_used(); 512 | 513 | let result = self.engine.add_lp([0u8; 32], [0u8; 32], *fee_payment); 514 | 515 | match result { 516 | Ok(idx) => { 517 | assert!( 518 | is_account_used(&self.engine, idx), 519 | "{}: LP not marked used", 520 | context 521 | ); 522 | assert_eq!( 523 | self.count_used(), 524 | num_used_before + 1, 525 | "{}: num_used didn't increment", 526 | context 527 | ); 528 | 529 | let new_id = self.engine.accounts[idx as usize].account_id; 530 | assert!( 531 | !self.account_ids.contains(&new_id), 532 | "{}: duplicate LP account_id", 533 | context 534 | ); 535 | self.account_ids.push(new_id); 536 | self.live_accounts.push(idx); 537 | if self.lp_idx.is_none() { 538 | self.lp_idx = Some(idx); 539 | } 540 | assert_global_invariants(&self.engine, &context); 541 | } 542 | Err(_) => { 543 | // Simulate Solana rollback - restore engine and harness state 544 | *self.engine = before; 545 | self.live_accounts = live_before; 546 | self.account_ids = ids_before; 547 | self.lp_idx = lp_before; 548 | } 549 | } 550 | } 551 | 552 | Action::Deposit { who, amount } => { 553 | let idx = self.resolve_selector(who); 554 | let before = (*self.engine).clone(); 555 | let vault_before = self.engine.vault; 556 | 557 | let result = self.engine.deposit(idx, *amount); 558 | 559 | match result { 560 | Ok(()) => { 561 | // vault_after == vault_before + amount 562 | assert_eq!( 563 | self.engine.vault, 564 | vault_before + amount, 565 | "{}: vault didn't increase correctly", 566 | context 567 | ); 568 | assert_global_invariants(&self.engine, &context); 569 | } 570 | Err(_) => { 571 | // Simulate Solana rollback 572 | *self.engine = before; 573 | } 574 | } 575 | } 576 | 577 | Action::Withdraw { who, amount } => { 578 | let idx = self.resolve_selector(who); 579 | let before = (*self.engine).clone(); 580 | let vault_before = self.engine.vault; 581 | 582 | let result = self.engine.withdraw(idx, *amount); 583 | 584 | match result { 585 | Ok(()) => { 586 | // vault_after == vault_before - amount 587 | assert_eq!( 588 | self.engine.vault, 589 | vault_before - amount, 590 | "{}: vault didn't decrease correctly", 591 | context 592 | ); 593 | assert_global_invariants(&self.engine, &context); 594 | } 595 | Err(_) => { 596 | // Simulate Solana rollback 597 | *self.engine = before; 598 | } 599 | } 600 | } 601 | 602 | Action::AdvanceSlot { dt } => { 603 | // advance_slot is infallible - no rollback needed 604 | let slot_before = self.engine.current_slot; 605 | self.engine.advance_slot(*dt); 606 | assert!( 607 | self.engine.current_slot >= slot_before, 608 | "{}: current_slot went backwards", 609 | context 610 | ); 611 | assert_global_invariants(&self.engine, &context); 612 | } 613 | 614 | Action::AccrueFunding { 615 | dt, 616 | oracle_price, 617 | rate_bps, 618 | } => { 619 | let before = (*self.engine).clone(); 620 | let last_slot_before = self.engine.last_funding_slot; 621 | let now_slot = self.engine.current_slot.saturating_add(*dt); 622 | 623 | let result = self 624 | .engine 625 | .accrue_funding(now_slot, *oracle_price, *rate_bps); 626 | 627 | match result { 628 | Ok(()) => { 629 | // Only expect last_funding_slot to update if now_slot > old value 630 | if now_slot > last_slot_before { 631 | assert_eq!( 632 | self.engine.last_funding_slot, now_slot, 633 | "{}: last_funding_slot not updated", 634 | context 635 | ); 636 | } 637 | assert_global_invariants(&self.engine, &context); 638 | } 639 | Err(_) => { 640 | // Simulate Solana rollback 641 | *self.engine = before; 642 | } 643 | } 644 | } 645 | 646 | Action::Touch { who } => { 647 | let idx = self.resolve_selector(who); 648 | let before = (*self.engine).clone(); 649 | 650 | let result = self.engine.touch_account(idx); 651 | 652 | match result { 653 | Ok(()) => { 654 | // funding_index should equal global index 655 | assert_eq!( 656 | self.engine.accounts[idx as usize].funding_index, 657 | self.engine.funding_index_qpb_e6, 658 | "{}: funding_index not synced", 659 | context 660 | ); 661 | assert_global_invariants(&self.engine, &context); 662 | } 663 | Err(_) => { 664 | // Simulate Solana rollback 665 | *self.engine = before; 666 | } 667 | } 668 | } 669 | 670 | Action::ExecuteTrade { 671 | lp, 672 | user, 673 | oracle_price, 674 | size, 675 | } => { 676 | let lp_idx = self.resolve_selector(lp); 677 | let user_idx = self.resolve_selector(user); 678 | 679 | // Skip if LP and user are the same account (invalid trade) 680 | if lp_idx == user_idx { 681 | return; 682 | } 683 | 684 | let before = (*self.engine).clone(); 685 | 686 | let result = 687 | self.engine 688 | .execute_trade(&MATCHER, lp_idx, user_idx, *oracle_price, *size); 689 | 690 | match result { 691 | Ok(_) => { 692 | // Trade succeeded - positions modified, that's fine 693 | assert_global_invariants(&self.engine, &context); 694 | } 695 | Err(_) => { 696 | // Simulate Solana rollback 697 | *self.engine = before; 698 | } 699 | } 700 | } 701 | 702 | Action::PanicSettleAll { oracle_price } => { 703 | let before = (*self.engine).clone(); 704 | 705 | let result = self.engine.panic_settle_all(*oracle_price); 706 | 707 | match result { 708 | Ok(()) => { 709 | // risk_reduction_only should be true 710 | assert!( 711 | self.engine.risk_reduction_only, 712 | "{}: risk_reduction_only not set after panic_settle", 713 | context 714 | ); 715 | // warmup_paused should be true 716 | assert!( 717 | self.engine.warmup_paused, 718 | "{}: warmup_paused not set after panic_settle", 719 | context 720 | ); 721 | // All positions should be 0 - scan ALL used accounts, not just live_accounts 722 | let n = account_count(&self.engine); 723 | for idx in 0..n { 724 | if is_account_used(&self.engine, idx as u16) { 725 | assert_eq!( 726 | self.engine.accounts[idx].position_size, 0, 727 | "{}: position not closed for account {}", 728 | context, idx 729 | ); 730 | } 731 | } 732 | assert_global_invariants(&self.engine, &context); 733 | } 734 | Err(_) => { 735 | // Simulate Solana rollback 736 | *self.engine = before; 737 | } 738 | } 739 | } 740 | 741 | Action::ForceRealizeLosses { oracle_price } => { 742 | let before = (*self.engine).clone(); 743 | 744 | let result = self.engine.force_realize_losses(*oracle_price); 745 | 746 | match result { 747 | Ok(()) => { 748 | // risk_reduction_only and warmup_paused should be true 749 | assert!( 750 | self.engine.risk_reduction_only, 751 | "{}: risk_reduction_only not set after force_realize", 752 | context 753 | ); 754 | assert!( 755 | self.engine.warmup_paused, 756 | "{}: warmup_paused not set after force_realize", 757 | context 758 | ); 759 | // All positions should be 0 - scan ALL used accounts 760 | let n = account_count(&self.engine); 761 | for idx in 0..n { 762 | if is_account_used(&self.engine, idx as u16) { 763 | assert_eq!( 764 | self.engine.accounts[idx].position_size, 0, 765 | "{}: position not closed for account {}", 766 | context, idx 767 | ); 768 | } 769 | } 770 | assert_global_invariants(&self.engine, &context); 771 | } 772 | Err(_) => { 773 | // Simulate Solana rollback 774 | *self.engine = before; 775 | } 776 | } 777 | } 778 | 779 | Action::TopUpInsurance { amount } => { 780 | let before = (*self.engine).clone(); 781 | let vault_before = self.engine.vault; 782 | let loss_accum_before = self.engine.loss_accum; 783 | 784 | let result = self.engine.top_up_insurance_fund(*amount); 785 | 786 | match result { 787 | Ok(exited_risk_mode) => { 788 | // vault should increase 789 | assert_eq!( 790 | self.engine.vault, 791 | vault_before + amount, 792 | "{}: vault didn't increase", 793 | context 794 | ); 795 | // loss_accum should decrease first 796 | if loss_accum_before > 0 { 797 | assert!( 798 | self.engine.loss_accum <= loss_accum_before, 799 | "{}: loss_accum didn't decrease", 800 | context 801 | ); 802 | } 803 | // If exited risk mode, verify conditions 804 | if exited_risk_mode { 805 | assert!( 806 | !self.engine.risk_reduction_only, 807 | "{}: still in risk mode after exit", 808 | context 809 | ); 810 | } 811 | assert_global_invariants(&self.engine, &context); 812 | } 813 | Err(_) => { 814 | // Simulate Solana rollback 815 | *self.engine = before; 816 | } 817 | } 818 | } 819 | } 820 | } 821 | 822 | fn count_used(&self) -> u32 { 823 | let mut count = 0; 824 | let n = account_count(&self.engine); 825 | for i in 0..n { 826 | if is_account_used(&self.engine, i as u16) { 827 | count += 1; 828 | } 829 | } 830 | count 831 | } 832 | } 833 | 834 | // State machine proptest 835 | proptest! { 836 | #![proptest_config(ProptestConfig::with_cases(100))] 837 | 838 | #[test] 839 | fn fuzz_state_machine_regime_a( 840 | initial_insurance in 0u128..50_000, 841 | actions in prop::collection::vec(action_strategy(), 50..100) 842 | ) { 843 | let mut state = FuzzState::new(params_regime_a()); 844 | 845 | // Setup: Add initial LP and users 846 | let lp_result = state.engine.add_lp([0u8; 32], [0u8; 32], 1); 847 | if let Ok(idx) = lp_result { 848 | state.live_accounts.push(idx); 849 | state.lp_idx = Some(idx); 850 | state.account_ids.push(state.engine.accounts[idx as usize].account_id); 851 | } 852 | 853 | for _ in 0..2 { 854 | if let Ok(idx) = state.engine.add_user(1) { 855 | state.live_accounts.push(idx); 856 | state.account_ids.push(state.engine.accounts[idx as usize].account_id); 857 | } 858 | } 859 | 860 | // Initial deposits 861 | for &idx in &state.live_accounts.clone() { 862 | let _ = state.engine.deposit(idx, 10_000); 863 | } 864 | 865 | // Top up insurance using proper API (maintains conservation) 866 | let current_insurance = state.engine.insurance_fund.balance; 867 | if initial_insurance > current_insurance { 868 | let _ = state.engine.top_up_insurance_fund(initial_insurance - current_insurance); 869 | } 870 | 871 | // Execute actions - selectors resolved at runtime against live state 872 | for (step, action) in actions.iter().enumerate() { 873 | state.execute(action, step); 874 | } 875 | } 876 | 877 | #[test] 878 | fn fuzz_state_machine_regime_b( 879 | initial_insurance in 1000u128..50_000, // Above floor 880 | actions in prop::collection::vec(action_strategy(), 50..100) 881 | ) { 882 | let mut state = FuzzState::new(params_regime_b()); 883 | 884 | // Setup: Add initial LP and users 885 | let lp_result = state.engine.add_lp([0u8; 32], [0u8; 32], 1); 886 | if let Ok(idx) = lp_result { 887 | state.live_accounts.push(idx); 888 | state.lp_idx = Some(idx); 889 | state.account_ids.push(state.engine.accounts[idx as usize].account_id); 890 | } 891 | 892 | for _ in 0..2 { 893 | if let Ok(idx) = state.engine.add_user(1) { 894 | state.live_accounts.push(idx); 895 | state.account_ids.push(state.engine.accounts[idx as usize].account_id); 896 | } 897 | } 898 | 899 | // Initial deposits 900 | for &idx in &state.live_accounts.clone() { 901 | let _ = state.engine.deposit(idx, 10_000); 902 | } 903 | 904 | // Top up insurance using proper API (maintains conservation) 905 | let floor = state.engine.params.risk_reduction_threshold; 906 | let target_insurance = initial_insurance.max(floor + 100); 907 | let current_insurance = state.engine.insurance_fund.balance; 908 | if target_insurance > current_insurance { 909 | let _ = state.engine.top_up_insurance_fund(target_insurance - current_insurance); 910 | } 911 | 912 | // Execute actions 913 | for (step, action) in actions.iter().enumerate() { 914 | state.execute(action, step); 915 | } 916 | } 917 | } 918 | 919 | // ============================================================================ 920 | // SECTION 6: UNIT PROPERTY FUZZ TESTS (FOCUSED) 921 | // ============================================================================ 922 | 923 | proptest! { 924 | #![proptest_config(ProptestConfig::with_cases(500))] 925 | 926 | // 1. withdrawable_pnl monotone in slot for positive pnl 927 | #[test] 928 | fn fuzz_prop_withdrawable_monotone( 929 | pnl in 1i128..100_000, 930 | slope in 1u128..10_000, 931 | slot1 in 0u64..500, 932 | slot2 in 0u64..500 933 | ) { 934 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); 935 | let user_idx = engine.add_user(1).unwrap(); 936 | 937 | engine.accounts[user_idx as usize].pnl = pnl; 938 | engine.accounts[user_idx as usize].warmup_slope_per_step = slope; 939 | engine.accounts[user_idx as usize].warmup_started_at_slot = 0; 940 | 941 | let earlier = slot1.min(slot2); 942 | let later = slot1.max(slot2); 943 | 944 | engine.current_slot = earlier; 945 | let w1 = engine.withdrawable_pnl(&engine.accounts[user_idx as usize]); 946 | 947 | engine.current_slot = later; 948 | let w2 = engine.withdrawable_pnl(&engine.accounts[user_idx as usize]); 949 | 950 | prop_assert!(w2 >= w1, "Withdrawable not monotone: {} -> {} at slots {} -> {}", 951 | w1, w2, earlier, later); 952 | } 953 | 954 | // 2. withdrawable_pnl == 0 if pnl<=0 or slope==0 or elapsed==0 955 | #[test] 956 | fn fuzz_prop_withdrawable_zero_conditions( 957 | principal in 0u128..100_000, 958 | pnl in -100_000i128..0, // Non-positive PnL 959 | slope in 0u128..10_000, 960 | slot in 0u64..500 961 | ) { 962 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); 963 | let user_idx = engine.add_user(1).unwrap(); 964 | 965 | engine.accounts[user_idx as usize].capital = principal; 966 | engine.accounts[user_idx as usize].pnl = pnl; 967 | engine.accounts[user_idx as usize].warmup_slope_per_step = slope; 968 | engine.accounts[user_idx as usize].warmup_started_at_slot = 0; 969 | engine.current_slot = slot; 970 | 971 | let withdrawable = engine.withdrawable_pnl(&engine.accounts[user_idx as usize]); 972 | 973 | // If pnl <= 0, withdrawable must be 0 974 | if pnl <= 0 { 975 | prop_assert_eq!(withdrawable, 0, "Withdrawable should be 0 for non-positive pnl"); 976 | } 977 | } 978 | 979 | #[test] 980 | fn fuzz_prop_withdrawable_zero_slope( 981 | pnl in 1i128..100_000, 982 | slot in 1u64..500 983 | ) { 984 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); 985 | let user_idx = engine.add_user(1).unwrap(); 986 | 987 | engine.accounts[user_idx as usize].pnl = pnl; 988 | engine.accounts[user_idx as usize].warmup_slope_per_step = 0; // Zero slope 989 | engine.accounts[user_idx as usize].warmup_started_at_slot = 0; 990 | engine.current_slot = slot; 991 | 992 | let withdrawable = engine.withdrawable_pnl(&engine.accounts[user_idx as usize]); 993 | prop_assert_eq!(withdrawable, 0, "Withdrawable should be 0 for zero slope"); 994 | } 995 | 996 | // 3. warmup_paused freezes progress 997 | #[test] 998 | fn fuzz_prop_warmup_pause_freezes( 999 | pnl in 1i128..10_000, 1000 | slope in 1u128..1000, 1001 | pause_slot in 1u64..100, 1002 | extra_slots in 1u64..200 1003 | ) { 1004 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); 1005 | let user_idx = engine.add_user(1).unwrap(); 1006 | 1007 | engine.accounts[user_idx as usize].pnl = pnl; 1008 | engine.accounts[user_idx as usize].warmup_slope_per_step = slope; 1009 | engine.accounts[user_idx as usize].warmup_started_at_slot = 0; 1010 | 1011 | // Pause at pause_slot 1012 | engine.warmup_paused = true; 1013 | engine.warmup_pause_slot = pause_slot; 1014 | 1015 | // Get withdrawable at pause_slot 1016 | engine.current_slot = pause_slot; 1017 | let w_at_pause = engine.withdrawable_pnl(&engine.accounts[user_idx as usize]); 1018 | 1019 | // Get withdrawable after more time passes 1020 | engine.current_slot = pause_slot + extra_slots; 1021 | let w_after_pause = engine.withdrawable_pnl(&engine.accounts[user_idx as usize]); 1022 | 1023 | prop_assert_eq!(w_at_pause, w_after_pause, 1024 | "Withdrawable should not increase after pause"); 1025 | } 1026 | 1027 | // 4. settle_warmup_to_capital idempotent at same slot 1028 | #[test] 1029 | fn fuzz_prop_settle_idempotent( 1030 | capital in 100u128..10_000, 1031 | pnl in 1i128..5_000, 1032 | slope in 1u128..1000, 1033 | slot in 1u64..200 1034 | ) { 1035 | let mut engine = Box::new(RiskEngine::new(params_regime_b())); 1036 | let user_idx = engine.add_user(1).unwrap(); 1037 | 1038 | engine.insurance_fund.balance = 100_000; 1039 | engine.vault = 100_000; 1040 | engine.deposit(user_idx, capital).unwrap(); 1041 | engine.accounts[user_idx as usize].pnl = pnl; 1042 | engine.accounts[user_idx as usize].warmup_slope_per_step = slope; 1043 | engine.accounts[user_idx as usize].warmup_started_at_slot = 0; 1044 | engine.current_slot = slot; 1045 | 1046 | // First settlement 1047 | let _ = engine.settle_warmup_to_capital(user_idx); 1048 | let state1 = ( 1049 | engine.accounts[user_idx as usize].capital, 1050 | engine.accounts[user_idx as usize].pnl, 1051 | engine.warmed_pos_total, 1052 | engine.warmed_neg_total, 1053 | engine.warmup_insurance_reserved, 1054 | ); 1055 | 1056 | // Second settlement at same slot 1057 | let _ = engine.settle_warmup_to_capital(user_idx); 1058 | let state2 = ( 1059 | engine.accounts[user_idx as usize].capital, 1060 | engine.accounts[user_idx as usize].pnl, 1061 | engine.warmed_pos_total, 1062 | engine.warmed_neg_total, 1063 | engine.warmup_insurance_reserved, 1064 | ); 1065 | 1066 | prop_assert_eq!(state1, state2, "Settlement should be idempotent"); 1067 | } 1068 | 1069 | // 5. settle_warmup_to_capital: warmed totals monotone non-decreasing 1070 | #[test] 1071 | fn fuzz_prop_warmed_totals_monotone( 1072 | capital in 100u128..10_000, 1073 | pnl in 1i128..5_000, 1074 | slope in 1u128..1000, 1075 | slots in prop::collection::vec(1u64..50, 1..5) 1076 | ) { 1077 | let mut engine = Box::new(RiskEngine::new(params_regime_b())); 1078 | let user_idx = engine.add_user(1).unwrap(); 1079 | 1080 | engine.insurance_fund.balance = 100_000; 1081 | engine.vault = 100_000; 1082 | engine.deposit(user_idx, capital).unwrap(); 1083 | engine.accounts[user_idx as usize].pnl = pnl; 1084 | engine.accounts[user_idx as usize].warmup_slope_per_step = slope; 1085 | engine.accounts[user_idx as usize].warmup_started_at_slot = 0; 1086 | 1087 | let mut prev_pos = engine.warmed_pos_total; 1088 | let mut prev_neg = engine.warmed_neg_total; 1089 | let mut current = 0u64; 1090 | 1091 | for &dt in &slots { 1092 | current += dt; 1093 | engine.current_slot = current; 1094 | let _ = engine.settle_warmup_to_capital(user_idx); 1095 | 1096 | prop_assert!(engine.warmed_pos_total >= prev_pos, 1097 | "warmed_pos_total decreased"); 1098 | prop_assert!(engine.warmed_neg_total >= prev_neg, 1099 | "warmed_neg_total decreased"); 1100 | 1101 | prev_pos = engine.warmed_pos_total; 1102 | prev_neg = engine.warmed_neg_total; 1103 | } 1104 | } 1105 | 1106 | // 6. apply_adl never changes any capital 1107 | #[test] 1108 | fn fuzz_prop_adl_preserves_capital( 1109 | capitals in prop::collection::vec(0u128..50_000, 2..5), 1110 | pnls in prop::collection::vec(-10_000i128..10_000, 2..5), 1111 | loss in 0u128..20_000 1112 | ) { 1113 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); 1114 | 1115 | let mut indices = Vec::new(); 1116 | for i in 0..capitals.len().min(pnls.len()) { 1117 | let idx = if i == 0 { 1118 | engine.add_lp([0u8; 32], [0u8; 32], 1).unwrap() 1119 | } else { 1120 | engine.add_user(1).unwrap() 1121 | }; 1122 | engine.accounts[idx as usize].capital = capitals[i]; 1123 | engine.accounts[idx as usize].pnl = pnls[i]; 1124 | indices.push(idx); 1125 | } 1126 | 1127 | engine.insurance_fund.balance = 100_000; 1128 | engine.vault = capitals.iter().sum::() + 100_000; 1129 | 1130 | let capitals_before: Vec<_> = indices 1131 | .iter() 1132 | .map(|&idx| engine.accounts[idx as usize].capital) 1133 | .collect(); 1134 | 1135 | let _ = engine.apply_adl(loss); 1136 | 1137 | for (i, &idx) in indices.iter().enumerate() { 1138 | prop_assert_eq!( 1139 | engine.accounts[idx as usize].capital, 1140 | capitals_before[i], 1141 | "ADL changed capital for account {}", 1142 | idx 1143 | ); 1144 | } 1145 | } 1146 | 1147 | // 7. touch_account idempotent if global index unchanged 1148 | #[test] 1149 | fn fuzz_prop_touch_idempotent( 1150 | position in -100_000i128..100_000, 1151 | pnl in -50_000i128..50_000, 1152 | funding_delta in -1_000_000i128..1_000_000 1153 | ) { 1154 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); 1155 | let user_idx = engine.add_user(1).unwrap(); 1156 | 1157 | engine.accounts[user_idx as usize].position_size = position; 1158 | engine.accounts[user_idx as usize].pnl = pnl; 1159 | engine.funding_index_qpb_e6 = funding_delta; 1160 | 1161 | // First touch 1162 | let _ = engine.touch_account(user_idx); 1163 | let state1 = ( 1164 | engine.accounts[user_idx as usize].pnl, 1165 | engine.accounts[user_idx as usize].funding_index, 1166 | ); 1167 | 1168 | // Second touch without changing global index 1169 | let _ = engine.touch_account(user_idx); 1170 | let state2 = ( 1171 | engine.accounts[user_idx as usize].pnl, 1172 | engine.accounts[user_idx as usize].funding_index, 1173 | ); 1174 | 1175 | prop_assert_eq!(state1, state2, "Touch should be idempotent"); 1176 | } 1177 | 1178 | // 8. accrue_funding with dt=0 is no-op 1179 | #[test] 1180 | fn fuzz_prop_funding_zero_dt_noop( 1181 | price in 100_000u64..10_000_000, 1182 | rate in -1000i64..1000 1183 | ) { 1184 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); 1185 | 1186 | let index_before = engine.funding_index_qpb_e6; 1187 | let slot_before = engine.last_funding_slot; 1188 | 1189 | // Accrue with same slot (dt=0) 1190 | let _ = engine.accrue_funding(slot_before, price, rate); 1191 | 1192 | prop_assert_eq!(engine.funding_index_qpb_e6, index_before, 1193 | "Funding index changed with dt=0"); 1194 | } 1195 | 1196 | // 9. Collateral calculation is consistent 1197 | #[test] 1198 | fn fuzz_prop_collateral_calculation( 1199 | capital in 0u128..100_000, 1200 | pnl in -50_000i128..50_000 1201 | ) { 1202 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); 1203 | let user_idx = engine.add_user(1).unwrap(); 1204 | 1205 | engine.accounts[user_idx as usize].capital = capital; 1206 | engine.accounts[user_idx as usize].pnl = pnl; 1207 | 1208 | let collateral = engine.account_collateral(&engine.accounts[user_idx as usize]); 1209 | 1210 | // Collateral = capital + max(0, pnl) 1211 | let expected = if pnl >= 0 { 1212 | capital.saturating_add(pnl as u128) 1213 | } else { 1214 | capital 1215 | }; 1216 | 1217 | prop_assert_eq!(collateral, expected, 1218 | "Collateral calculation incorrect: got {}, expected {}", 1219 | collateral, expected); 1220 | } 1221 | 1222 | // 10. add_user/add_lp fails when at max capacity 1223 | #[test] 1224 | fn fuzz_prop_add_fails_at_capacity(num_to_add in 1usize..10) { 1225 | let mut params = params_regime_a(); 1226 | params.max_accounts = 4; // Very small 1227 | let mut engine = Box::new(RiskEngine::new(params)); 1228 | 1229 | // Fill up 1230 | for _ in 0..4 { 1231 | let _ = engine.add_user(1); 1232 | } 1233 | 1234 | // Additional adds should fail 1235 | for _ in 0..num_to_add { 1236 | let result = engine.add_user(1); 1237 | prop_assert!(result.is_err(), "add_user should fail at capacity"); 1238 | } 1239 | } 1240 | 1241 | // 11. Zero position pays no funding 1242 | #[test] 1243 | fn fuzz_prop_zero_position_no_funding( 1244 | pnl in -100_000i128..100_000, 1245 | funding_delta in -10_000_000i128..10_000_000 1246 | ) { 1247 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); 1248 | let user_idx = engine.add_user(1).unwrap(); 1249 | 1250 | engine.accounts[user_idx as usize].position_size = 0; 1251 | engine.accounts[user_idx as usize].pnl = pnl; 1252 | engine.funding_index_qpb_e6 = funding_delta; 1253 | 1254 | let _ = engine.touch_account(user_idx); 1255 | 1256 | prop_assert_eq!(engine.accounts[user_idx as usize].pnl, pnl, 1257 | "Zero position should not pay funding"); 1258 | } 1259 | 1260 | // 12. Funding is zero-sum between opposite positions 1261 | #[test] 1262 | fn fuzz_prop_funding_zero_sum( 1263 | position in 1i128..100_000, 1264 | funding_delta in -1_000_000i128..1_000_000 1265 | ) { 1266 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); 1267 | let user_idx = engine.add_user(1).unwrap(); 1268 | let lp_idx = engine.add_lp([0u8; 32], [0u8; 32], 1).unwrap(); 1269 | 1270 | // Opposite positions 1271 | engine.accounts[user_idx as usize].position_size = position; 1272 | engine.accounts[lp_idx as usize].position_size = -position; 1273 | 1274 | let total_pnl_before = engine.accounts[user_idx as usize].pnl 1275 | + engine.accounts[lp_idx as usize].pnl; 1276 | 1277 | engine.funding_index_qpb_e6 = funding_delta; 1278 | 1279 | let _ = engine.touch_account(user_idx); 1280 | let _ = engine.touch_account(lp_idx); 1281 | 1282 | let total_pnl_after = engine.accounts[user_idx as usize].pnl 1283 | + engine.accounts[lp_idx as usize].pnl; 1284 | 1285 | // Funding payments round UP when account pays, so total PNL may decrease 1286 | // (vault keeps rounding dust). This ensures one-sided conservation slack. 1287 | // The change should never be positive (no value created from thin air). 1288 | let change = total_pnl_after - total_pnl_before; 1289 | prop_assert!(change <= 0, 1290 | "Funding should not create value: change={}", change); 1291 | // The absolute change should be bounded by rounding (at most 2 per account pair) 1292 | prop_assert!(change >= -2, 1293 | "Funding change should be bounded: change={}", change); 1294 | } 1295 | } 1296 | 1297 | // ============================================================================ 1298 | // SECTION 7: DETERMINISTIC SEEDED FUZZER 1299 | // ============================================================================ 1300 | 1301 | /// xorshift64 PRNG for deterministic randomness 1302 | struct Rng { 1303 | state: u64, 1304 | } 1305 | 1306 | impl Rng { 1307 | fn new(seed: u64) -> Self { 1308 | Rng { 1309 | state: if seed == 0 { 1 } else { seed }, 1310 | } 1311 | } 1312 | 1313 | fn next(&mut self) -> u64 { 1314 | let mut x = self.state; 1315 | x ^= x << 13; 1316 | x ^= x >> 7; 1317 | x ^= x << 17; 1318 | self.state = x; 1319 | x 1320 | } 1321 | 1322 | fn u64(&mut self, lo: u64, hi: u64) -> u64 { 1323 | if lo >= hi { 1324 | return lo; 1325 | } 1326 | lo + (self.next() % (hi - lo + 1)) 1327 | } 1328 | 1329 | fn u128(&mut self, lo: u128, hi: u128) -> u128 { 1330 | if lo >= hi { 1331 | return lo; 1332 | } 1333 | lo + ((self.next() as u128) % (hi - lo + 1)) 1334 | } 1335 | 1336 | fn i128(&mut self, lo: i128, hi: i128) -> i128 { 1337 | if lo >= hi { 1338 | return lo; 1339 | } 1340 | // Avoid overflow: use u64 directly and cast safely 1341 | let range = (hi - lo + 1) as u128; 1342 | lo + ((self.next() as u128 % range) as i128) 1343 | } 1344 | 1345 | fn i64(&mut self, lo: i64, hi: i64) -> i64 { 1346 | if lo >= hi { 1347 | return lo; 1348 | } 1349 | // Avoid overflow: use u64 directly and cast safely 1350 | let range = (hi - lo + 1) as u64; 1351 | lo + ((self.next() % range) as i64) 1352 | } 1353 | 1354 | fn usize(&mut self, lo: usize, hi: usize) -> usize { 1355 | if lo >= hi { 1356 | return lo; 1357 | } 1358 | lo + ((self.next() as usize) % (hi - lo + 1)) 1359 | } 1360 | } 1361 | 1362 | /// Generate a random selector using RNG 1363 | fn random_selector(rng: &mut Rng) -> IdxSel { 1364 | match rng.usize(0, 3) { 1365 | 0 => IdxSel::Existing, 1366 | 1 => IdxSel::ExistingNonLp, 1367 | 2 => IdxSel::Lp, 1368 | _ => IdxSel::Random(rng.u64(0, 63) as u16), 1369 | } 1370 | } 1371 | 1372 | /// Generate a random action using the RNG (selector-based) 1373 | fn random_action(rng: &mut Rng) -> (Action, String) { 1374 | // Note: ApplyAdl removed - it's internal and tested via settlement ops 1375 | let action_type = rng.usize(0, 10); 1376 | 1377 | let action = match action_type { 1378 | 0 => Action::AddUser { 1379 | fee_payment: rng.u128(1, 100), 1380 | }, 1381 | 1 => Action::AddLp { 1382 | fee_payment: rng.u128(1, 100), 1383 | }, 1384 | 2 => Action::Deposit { 1385 | who: random_selector(rng), 1386 | amount: rng.u128(0, 50_000), 1387 | }, 1388 | 3 => Action::Withdraw { 1389 | who: random_selector(rng), 1390 | amount: rng.u128(0, 50_000), 1391 | }, 1392 | 4 => Action::AdvanceSlot { dt: rng.u64(0, 10) }, 1393 | 5 => Action::AccrueFunding { 1394 | dt: rng.u64(1, 50), 1395 | oracle_price: rng.u64(100_000, 10_000_000), 1396 | rate_bps: rng.i64(-100, 100), 1397 | }, 1398 | 6 => Action::Touch { 1399 | who: random_selector(rng), 1400 | }, 1401 | 7 => Action::ExecuteTrade { 1402 | lp: IdxSel::Lp, 1403 | user: IdxSel::ExistingNonLp, 1404 | oracle_price: rng.u64(100_000, 10_000_000), 1405 | size: rng.i128(-5_000, 5_000), 1406 | }, 1407 | 8 => Action::PanicSettleAll { 1408 | oracle_price: rng.u64(100_000, 10_000_000), 1409 | }, 1410 | 9 => Action::ForceRealizeLosses { 1411 | oracle_price: rng.u64(100_000, 10_000_000), 1412 | }, 1413 | _ => Action::TopUpInsurance { 1414 | amount: rng.u128(0, 10_000), 1415 | }, 1416 | }; 1417 | 1418 | let desc = format!("{:?}", action); 1419 | (action, desc) 1420 | } 1421 | 1422 | /// Compute conservation slack without panicking 1423 | fn compute_conservation_slack(engine: &RiskEngine) -> (i128, u128, i128, u128, u128) { 1424 | let mut total_capital = 0u128; 1425 | let mut net_settled_pnl: i128 = 0; 1426 | let global_index = engine.funding_index_qpb_e6; 1427 | 1428 | let n = account_count(engine); 1429 | for i in 0..n { 1430 | if is_account_used(engine, i as u16) { 1431 | let acc = &engine.accounts[i]; 1432 | total_capital += acc.capital; 1433 | 1434 | // Compute settled PNL using shared helper (matches engine rounding) 1435 | let mut settled_pnl = acc.pnl; 1436 | if acc.position_size != 0 { 1437 | let delta_f = global_index.saturating_sub(acc.funding_index); 1438 | if delta_f != 0 { 1439 | let payment = funding_payment(acc.position_size, delta_f); 1440 | settled_pnl = settled_pnl.saturating_sub(payment); 1441 | } 1442 | } 1443 | net_settled_pnl = net_settled_pnl.saturating_add(settled_pnl); 1444 | } 1445 | } 1446 | let base = total_capital + engine.insurance_fund.balance; 1447 | let expected = if net_settled_pnl >= 0 { 1448 | base + net_settled_pnl as u128 1449 | } else { 1450 | base.saturating_sub((-net_settled_pnl) as u128) 1451 | }; 1452 | let actual = engine.vault + engine.loss_accum; 1453 | let slack = actual as i128 - expected as i128; 1454 | ( 1455 | slack, 1456 | total_capital, 1457 | net_settled_pnl, 1458 | engine.insurance_fund.balance, 1459 | actual, 1460 | ) 1461 | } 1462 | 1463 | /// Run deterministic fuzzer for a single regime 1464 | fn run_deterministic_fuzzer( 1465 | params: RiskParams, 1466 | regime_name: &str, 1467 | seeds: std::ops::Range, 1468 | steps: usize, 1469 | ) { 1470 | for seed in seeds { 1471 | let mut rng = Rng::new(seed); 1472 | let mut state = FuzzState::new(params.clone()); 1473 | 1474 | // Track last N actions for repro 1475 | let mut action_history: Vec = Vec::with_capacity(10); 1476 | 1477 | // Setup: create LP and 2 users 1478 | if let Ok(idx) = state.engine.add_lp([0u8; 32], [0u8; 32], 1) { 1479 | state.live_accounts.push(idx); 1480 | state.lp_idx = Some(idx); 1481 | state 1482 | .account_ids 1483 | .push(state.engine.accounts[idx as usize].account_id); 1484 | } 1485 | 1486 | for _ in 0..2 { 1487 | if let Ok(idx) = state.engine.add_user(1) { 1488 | state.live_accounts.push(idx); 1489 | state 1490 | .account_ids 1491 | .push(state.engine.accounts[idx as usize].account_id); 1492 | } 1493 | } 1494 | 1495 | // Initial deposits 1496 | for &idx in &state.live_accounts.clone() { 1497 | let _ = state.engine.deposit(idx, rng.u128(5_000, 50_000)); 1498 | } 1499 | 1500 | // Top up insurance using proper API (maintains conservation) 1501 | let floor = state.engine.params.risk_reduction_threshold; 1502 | let target_ins = floor + rng.u128(5_000, 100_000); 1503 | let current_ins = state.engine.insurance_fund.balance; 1504 | if target_ins > current_ins { 1505 | let _ = state.engine.top_up_insurance_fund(target_ins - current_ins); 1506 | } 1507 | 1508 | // Verify conservation after setup 1509 | if !state.engine.check_conservation() { 1510 | eprintln!("Conservation failed after setup for seed {}", seed); 1511 | eprintln!( 1512 | " vault={}, insurance={}", 1513 | state.engine.vault, state.engine.insurance_fund.balance 1514 | ); 1515 | eprintln!(" live_accounts={:?}", state.live_accounts); 1516 | let mut total_cap = 0u128; 1517 | for &idx in &state.live_accounts { 1518 | eprintln!( 1519 | " account[{}]: capital={}", 1520 | idx, state.engine.accounts[idx as usize].capital 1521 | ); 1522 | total_cap += state.engine.accounts[idx as usize].capital; 1523 | } 1524 | eprintln!(" total_capital={}", total_cap); 1525 | panic!("Conservation failed after setup"); 1526 | } 1527 | 1528 | // Track slack before starting 1529 | let mut _last_slack: i128 = 0; 1530 | let verbose = false; // Disable verbose for now 1531 | 1532 | // Run steps 1533 | for step in 0..steps { 1534 | let (slack_before, _, _, _, _) = compute_conservation_slack(&state.engine); 1535 | // Use selector-based random_action (no live/lp args needed) 1536 | let (action, desc) = random_action(&mut rng); 1537 | 1538 | // Keep last 10 actions 1539 | if action_history.len() >= 10 { 1540 | action_history.remove(0); 1541 | } 1542 | action_history.push(desc.clone()); 1543 | 1544 | // Execute with panic catching for better error messages 1545 | let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { 1546 | state.execute(&action, step); 1547 | })); 1548 | 1549 | // Track slack changes 1550 | let (slack_after, total_cap, net_pnl, ins, actual) = 1551 | compute_conservation_slack(&state.engine); 1552 | let slack_delta = slack_after - slack_before; 1553 | if verbose && slack_delta != 0 { 1554 | eprintln!( 1555 | "Step {}: {} -> slack delta={}, total slack={} (cap={}, pnl={}, ins={}, actual={})", 1556 | step, desc, slack_delta, slack_after, total_cap, net_pnl, ins, actual 1557 | ); 1558 | } 1559 | _last_slack = slack_after; 1560 | 1561 | if result.is_err() { 1562 | eprintln!("\n=== DETERMINISTIC FUZZER FAILURE ==="); 1563 | eprintln!("Regime: {}", regime_name); 1564 | eprintln!("Seed: {}", seed); 1565 | eprintln!("Step: {}", step); 1566 | eprintln!("Action: {}", desc); 1567 | eprintln!("Slack before: {}, after: {}", slack_before, slack_after); 1568 | eprintln!("\nLast 10 actions:"); 1569 | for (i, act) in action_history.iter().enumerate() { 1570 | eprintln!(" {}: {}", step.saturating_sub(9) + i, act); 1571 | } 1572 | eprintln!( 1573 | "\nTo reproduce: run with seed={}, stop at step={}", 1574 | seed, step 1575 | ); 1576 | panic!("Deterministic fuzzer failed - see above for repro"); 1577 | } 1578 | // Note: live_accounts tracking is now handled inside execute() via the returned idx 1579 | // when AddUser/AddLp succeeds. No need for separate tracking here. 1580 | } 1581 | } 1582 | } 1583 | 1584 | #[test] 1585 | fn fuzz_deterministic_regime_a() { 1586 | run_deterministic_fuzzer(params_regime_a(), "A (floor=0)", 1..501, 200); 1587 | } 1588 | 1589 | #[test] 1590 | fn fuzz_deterministic_regime_b() { 1591 | run_deterministic_fuzzer(params_regime_b(), "B (floor=1000)", 1..501, 200); 1592 | } 1593 | 1594 | // Extended deterministic test with more seeds 1595 | #[test] 1596 | #[ignore] // Run with: cargo test --features fuzz fuzz_deterministic_extended -- --ignored 1597 | fn fuzz_deterministic_extended() { 1598 | run_deterministic_fuzzer(params_regime_a(), "A extended", 1..2001, 500); 1599 | run_deterministic_fuzzer(params_regime_b(), "B extended", 1..2001, 500); 1600 | } 1601 | 1602 | // ============================================================================ 1603 | // SECTION 8: LEGACY PROPTEST TESTS (PRESERVED FROM ORIGINAL) 1604 | // ============================================================================ 1605 | 1606 | // Strategy helpers 1607 | fn amount_strategy() -> impl Strategy { 1608 | 0u128..1_000_000 1609 | } 1610 | 1611 | fn position_strategy() -> impl Strategy { 1612 | -100_000i128..100_000 1613 | } 1614 | 1615 | proptest! { 1616 | // Test that deposit always increases vault and principal 1617 | #[test] 1618 | fn fuzz_deposit_increases_balance(amount in amount_strategy()) { 1619 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); 1620 | let user_idx = engine.add_user(1).unwrap(); 1621 | 1622 | let vault_before = engine.vault; 1623 | let principal_before = engine.accounts[user_idx as usize].capital; 1624 | 1625 | let _ = engine.deposit(user_idx, amount); 1626 | 1627 | prop_assert_eq!(engine.vault, vault_before + amount); 1628 | prop_assert_eq!(engine.accounts[user_idx as usize].capital, principal_before + amount); 1629 | } 1630 | 1631 | // Test that withdrawal never increases balance (uses Solana rollback simulation on Err) 1632 | #[test] 1633 | fn fuzz_withdraw_decreases_or_fails( 1634 | deposit_amount in amount_strategy(), 1635 | withdraw_amount in amount_strategy() 1636 | ) { 1637 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); 1638 | let user_idx = engine.add_user(1).unwrap(); 1639 | 1640 | engine.deposit(user_idx, deposit_amount).unwrap(); 1641 | 1642 | // Snapshot for rollback simulation 1643 | let before = (*engine).clone(); 1644 | 1645 | let result = engine.withdraw(user_idx, withdraw_amount); 1646 | 1647 | if result.is_ok() { 1648 | prop_assert!(engine.vault <= before.vault); 1649 | prop_assert!(engine.accounts[user_idx as usize].capital <= before.accounts[user_idx as usize].capital); 1650 | } else { 1651 | // Simulate Solana rollback then verify state is restored 1652 | *engine = before.clone(); 1653 | prop_assert_eq!(engine.vault, before.vault); 1654 | prop_assert_eq!(engine.accounts[user_idx as usize].capital, before.accounts[user_idx as usize].capital); 1655 | } 1656 | } 1657 | 1658 | // Test conservation after operations 1659 | #[test] 1660 | fn fuzz_conservation_after_operations( 1661 | deposits in prop::collection::vec(amount_strategy(), 1..10), 1662 | withdrawals in prop::collection::vec(amount_strategy(), 1..10) 1663 | ) { 1664 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); 1665 | let user_idx = engine.add_user(1).unwrap(); 1666 | 1667 | for amount in deposits { 1668 | let _ = engine.deposit(user_idx, amount); 1669 | } 1670 | 1671 | prop_assert!(engine.check_conservation()); 1672 | 1673 | for amount in withdrawals { 1674 | let _ = engine.withdraw(user_idx, amount); 1675 | } 1676 | 1677 | prop_assert!(engine.check_conservation()); 1678 | } 1679 | 1680 | // Test funding idempotence 1681 | #[test] 1682 | fn fuzz_funding_idempotence( 1683 | position in position_strategy(), 1684 | index_delta in -1_000_000i128..1_000_000 1685 | ) { 1686 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); 1687 | let user_idx = engine.add_user(1).unwrap(); 1688 | 1689 | engine.accounts[user_idx as usize].position_size = position; 1690 | engine.funding_index_qpb_e6 = index_delta; 1691 | 1692 | let _ = engine.touch_account(user_idx); 1693 | let pnl_first = engine.accounts[user_idx as usize].pnl; 1694 | 1695 | let _ = engine.touch_account(user_idx); 1696 | let pnl_second = engine.accounts[user_idx as usize].pnl; 1697 | 1698 | prop_assert_eq!(pnl_first, pnl_second, "Funding settlement should be idempotent"); 1699 | } 1700 | 1701 | // Test funding preserves principal 1702 | #[test] 1703 | fn fuzz_funding_preserves_principal( 1704 | principal in amount_strategy(), 1705 | position in position_strategy(), 1706 | funding_delta in -10_000_000i128..10_000_000 1707 | ) { 1708 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); 1709 | let user_idx = engine.add_user(1).unwrap(); 1710 | 1711 | engine.accounts[user_idx as usize].capital = principal; 1712 | engine.accounts[user_idx as usize].position_size = position; 1713 | engine.funding_index_qpb_e6 = funding_delta; 1714 | 1715 | let _ = engine.touch_account(user_idx); 1716 | 1717 | prop_assert_eq!(engine.accounts[user_idx as usize].capital, principal, 1718 | "Funding must never modify principal"); 1719 | } 1720 | 1721 | // Test ADL insurance failover 1722 | #[test] 1723 | fn fuzz_adl_insurance_failover( 1724 | user_pnl in 0i128..10_000, 1725 | insurance_balance in 0u128..5_000, 1726 | loss in 5_000u128..20_000 1727 | ) { 1728 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); 1729 | let user_idx = engine.add_user(1).unwrap(); 1730 | 1731 | engine.accounts[user_idx as usize].pnl = user_pnl; 1732 | engine.insurance_fund.balance = insurance_balance; 1733 | 1734 | let _ = engine.apply_adl(loss); 1735 | 1736 | let total_available = (user_pnl as u128) + insurance_balance; 1737 | if loss > total_available { 1738 | prop_assert!(engine.loss_accum > 0); 1739 | } 1740 | } 1741 | 1742 | // Conservation after panic settle 1743 | #[test] 1744 | fn fuzz_conservation_after_panic_settle( 1745 | user_capital in 1000u128..100_000, 1746 | lp_capital in 1000u128..100_000, 1747 | position in 1i128..10_000, 1748 | entry_price in 100_000u64..10_000_000, 1749 | oracle_price in 100_000u64..10_000_000, 1750 | insurance in 0u128..10_000 1751 | ) { 1752 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); 1753 | let user_idx = engine.add_user(1).unwrap(); 1754 | let lp_idx = engine.add_lp([0u8; 32], [0u8; 32], 1).unwrap(); 1755 | 1756 | engine.deposit(user_idx, user_capital).unwrap(); 1757 | engine.deposit(lp_idx, lp_capital).unwrap(); 1758 | 1759 | engine.accounts[user_idx as usize].position_size = position; 1760 | engine.accounts[user_idx as usize].entry_price = entry_price; 1761 | engine.accounts[lp_idx as usize].position_size = -position; 1762 | engine.accounts[lp_idx as usize].entry_price = entry_price; 1763 | 1764 | let total_capital = user_capital + lp_capital; 1765 | engine.insurance_fund.balance = insurance; 1766 | engine.vault = total_capital + insurance; 1767 | 1768 | prop_assert!(engine.check_conservation(), "Before panic_settle"); 1769 | 1770 | let _ = engine.panic_settle_all(oracle_price); 1771 | 1772 | prop_assert!(engine.check_conservation(), "After panic_settle"); 1773 | 1774 | prop_assert_eq!(engine.accounts[user_idx as usize].position_size, 0); 1775 | prop_assert_eq!(engine.accounts[lp_idx as usize].position_size, 0); 1776 | } 1777 | } 1778 | 1779 | // ============================================================================ 1780 | // SECTION 9: CONSERVATION REGRESSION TESTS 1781 | // These verify that conservation invariant holds under various conditions 1782 | // ============================================================================ 1783 | 1784 | /// Verify panic_settle_all preserves conservation with lazy funding 1785 | /// Conservation uses settled_pnl which accounts for unsettled funding payments 1786 | #[test] 1787 | fn panic_settle_preserves_conservation_with_lazy_funding() { 1788 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); 1789 | 1790 | // Create LP and user with positions 1791 | let lp_idx = engine.add_lp([0u8; 32], [0u8; 32], 1).unwrap(); 1792 | let user_idx = engine.add_user(1).unwrap(); 1793 | engine.deposit(lp_idx, 100_000).unwrap(); 1794 | engine.deposit(user_idx, 100_000).unwrap(); 1795 | 1796 | // Execute a trade to create positions 1797 | engine 1798 | .execute_trade(&MATCHER, lp_idx, user_idx, 1_000_000, 1000) 1799 | .unwrap(); 1800 | 1801 | // Accrue significant funding WITHOUT touching accounts 1802 | engine.accrue_funding(1000, 1_000_000, 1000).unwrap(); 1803 | 1804 | // Verify conservation holds before panic settle (uses settled_pnl) 1805 | assert!( 1806 | engine.check_conservation(), 1807 | "Conservation should hold before panic_settle" 1808 | ); 1809 | 1810 | // Panic settle 1811 | engine.panic_settle_all(1_000_000).unwrap(); 1812 | 1813 | // Verify conservation still holds 1814 | assert!( 1815 | engine.check_conservation(), 1816 | "Conservation must hold after panic_settle" 1817 | ); 1818 | 1819 | // All positions should be closed 1820 | assert_eq!(engine.accounts[user_idx as usize].position_size, 0); 1821 | assert_eq!(engine.accounts[lp_idx as usize].position_size, 0); 1822 | } 1823 | 1824 | /// Verify check_conservation uses settled_pnl (accounts for lazy funding) 1825 | /// This prevents "docs drift" - ensures engine matches documented formula 1826 | #[test] 1827 | fn conservation_uses_settled_pnl_regression() { 1828 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); 1829 | 1830 | // Create LP and user with positions 1831 | let lp_idx = engine.add_lp([0u8; 32], [0u8; 32], 1).unwrap(); 1832 | let user_idx = engine.add_user(1).unwrap(); 1833 | engine.deposit(lp_idx, 100_000).unwrap(); 1834 | engine.deposit(user_idx, 100_000).unwrap(); 1835 | 1836 | // Execute trade to create positions 1837 | engine 1838 | .execute_trade(&MATCHER, lp_idx, user_idx, 1_000_000, 1000) 1839 | .unwrap(); 1840 | 1841 | // Accrue significant funding WITHOUT touching accounts 1842 | // This creates a gap between account.pnl and settled_pnl 1843 | engine.accrue_funding(1000, 1_000_000, 500).unwrap(); 1844 | 1845 | // Manually compute conservation using settled_pnl formula 1846 | let global_index = engine.funding_index_qpb_e6; 1847 | let mut total_capital = 0u128; 1848 | let mut net_settled_pnl: i128 = 0; 1849 | 1850 | for i in 0..account_count(&engine) { 1851 | if is_account_used(&engine, i as u16) { 1852 | let acc = &engine.accounts[i]; 1853 | total_capital += acc.capital; 1854 | 1855 | // Compute settled PNL using shared helper (matches engine rounding) 1856 | let mut settled_pnl = acc.pnl; 1857 | if acc.position_size != 0 { 1858 | let delta_f = global_index.saturating_sub(acc.funding_index); 1859 | if delta_f != 0 { 1860 | let payment = funding_payment(acc.position_size, delta_f); 1861 | settled_pnl = settled_pnl.saturating_sub(payment); 1862 | } 1863 | } 1864 | net_settled_pnl = net_settled_pnl.saturating_add(settled_pnl); 1865 | } 1866 | } 1867 | 1868 | // Compute expected: sum(capital) + sum(settled_pnl) + insurance 1869 | let base = total_capital + engine.insurance_fund.balance; 1870 | let expected = if net_settled_pnl >= 0 { 1871 | base + (net_settled_pnl as u128) 1872 | } else { 1873 | base.saturating_sub((-net_settled_pnl) as u128) 1874 | }; 1875 | 1876 | // Compute actual: vault + loss_accum 1877 | let actual = engine.vault + engine.loss_accum; 1878 | 1879 | // Verify our manual computation matches engine's check 1880 | assert!( 1881 | engine.check_conservation(), 1882 | "check_conservation failed: actual={}, expected={}, diff={}", 1883 | actual, 1884 | expected, 1885 | (actual as i128) - (expected as i128) 1886 | ); 1887 | 1888 | // Also verify our formula matches (within rounding tolerance) 1889 | let diff = if actual >= expected { 1890 | actual - expected 1891 | } else { 1892 | expected - actual 1893 | }; 1894 | assert!( 1895 | diff <= 100, // MAX_ROUNDING_SLACK is typically small 1896 | "Manual settled_pnl formula doesn't match engine: actual={}, expected={}, diff={}", 1897 | actual, 1898 | expected, 1899 | diff 1900 | ); 1901 | } 1902 | 1903 | /// Verify the test harness correctly simulates Solana atomicity 1904 | /// When an operation returns Err, the harness must restore the engine to pre-call state 1905 | /// This ensures the fuzz suite accurately models on-chain behavior 1906 | #[test] 1907 | fn harness_rollback_simulation_test() { 1908 | let mut engine = Box::new(RiskEngine::new(params_regime_a())); 1909 | 1910 | // Create user with some capital 1911 | let user_idx = engine.add_user(1).unwrap(); 1912 | engine.deposit(user_idx, 1000).unwrap(); 1913 | 1914 | // Accrue some funding to create state that could be mutated 1915 | engine.accrue_funding(100, 1_000_000, 100).unwrap(); 1916 | 1917 | // Capture complete state before failed operation (deep clone of RiskEngine) 1918 | let before = (*engine).clone(); 1919 | 1920 | // Capture expected values before any operation 1921 | let expected_vault = engine.vault; 1922 | let expected_capital = engine.accounts[user_idx as usize].capital; 1923 | let expected_pnl = engine.accounts[user_idx as usize].pnl; 1924 | let expected_funding_index = engine.accounts[user_idx as usize].funding_index; 1925 | let expected_warmed_pos = engine.warmed_pos_total; 1926 | let expected_warmed_neg = engine.warmed_neg_total; 1927 | let expected_warmup_reserved = engine.warmup_insurance_reserved; 1928 | 1929 | // Try to withdraw more than available - will fail 1930 | let result = engine.withdraw(user_idx, 999_999); 1931 | assert!( 1932 | result.is_err(), 1933 | "Withdraw should fail with insufficient balance" 1934 | ); 1935 | 1936 | // Simulate Solana rollback (this is what the harness does) 1937 | // Deep restore of RiskEngine contents 1938 | *engine = before; 1939 | 1940 | // Verify state is exactly restored 1941 | assert_eq!(engine.vault, expected_vault, "vault must be restored"); 1942 | assert_eq!( 1943 | engine.accounts[user_idx as usize].capital, expected_capital, 1944 | "capital must be restored" 1945 | ); 1946 | assert_eq!( 1947 | engine.accounts[user_idx as usize].pnl, expected_pnl, 1948 | "pnl must be restored" 1949 | ); 1950 | assert_eq!( 1951 | engine.accounts[user_idx as usize].funding_index, expected_funding_index, 1952 | "funding_index must be restored" 1953 | ); 1954 | assert_eq!( 1955 | engine.warmed_pos_total, expected_warmed_pos, 1956 | "warmed_pos_total must be restored" 1957 | ); 1958 | assert_eq!( 1959 | engine.warmed_neg_total, expected_warmed_neg, 1960 | "warmed_neg_total must be restored" 1961 | ); 1962 | assert_eq!( 1963 | engine.warmup_insurance_reserved, expected_warmup_reserved, 1964 | "warmup_insurance_reserved must be restored" 1965 | ); 1966 | 1967 | // Conservation must still hold after rollback 1968 | assert!( 1969 | engine.check_conservation(), 1970 | "Conservation must hold after harness rollback" 1971 | ); 1972 | } 1973 | --------------------------------------------------------------------------------