Skip to main content

percli_core/
lib.rs

1#![allow(unexpected_cfgs)]
2//! percli-core: Offline simulation adapter for the Percolator risk engine.
3//!
4//! Wraps the Percolator engine with human-readable account names,
5//! serde-compatible snapshots, and ergonomic defaults.
6
7#[cfg(not(target_os = "solana"))]
8pub mod agent;
9pub mod scenario;
10
11pub use percolator::{
12    Account, CrankOutcome, InsuranceFund, LiquidationPolicy, RiskEngine, RiskError, RiskParams,
13    SideMode, ADL_ONE, I128, MAX_ACCOUNTS, MAX_ORACLE_PRICE, MAX_VAULT_TVL, POS_SCALE, U128,
14};
15
16use serde::{Deserialize, Serialize};
17use std::collections::BTreeMap;
18
19/// Human-readable wrapper around the Percolator [`RiskEngine`].
20///
21/// Maps string account names to u16 indices, tracks oracle/slot/funding state,
22/// and provides ergonomic methods for deposits, withdrawals, trades, and cranks.
23pub struct NamedEngine {
24    pub engine: RiskEngine,
25    pub accounts: BTreeMap<String, u16>,
26    pub current_slot: u64,
27    pub last_oracle_price: u64,
28    pub last_funding_rate: i64,
29    next_idx: u16,
30}
31
32impl NamedEngine {
33    /// Create a new engine with the given risk parameters, slot, and oracle price.
34    pub fn new(params: RiskParams, init_slot: u64, init_oracle_price: u64) -> Self {
35        let engine = RiskEngine::new_with_market(params, init_slot, init_oracle_price);
36        Self {
37            engine,
38            accounts: BTreeMap::new(),
39            current_slot: init_slot,
40            last_oracle_price: init_oracle_price,
41            last_funding_rate: 0,
42            next_idx: 0,
43        }
44    }
45
46    /// Resolve an account name to its index. Returns error if not found.
47    pub fn resolve(&self, name: &str) -> Result<u16, EngineError> {
48        self.accounts
49            .get(name)
50            .copied()
51            .ok_or_else(|| EngineError::UnknownAccount(name.to_string()))
52    }
53
54    fn ensure_account(&mut self, name: &str) -> Result<u16, EngineError> {
55        if let Some(&idx) = self.accounts.get(name) {
56            return Ok(idx);
57        }
58        if name.is_empty() || name.len() > 64 {
59            return Err(EngineError::InvalidAccountName(name.to_string()));
60        }
61        let limit = self.engine.params.max_accounts as usize;
62        if (self.next_idx as usize) >= limit {
63            return Err(EngineError::AccountLimitReached(limit));
64        }
65        let idx = self.next_idx;
66        self.next_idx += 1;
67        self.accounts.insert(name.to_string(), idx);
68        Ok(idx)
69    }
70
71    /// Deposit collateral into an account. Creates the account if it doesn't exist.
72    pub fn deposit(&mut self, name: &str, amount: u128) -> Result<(), EngineError> {
73        if amount == 0 {
74            return Err(EngineError::InvalidAmount);
75        }
76        let idx = self.ensure_account(name)?;
77        self.engine
78            .deposit(idx, amount, self.last_oracle_price, self.current_slot)
79            .map_err(EngineError::Risk)
80    }
81
82    /// Withdraw collateral from an account. Fails if the account would breach margin.
83    pub fn withdraw(&mut self, name: &str, amount: u128) -> Result<(), EngineError> {
84        if amount == 0 {
85            return Err(EngineError::InvalidAmount);
86        }
87        let idx = self.resolve(name)?;
88        self.engine
89            .withdraw_not_atomic(
90                idx,
91                amount,
92                self.last_oracle_price,
93                self.current_slot,
94                self.last_funding_rate,
95            )
96            .map_err(EngineError::Risk)
97    }
98
99    /// Execute a trade between two accounts. `size_q` is in POS_SCALE units.
100    pub fn trade(
101        &mut self,
102        long: &str,
103        short: &str,
104        size_q: i128,
105        exec_price: u64,
106    ) -> Result<(), EngineError> {
107        let a = self.resolve(long)?;
108        let b = self.resolve(short)?;
109        self.engine
110            .execute_trade_not_atomic(
111                a,
112                b,
113                self.last_oracle_price,
114                self.current_slot,
115                size_q,
116                exec_price,
117                self.last_funding_rate,
118            )
119            .map_err(EngineError::Risk)
120    }
121
122    /// Run the keeper crank at the given oracle price and slot.
123    pub fn crank(&mut self, oracle_price: u64, slot: u64) -> Result<CrankOutcome, EngineError> {
124        self.last_oracle_price = oracle_price;
125        self.current_slot = slot;
126        self.engine
127            .keeper_crank_not_atomic(slot, oracle_price, &[], 0, self.last_funding_rate)
128            .map_err(EngineError::Risk)
129    }
130
131    /// Attempt to liquidate an account. Returns true if liquidation occurred.
132    pub fn liquidate(&mut self, name: &str) -> Result<bool, EngineError> {
133        let idx = self.resolve(name)?;
134        self.engine
135            .liquidate_at_oracle_not_atomic(
136                idx,
137                self.current_slot,
138                self.last_oracle_price,
139                LiquidationPolicy::FullClose,
140                self.last_funding_rate,
141            )
142            .map_err(EngineError::Risk)
143    }
144
145    /// Settle an account's PnL against the vault.
146    pub fn settle(&mut self, name: &str) -> Result<(), EngineError> {
147        let idx = self.resolve(name)?;
148        self.engine
149            .settle_account_not_atomic(
150                idx,
151                self.last_oracle_price,
152                self.current_slot,
153                self.last_funding_rate,
154            )
155            .map_err(EngineError::Risk)
156    }
157
158    pub fn set_oracle(&mut self, oracle_price: u64) -> Result<(), EngineError> {
159        if oracle_price == 0 || oracle_price > MAX_ORACLE_PRICE {
160            return Err(EngineError::InvalidOraclePrice(oracle_price));
161        }
162        self.last_oracle_price = oracle_price;
163        Ok(())
164    }
165
166    pub fn set_slot(&mut self, slot: u64) {
167        self.current_slot = slot;
168    }
169
170    pub fn set_funding_rate(&mut self, rate: i64) {
171        self.last_funding_rate = rate;
172    }
173
174    /// Capture a complete snapshot of the engine state.
175    pub fn snapshot(&self) -> EngineSnapshot {
176        let (h_num, h_den) = self.engine.haircut_ratio();
177        let conservation = self.engine.check_conservation();
178
179        let mut account_snaps = Vec::new();
180        for (name, &idx) in &self.accounts {
181            if self.engine.is_used(idx as usize) {
182                let acct = &self.engine.accounts[idx as usize];
183                let eff_pos = self.engine.effective_pos_q(idx as usize);
184                let eff_pnl = self.engine.effective_matured_pnl(idx as usize);
185                let equity_maint = self.engine.account_equity_maint_raw(acct);
186                let equity_init = self.engine.account_equity_init_raw(acct, idx as usize);
187                let above_mm = self.engine.is_above_maintenance_margin(
188                    acct,
189                    idx as usize,
190                    self.last_oracle_price,
191                );
192                let above_im =
193                    self.engine
194                        .is_above_initial_margin(acct, idx as usize, self.last_oracle_price);
195                let notional = self.engine.notional(idx as usize, self.last_oracle_price);
196                account_snaps.push(AccountSnapshot {
197                    name: name.clone(),
198                    index: idx,
199                    capital: acct.capital.get(),
200                    pnl: acct.pnl,
201                    reserved_pnl: acct.reserved_pnl,
202                    effective_position_q: eff_pos,
203                    effective_matured_pnl: eff_pnl,
204                    equity_maint,
205                    equity_init,
206                    above_maintenance_margin: above_mm,
207                    above_initial_margin: above_im,
208                    notional,
209                    fee_credits: acct.fee_credits.get(),
210                });
211            }
212        }
213
214        EngineSnapshot {
215            vault: self.engine.vault.get(),
216            insurance_fund: self.engine.insurance_fund.balance.get(),
217            haircut_numerator: h_num,
218            haircut_denominator: h_den,
219            conservation,
220            current_slot: self.current_slot,
221            last_oracle_price: self.last_oracle_price,
222            last_funding_rate: self.last_funding_rate,
223            side_mode_long: format!("{:?}", self.engine.side_mode_long),
224            side_mode_short: format!("{:?}", self.engine.side_mode_short),
225            oi_long_q: self.engine.oi_eff_long_q,
226            oi_short_q: self.engine.oi_eff_short_q,
227            lifetime_liquidations: self.engine.lifetime_liquidations,
228            accounts: account_snaps,
229        }
230    }
231}
232
233/// Complete serializable snapshot of the engine state.
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct EngineSnapshot {
236    pub vault: u128,
237    pub insurance_fund: u128,
238    pub haircut_numerator: u128,
239    pub haircut_denominator: u128,
240    pub conservation: bool,
241    pub current_slot: u64,
242    pub last_oracle_price: u64,
243    pub last_funding_rate: i64,
244    pub side_mode_long: String,
245    pub side_mode_short: String,
246    pub oi_long_q: u128,
247    pub oi_short_q: u128,
248    pub lifetime_liquidations: u64,
249    pub accounts: Vec<AccountSnapshot>,
250}
251
252impl EngineSnapshot {
253    pub fn haircut_ratio_f64(&self) -> f64 {
254        if self.haircut_denominator == 0 {
255            1.0
256        } else {
257            self.haircut_numerator as f64 / self.haircut_denominator as f64
258        }
259    }
260}
261
262/// Serializable snapshot of a single account's state.
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct AccountSnapshot {
265    pub name: String,
266    pub index: u16,
267    pub capital: u128,
268    pub pnl: i128,
269    pub reserved_pnl: u128,
270    pub effective_position_q: i128,
271    pub effective_matured_pnl: u128,
272    pub equity_maint: i128,
273    pub equity_init: i128,
274    pub above_maintenance_margin: bool,
275    pub above_initial_margin: bool,
276    pub notional: u128,
277    pub fee_credits: i128,
278}
279
280/// Serde-friendly risk parameter configuration with sensible defaults.
281///
282/// All BPS values are in basis points (1 bps = 0.01%).
283/// Converts to [`RiskParams`] via [`to_risk_params()`](ParamsConfig::to_risk_params).
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct ParamsConfig {
286    #[serde(default = "default_warmup")]
287    pub warmup_period_slots: u64,
288    #[serde(default = "default_mm_bps")]
289    pub maintenance_margin_bps: u64,
290    #[serde(default = "default_im_bps")]
291    pub initial_margin_bps: u64,
292    #[serde(default)]
293    pub trading_fee_bps: u64,
294    #[serde(default = "default_max_accounts")]
295    pub max_accounts: u64,
296    #[serde(default)]
297    pub new_account_fee: u64,
298    #[serde(default)]
299    pub maintenance_fee_per_slot: u64,
300    #[serde(default = "default_crank_staleness")]
301    pub max_crank_staleness_slots: u64,
302    #[serde(default)]
303    pub liquidation_fee_bps: u64,
304    #[serde(default)]
305    pub liquidation_fee_cap: u64,
306    #[serde(default)]
307    pub min_liquidation_abs: u64,
308    #[serde(default = "default_min_deposit")]
309    pub min_initial_deposit: u64,
310    #[serde(default = "default_mm_req")]
311    pub min_nonzero_mm_req: u64,
312    #[serde(default = "default_im_req")]
313    pub min_nonzero_im_req: u64,
314    #[serde(default)]
315    pub insurance_floor: u64,
316}
317
318fn default_warmup() -> u64 {
319    100
320}
321fn default_mm_bps() -> u64 {
322    500
323}
324fn default_im_bps() -> u64 {
325    1000
326}
327fn default_max_accounts() -> u64 {
328    64
329}
330fn default_crank_staleness() -> u64 {
331    u64::MAX
332}
333fn default_mm_req() -> u64 {
334    1
335}
336fn default_im_req() -> u64 {
337    2
338}
339fn default_min_deposit() -> u64 {
340    2
341}
342impl ParamsConfig {
343    pub fn to_risk_params(&self) -> RiskParams {
344        RiskParams {
345            warmup_period_slots: self.warmup_period_slots,
346            maintenance_margin_bps: self.maintenance_margin_bps,
347            initial_margin_bps: self.initial_margin_bps,
348            trading_fee_bps: self.trading_fee_bps,
349            max_accounts: self.max_accounts,
350            new_account_fee: U128::new(self.new_account_fee as u128),
351            maintenance_fee_per_slot: U128::new(self.maintenance_fee_per_slot as u128),
352            max_crank_staleness_slots: self.max_crank_staleness_slots,
353            liquidation_fee_bps: self.liquidation_fee_bps,
354            liquidation_fee_cap: U128::new(self.liquidation_fee_cap as u128),
355            min_liquidation_abs: U128::new(self.min_liquidation_abs as u128),
356            min_initial_deposit: U128::new(self.min_initial_deposit as u128),
357            min_nonzero_mm_req: self.min_nonzero_mm_req as u128,
358            min_nonzero_im_req: self.min_nonzero_im_req as u128,
359            insurance_floor: U128::new(self.insurance_floor as u128),
360        }
361    }
362}
363
364impl Default for ParamsConfig {
365    fn default() -> Self {
366        Self {
367            warmup_period_slots: default_warmup(),
368            maintenance_margin_bps: default_mm_bps(),
369            initial_margin_bps: default_im_bps(),
370            trading_fee_bps: 0,
371            max_accounts: default_max_accounts(),
372            new_account_fee: 0,
373            maintenance_fee_per_slot: 0,
374            max_crank_staleness_slots: default_crank_staleness(),
375            liquidation_fee_bps: 0,
376            liquidation_fee_cap: 0,
377            min_liquidation_abs: 0,
378            min_initial_deposit: default_min_deposit(),
379            min_nonzero_mm_req: default_mm_req(),
380            min_nonzero_im_req: default_im_req(),
381            insurance_floor: 0,
382        }
383    }
384}
385
386/// Errors from the [`NamedEngine`] wrapper layer.
387#[derive(Debug)]
388pub enum EngineError {
389    UnknownAccount(String),
390    AccountLimitReached(usize),
391    InvalidAccountName(String),
392    InvalidAmount,
393    InvalidOraclePrice(u64),
394    Risk(RiskError),
395}
396
397impl std::fmt::Display for EngineError {
398    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399        match self {
400            Self::UnknownAccount(name) => write!(f, "unknown account: {name}"),
401            Self::AccountLimitReached(max) => write!(f, "account limit reached ({max})"),
402            Self::InvalidAccountName(name) => write!(f, "invalid account name: {name:?}"),
403            Self::InvalidAmount => write!(f, "amount must be non-zero"),
404            Self::InvalidOraclePrice(p) => write!(f, "invalid oracle price: {p}"),
405            Self::Risk(e) => write!(f, "risk engine error: {e:?}"),
406        }
407    }
408}
409
410impl std::error::Error for EngineError {}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    fn default_engine() -> NamedEngine {
417        let params = ParamsConfig::default().to_risk_params();
418        NamedEngine::new(params, 0, 1000)
419    }
420
421    #[test]
422    fn deposit_withdraw_roundtrip() {
423        let mut eng = default_engine();
424        eng.deposit("alice", 100_000).unwrap();
425        let snap = eng.snapshot();
426        assert_eq!(snap.vault, 100_000);
427        assert_eq!(snap.accounts[0].capital, 100_000);
428
429        eng.withdraw("alice", 50_000).unwrap();
430        let snap = eng.snapshot();
431        assert_eq!(snap.accounts[0].capital, 50_000);
432    }
433
434    #[test]
435    fn account_overflow_rejected() {
436        let params = ParamsConfig {
437            max_accounts: 2,
438            ..Default::default()
439        };
440        let risk = params.to_risk_params();
441        let mut eng = NamedEngine::new(risk, 0, 1000);
442
443        eng.deposit("a", 100).unwrap();
444        eng.deposit("b", 100).unwrap();
445
446        let err = eng.deposit("c", 100).unwrap_err();
447        assert!(matches!(err, EngineError::AccountLimitReached(_)));
448    }
449
450    #[test]
451    fn name_resolution() {
452        let mut eng = default_engine();
453        assert!(eng.resolve("alice").is_err());
454
455        eng.deposit("alice", 1000).unwrap();
456        assert_eq!(eng.resolve("alice").unwrap(), 0);
457    }
458
459    #[test]
460    fn zero_amount_rejected() {
461        let mut eng = default_engine();
462        let err = eng.deposit("alice", 0).unwrap_err();
463        assert!(matches!(err, EngineError::InvalidAmount));
464    }
465
466    #[test]
467    fn trade_basic() {
468        let mut eng = default_engine();
469        eng.deposit("alice", 100_000).unwrap();
470        eng.deposit("bob", 100_000).unwrap();
471        eng.crank(1000, 1).unwrap();
472        eng.trade("alice", "bob", 10 * POS_SCALE as i128, 1000)
473            .unwrap();
474
475        let snap = eng.snapshot();
476        assert_eq!(snap.vault, 200_000);
477        assert!(snap.conservation);
478    }
479
480    #[test]
481    fn snapshot_serialization_roundtrip() {
482        let mut eng = default_engine();
483        eng.deposit("alice", 50_000).unwrap();
484        let snap = eng.snapshot();
485
486        let json = serde_json::to_string(&snap).unwrap();
487        let deser: EngineSnapshot = serde_json::from_str(&json).unwrap();
488        assert_eq!(deser.vault, snap.vault);
489        assert_eq!(deser.accounts.len(), 1);
490        assert_eq!(deser.accounts[0].name, "alice");
491    }
492
493    #[test]
494    fn empty_name_rejected() {
495        let mut eng = default_engine();
496        let err = eng.deposit("", 100).unwrap_err();
497        assert!(matches!(err, EngineError::InvalidAccountName(_)));
498    }
499
500    #[test]
501    fn duplicate_account_idempotent() {
502        let mut eng = default_engine();
503        eng.deposit("alice", 1000).unwrap();
504        eng.deposit("alice", 2000).unwrap();
505        assert_eq!(eng.resolve("alice").unwrap(), 0);
506        let snap = eng.snapshot();
507        assert_eq!(snap.accounts.len(), 1);
508        assert_eq!(snap.accounts[0].capital, 3000);
509    }
510
511    #[test]
512    fn invalid_oracle_price_rejected() {
513        let mut eng = default_engine();
514        assert!(eng.set_oracle(0).is_err());
515    }
516
517    #[test]
518    fn oracle_price_above_max_rejected() {
519        let mut eng = default_engine();
520        assert!(eng.set_oracle(MAX_ORACLE_PRICE + 1).is_err());
521        assert!(eng.set_oracle(MAX_ORACLE_PRICE).is_ok());
522    }
523
524    #[test]
525    fn withdraw_unknown_account_rejected() {
526        let mut eng = default_engine();
527        let err = eng.withdraw("ghost", 100).unwrap_err();
528        assert!(matches!(err, EngineError::UnknownAccount(_)));
529    }
530
531    #[test]
532    fn withdraw_zero_rejected() {
533        let mut eng = default_engine();
534        eng.deposit("alice", 1000).unwrap();
535        let err = eng.withdraw("alice", 0).unwrap_err();
536        assert!(matches!(err, EngineError::InvalidAmount));
537    }
538
539    #[test]
540    fn long_account_name_rejected() {
541        let mut eng = default_engine();
542        let name = "a".repeat(65);
543        let err = eng.deposit(&name, 100).unwrap_err();
544        assert!(matches!(err, EngineError::InvalidAccountName(_)));
545    }
546
547    #[test]
548    fn max_length_account_name_accepted() {
549        let mut eng = default_engine();
550        let name = "a".repeat(64);
551        eng.deposit(&name, 100).unwrap();
552        assert_eq!(eng.resolve(&name).unwrap(), 0);
553    }
554
555    #[test]
556    fn trade_unknown_account_rejected() {
557        let mut eng = default_engine();
558        eng.deposit("alice", 100_000).unwrap();
559        let err = eng
560            .trade("alice", "ghost", 10 * POS_SCALE as i128, 1000)
561            .unwrap_err();
562        assert!(matches!(err, EngineError::UnknownAccount(_)));
563    }
564
565    #[test]
566    fn liquidate_unknown_account_rejected() {
567        let mut eng = default_engine();
568        let err = eng.liquidate("ghost").unwrap_err();
569        assert!(matches!(err, EngineError::UnknownAccount(_)));
570    }
571
572    #[test]
573    fn settle_unknown_account_rejected() {
574        let mut eng = default_engine();
575        let err = eng.settle("ghost").unwrap_err();
576        assert!(matches!(err, EngineError::UnknownAccount(_)));
577    }
578
579    #[test]
580    fn set_slot_and_funding_rate() {
581        let mut eng = default_engine();
582        eng.set_slot(42);
583        assert_eq!(eng.current_slot, 42);
584        eng.set_funding_rate(-100);
585        assert_eq!(eng.last_funding_rate, -100);
586    }
587
588    #[test]
589    fn crank_updates_oracle_and_slot() {
590        let mut eng = default_engine();
591        eng.deposit("alice", 100_000).unwrap();
592        eng.crank(2000, 50).unwrap();
593        assert_eq!(eng.last_oracle_price, 2000);
594        assert_eq!(eng.current_slot, 50);
595    }
596
597    #[test]
598    fn snapshot_empty_engine() {
599        let eng = default_engine();
600        let snap = eng.snapshot();
601        assert_eq!(snap.vault, 0);
602        assert_eq!(snap.accounts.len(), 0);
603        assert!(snap.conservation);
604    }
605
606    #[test]
607    fn error_display() {
608        let e = EngineError::UnknownAccount("bob".to_string());
609        assert_eq!(e.to_string(), "unknown account: bob");
610
611        let e = EngineError::AccountLimitReached(64);
612        assert_eq!(e.to_string(), "account limit reached (64)");
613
614        let e = EngineError::InvalidAccountName("".to_string());
615        assert_eq!(e.to_string(), "invalid account name: \"\"");
616
617        let e = EngineError::InvalidAmount;
618        assert_eq!(e.to_string(), "amount must be non-zero");
619
620        let e = EngineError::InvalidOraclePrice(0);
621        assert_eq!(e.to_string(), "invalid oracle price: 0");
622    }
623
624    #[test]
625    fn params_config_default_roundtrip() {
626        let params = ParamsConfig::default();
627        let json = serde_json::to_string(&params).unwrap();
628        let deser: ParamsConfig = serde_json::from_str(&json).unwrap();
629        assert_eq!(deser.maintenance_margin_bps, params.maintenance_margin_bps);
630        assert_eq!(deser.initial_margin_bps, params.initial_margin_bps);
631        assert_eq!(deser.warmup_period_slots, params.warmup_period_slots);
632    }
633
634    #[test]
635    fn params_config_from_empty_json() {
636        let deser: ParamsConfig = serde_json::from_str("{}").unwrap();
637        assert_eq!(deser.maintenance_margin_bps, default_mm_bps());
638        assert_eq!(deser.initial_margin_bps, default_im_bps());
639        assert_eq!(deser.warmup_period_slots, default_warmup());
640        assert_eq!(deser.max_accounts, default_max_accounts());
641    }
642}
643
644#[cfg(kani)]
645mod proofs {
646    use super::*;
647
648    fn bounded_engine() -> NamedEngine {
649        let params = ParamsConfig {
650            max_accounts: MAX_ACCOUNTS as u64,
651            ..Default::default()
652        }
653        .to_risk_params();
654        NamedEngine::new(params, 0, 1000)
655    }
656
657    // --- Fast proofs (< 10s): input validation and pure logic ---
658
659    #[kani::proof]
660    fn prove_deposit_zero_always_fails() {
661        let mut eng = bounded_engine();
662        assert!(eng.deposit("alice", 0).is_err());
663    }
664
665    #[kani::proof]
666    fn prove_withdraw_unknown_account_fails() {
667        let eng = bounded_engine();
668        assert!(eng.resolve("nonexistent").is_err());
669    }
670
671    #[kani::proof]
672    fn prove_set_oracle_bounds() {
673        let mut eng = bounded_engine();
674        let price: u64 = kani::any();
675        let result = eng.set_oracle(price);
676        if price == 0 || price > MAX_ORACLE_PRICE {
677            assert!(result.is_err());
678        } else {
679            assert!(result.is_ok());
680            assert_eq!(eng.last_oracle_price, price);
681        }
682    }
683
684    #[kani::proof]
685    fn prove_ensure_account_name_validation() {
686        let mut eng = bounded_engine();
687        assert!(eng.deposit("", 100).is_err());
688    }
689
690    #[kani::proof]
691    fn prove_haircut_ratio_no_div_by_zero() {
692        let snap = EngineSnapshot {
693            vault: 0,
694            insurance_fund: 0,
695            haircut_numerator: kani::any(),
696            haircut_denominator: 0,
697            conservation: true,
698            current_slot: 0,
699            last_oracle_price: 1000,
700            last_funding_rate: 0,
701            side_mode_long: String::new(),
702            side_mode_short: String::new(),
703            oi_long_q: 0,
704            oi_short_q: 0,
705            lifetime_liquidations: 0,
706            accounts: vec![],
707        };
708        let h = snap.haircut_ratio_f64();
709        assert!(h == 1.0);
710    }
711
712    #[kani::proof]
713    fn prove_haircut_ratio_bounded() {
714        let num: u128 = kani::any();
715        let den: u128 = kani::any();
716        kani::assume(den > 0);
717        kani::assume(num <= den);
718
719        let snap = EngineSnapshot {
720            vault: 0,
721            insurance_fund: 0,
722            haircut_numerator: num,
723            haircut_denominator: den,
724            conservation: true,
725            current_slot: 0,
726            last_oracle_price: 1000,
727            last_funding_rate: 0,
728            side_mode_long: String::new(),
729            side_mode_short: String::new(),
730            oi_long_q: 0,
731            oi_short_q: 0,
732            lifetime_liquidations: 0,
733            accounts: vec![],
734        };
735        let h = snap.haircut_ratio_f64();
736        assert!(h >= 0.0 && h <= 1.0);
737    }
738
739    #[kani::proof]
740    fn prove_set_slot_always_succeeds() {
741        let mut eng = bounded_engine();
742        let slot: u64 = kani::any();
743        eng.set_slot(slot);
744        assert_eq!(eng.current_slot, slot);
745    }
746
747    #[kani::proof]
748    fn prove_set_funding_rate_always_succeeds() {
749        let mut eng = bounded_engine();
750        let rate: i64 = kani::any();
751        eng.set_funding_rate(rate);
752        assert_eq!(eng.last_funding_rate, rate);
753    }
754
755    #[kani::proof]
756    fn prove_params_to_risk_params_no_panic() {
757        let bps: u64 = kani::any();
758        kani::assume(bps <= 10_000);
759        let params = ParamsConfig {
760            maintenance_margin_bps: bps,
761            initial_margin_bps: bps,
762            max_accounts: MAX_ACCOUNTS as u64,
763            ..Default::default()
764        };
765        let _rp = params.to_risk_params();
766    }
767
768    // --- Slow proofs (CI-only, > 30s): require BTreeMap operations ---
769
770    #[kani::proof]
771    #[kani::unwind(6)]
772    fn prove_deposit_never_panics() {
773        let mut eng = bounded_engine();
774        let amount: u128 = kani::any();
775        kani::assume(amount <= MAX_VAULT_TVL);
776        let _ = eng.deposit("alice", amount);
777    }
778
779    #[kani::proof]
780    #[kani::unwind(6)]
781    fn prove_deposit_increases_vault() {
782        let mut eng = bounded_engine();
783        let amount: u128 = kani::any();
784        kani::assume(amount > 0 && amount <= 1_000_000_000);
785
786        let vault_before = eng.engine.vault.get();
787        let result = eng.deposit("alice", amount);
788        if result.is_ok() {
789            let vault_after = eng.engine.vault.get();
790            assert!(vault_after >= vault_before + amount);
791        }
792    }
793}