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 vendored Percolator engine with human-readable account names,
5//! serde-compatible snapshots, and ergonomic defaults.
6
7pub mod agent;
8pub mod percolator;
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(
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(
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(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(
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(
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 = "default_liq_buffer")]
307    pub liquidation_buffer_bps: u64,
308    #[serde(default)]
309    pub min_liquidation_abs: u64,
310    #[serde(default = "default_min_deposit")]
311    pub min_initial_deposit: u64,
312    #[serde(default = "default_mm_req")]
313    pub min_nonzero_mm_req: u64,
314    #[serde(default = "default_im_req")]
315    pub min_nonzero_im_req: u64,
316    #[serde(default)]
317    pub insurance_floor: u64,
318}
319
320fn default_warmup() -> u64 {
321    100
322}
323fn default_mm_bps() -> u64 {
324    500
325}
326fn default_im_bps() -> u64 {
327    1000
328}
329fn default_max_accounts() -> u64 {
330    64
331}
332fn default_crank_staleness() -> u64 {
333    u64::MAX
334}
335fn default_mm_req() -> u64 {
336    1
337}
338fn default_im_req() -> u64 {
339    2
340}
341fn default_min_deposit() -> u64 {
342    2
343}
344fn default_liq_buffer() -> u64 {
345    50
346}
347
348impl ParamsConfig {
349    pub fn to_risk_params(&self) -> RiskParams {
350        RiskParams {
351            warmup_period_slots: self.warmup_period_slots,
352            maintenance_margin_bps: self.maintenance_margin_bps,
353            initial_margin_bps: self.initial_margin_bps,
354            trading_fee_bps: self.trading_fee_bps,
355            max_accounts: self.max_accounts,
356            new_account_fee: U128::new(self.new_account_fee as u128),
357            maintenance_fee_per_slot: U128::new(self.maintenance_fee_per_slot as u128),
358            max_crank_staleness_slots: self.max_crank_staleness_slots,
359            liquidation_fee_bps: self.liquidation_fee_bps,
360            liquidation_fee_cap: U128::new(self.liquidation_fee_cap as u128),
361            liquidation_buffer_bps: self.liquidation_buffer_bps,
362            min_liquidation_abs: U128::new(self.min_liquidation_abs as u128),
363            min_initial_deposit: U128::new(self.min_initial_deposit as u128),
364            min_nonzero_mm_req: self.min_nonzero_mm_req as u128,
365            min_nonzero_im_req: self.min_nonzero_im_req as u128,
366            insurance_floor: U128::new(self.insurance_floor as u128),
367        }
368    }
369}
370
371impl Default for ParamsConfig {
372    fn default() -> Self {
373        Self {
374            warmup_period_slots: default_warmup(),
375            maintenance_margin_bps: default_mm_bps(),
376            initial_margin_bps: default_im_bps(),
377            trading_fee_bps: 0,
378            max_accounts: default_max_accounts(),
379            new_account_fee: 0,
380            maintenance_fee_per_slot: 0,
381            max_crank_staleness_slots: default_crank_staleness(),
382            liquidation_fee_bps: 0,
383            liquidation_fee_cap: 0,
384            liquidation_buffer_bps: default_liq_buffer(),
385            min_liquidation_abs: 0,
386            min_initial_deposit: default_min_deposit(),
387            min_nonzero_mm_req: default_mm_req(),
388            min_nonzero_im_req: default_im_req(),
389            insurance_floor: 0,
390        }
391    }
392}
393
394/// Errors from the [`NamedEngine`] wrapper layer.
395#[derive(Debug)]
396pub enum EngineError {
397    UnknownAccount(String),
398    AccountLimitReached(usize),
399    InvalidAccountName(String),
400    InvalidAmount,
401    InvalidOraclePrice(u64),
402    Risk(RiskError),
403}
404
405impl std::fmt::Display for EngineError {
406    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407        match self {
408            Self::UnknownAccount(name) => write!(f, "unknown account: {name}"),
409            Self::AccountLimitReached(max) => write!(f, "account limit reached ({max})"),
410            Self::InvalidAccountName(name) => write!(f, "invalid account name: {name:?}"),
411            Self::InvalidAmount => write!(f, "amount must be non-zero"),
412            Self::InvalidOraclePrice(p) => write!(f, "invalid oracle price: {p}"),
413            Self::Risk(e) => write!(f, "risk engine error: {e:?}"),
414        }
415    }
416}
417
418impl std::error::Error for EngineError {}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    fn default_engine() -> NamedEngine {
425        let params = ParamsConfig::default().to_risk_params();
426        NamedEngine::new(params, 0, 1000)
427    }
428
429    #[test]
430    fn deposit_withdraw_roundtrip() {
431        let mut eng = default_engine();
432        eng.deposit("alice", 100_000).unwrap();
433        let snap = eng.snapshot();
434        assert_eq!(snap.vault, 100_000);
435        assert_eq!(snap.accounts[0].capital, 100_000);
436
437        eng.withdraw("alice", 50_000).unwrap();
438        let snap = eng.snapshot();
439        assert_eq!(snap.accounts[0].capital, 50_000);
440    }
441
442    #[test]
443    fn account_overflow_rejected() {
444        let params = ParamsConfig {
445            max_accounts: 2,
446            ..Default::default()
447        };
448        let risk = params.to_risk_params();
449        let mut eng = NamedEngine::new(risk, 0, 1000);
450
451        eng.deposit("a", 100).unwrap();
452        eng.deposit("b", 100).unwrap();
453
454        let err = eng.deposit("c", 100).unwrap_err();
455        assert!(matches!(err, EngineError::AccountLimitReached(_)));
456    }
457
458    #[test]
459    fn name_resolution() {
460        let mut eng = default_engine();
461        assert!(eng.resolve("alice").is_err());
462
463        eng.deposit("alice", 1000).unwrap();
464        assert_eq!(eng.resolve("alice").unwrap(), 0);
465    }
466
467    #[test]
468    fn zero_amount_rejected() {
469        let mut eng = default_engine();
470        let err = eng.deposit("alice", 0).unwrap_err();
471        assert!(matches!(err, EngineError::InvalidAmount));
472    }
473
474    #[test]
475    fn trade_basic() {
476        let mut eng = default_engine();
477        eng.deposit("alice", 100_000).unwrap();
478        eng.deposit("bob", 100_000).unwrap();
479        eng.crank(1000, 1).unwrap();
480        eng.trade("alice", "bob", 10 * POS_SCALE as i128, 1000)
481            .unwrap();
482
483        let snap = eng.snapshot();
484        assert_eq!(snap.vault, 200_000);
485        assert!(snap.conservation);
486    }
487
488    #[test]
489    fn snapshot_serialization_roundtrip() {
490        let mut eng = default_engine();
491        eng.deposit("alice", 50_000).unwrap();
492        let snap = eng.snapshot();
493
494        let json = serde_json::to_string(&snap).unwrap();
495        let deser: EngineSnapshot = serde_json::from_str(&json).unwrap();
496        assert_eq!(deser.vault, snap.vault);
497        assert_eq!(deser.accounts.len(), 1);
498        assert_eq!(deser.accounts[0].name, "alice");
499    }
500
501    #[test]
502    fn empty_name_rejected() {
503        let mut eng = default_engine();
504        let err = eng.deposit("", 100).unwrap_err();
505        assert!(matches!(err, EngineError::InvalidAccountName(_)));
506    }
507
508    #[test]
509    fn duplicate_account_idempotent() {
510        let mut eng = default_engine();
511        eng.deposit("alice", 1000).unwrap();
512        eng.deposit("alice", 2000).unwrap();
513        assert_eq!(eng.resolve("alice").unwrap(), 0);
514        let snap = eng.snapshot();
515        assert_eq!(snap.accounts.len(), 1);
516        assert_eq!(snap.accounts[0].capital, 3000);
517    }
518
519    #[test]
520    fn invalid_oracle_price_rejected() {
521        let mut eng = default_engine();
522        assert!(eng.set_oracle(0).is_err());
523    }
524
525    #[test]
526    fn oracle_price_above_max_rejected() {
527        let mut eng = default_engine();
528        assert!(eng.set_oracle(MAX_ORACLE_PRICE + 1).is_err());
529        assert!(eng.set_oracle(MAX_ORACLE_PRICE).is_ok());
530    }
531
532    #[test]
533    fn withdraw_unknown_account_rejected() {
534        let mut eng = default_engine();
535        let err = eng.withdraw("ghost", 100).unwrap_err();
536        assert!(matches!(err, EngineError::UnknownAccount(_)));
537    }
538
539    #[test]
540    fn withdraw_zero_rejected() {
541        let mut eng = default_engine();
542        eng.deposit("alice", 1000).unwrap();
543        let err = eng.withdraw("alice", 0).unwrap_err();
544        assert!(matches!(err, EngineError::InvalidAmount));
545    }
546
547    #[test]
548    fn long_account_name_rejected() {
549        let mut eng = default_engine();
550        let name = "a".repeat(65);
551        let err = eng.deposit(&name, 100).unwrap_err();
552        assert!(matches!(err, EngineError::InvalidAccountName(_)));
553    }
554
555    #[test]
556    fn max_length_account_name_accepted() {
557        let mut eng = default_engine();
558        let name = "a".repeat(64);
559        eng.deposit(&name, 100).unwrap();
560        assert_eq!(eng.resolve(&name).unwrap(), 0);
561    }
562
563    #[test]
564    fn trade_unknown_account_rejected() {
565        let mut eng = default_engine();
566        eng.deposit("alice", 100_000).unwrap();
567        let err = eng
568            .trade("alice", "ghost", 10 * POS_SCALE as i128, 1000)
569            .unwrap_err();
570        assert!(matches!(err, EngineError::UnknownAccount(_)));
571    }
572
573    #[test]
574    fn liquidate_unknown_account_rejected() {
575        let mut eng = default_engine();
576        let err = eng.liquidate("ghost").unwrap_err();
577        assert!(matches!(err, EngineError::UnknownAccount(_)));
578    }
579
580    #[test]
581    fn settle_unknown_account_rejected() {
582        let mut eng = default_engine();
583        let err = eng.settle("ghost").unwrap_err();
584        assert!(matches!(err, EngineError::UnknownAccount(_)));
585    }
586
587    #[test]
588    fn set_slot_and_funding_rate() {
589        let mut eng = default_engine();
590        eng.set_slot(42);
591        assert_eq!(eng.current_slot, 42);
592        eng.set_funding_rate(-100);
593        assert_eq!(eng.last_funding_rate, -100);
594    }
595
596    #[test]
597    fn crank_updates_oracle_and_slot() {
598        let mut eng = default_engine();
599        eng.deposit("alice", 100_000).unwrap();
600        eng.crank(2000, 50).unwrap();
601        assert_eq!(eng.last_oracle_price, 2000);
602        assert_eq!(eng.current_slot, 50);
603    }
604
605    #[test]
606    fn snapshot_empty_engine() {
607        let eng = default_engine();
608        let snap = eng.snapshot();
609        assert_eq!(snap.vault, 0);
610        assert_eq!(snap.accounts.len(), 0);
611        assert!(snap.conservation);
612    }
613
614    #[test]
615    fn error_display() {
616        let e = EngineError::UnknownAccount("bob".to_string());
617        assert_eq!(e.to_string(), "unknown account: bob");
618
619        let e = EngineError::AccountLimitReached(64);
620        assert_eq!(e.to_string(), "account limit reached (64)");
621
622        let e = EngineError::InvalidAccountName("".to_string());
623        assert_eq!(e.to_string(), "invalid account name: \"\"");
624
625        let e = EngineError::InvalidAmount;
626        assert_eq!(e.to_string(), "amount must be non-zero");
627
628        let e = EngineError::InvalidOraclePrice(0);
629        assert_eq!(e.to_string(), "invalid oracle price: 0");
630    }
631
632    #[test]
633    fn params_config_default_roundtrip() {
634        let params = ParamsConfig::default();
635        let json = serde_json::to_string(&params).unwrap();
636        let deser: ParamsConfig = serde_json::from_str(&json).unwrap();
637        assert_eq!(deser.maintenance_margin_bps, params.maintenance_margin_bps);
638        assert_eq!(deser.initial_margin_bps, params.initial_margin_bps);
639        assert_eq!(deser.warmup_period_slots, params.warmup_period_slots);
640    }
641
642    #[test]
643    fn params_config_from_empty_json() {
644        let deser: ParamsConfig = serde_json::from_str("{}").unwrap();
645        assert_eq!(deser.maintenance_margin_bps, default_mm_bps());
646        assert_eq!(deser.initial_margin_bps, default_im_bps());
647        assert_eq!(deser.warmup_period_slots, default_warmup());
648        assert_eq!(deser.max_accounts, default_max_accounts());
649    }
650}
651
652#[cfg(kani)]
653mod proofs {
654    use super::*;
655
656    fn bounded_engine() -> NamedEngine {
657        let params = ParamsConfig {
658            max_accounts: MAX_ACCOUNTS as u64,
659            ..Default::default()
660        }
661        .to_risk_params();
662        NamedEngine::new(params, 0, 1000)
663    }
664
665    // --- Fast proofs (< 10s): input validation and pure logic ---
666
667    #[kani::proof]
668    fn prove_deposit_zero_always_fails() {
669        let mut eng = bounded_engine();
670        assert!(eng.deposit("alice", 0).is_err());
671    }
672
673    #[kani::proof]
674    fn prove_withdraw_unknown_account_fails() {
675        let eng = bounded_engine();
676        assert!(eng.resolve("nonexistent").is_err());
677    }
678
679    #[kani::proof]
680    fn prove_set_oracle_bounds() {
681        let mut eng = bounded_engine();
682        let price: u64 = kani::any();
683        let result = eng.set_oracle(price);
684        if price == 0 || price > MAX_ORACLE_PRICE {
685            assert!(result.is_err());
686        } else {
687            assert!(result.is_ok());
688            assert_eq!(eng.last_oracle_price, price);
689        }
690    }
691
692    #[kani::proof]
693    fn prove_ensure_account_name_validation() {
694        let mut eng = bounded_engine();
695        assert!(eng.deposit("", 100).is_err());
696    }
697
698    #[kani::proof]
699    fn prove_haircut_ratio_no_div_by_zero() {
700        let snap = EngineSnapshot {
701            vault: 0,
702            insurance_fund: 0,
703            haircut_numerator: kani::any(),
704            haircut_denominator: 0,
705            conservation: true,
706            current_slot: 0,
707            last_oracle_price: 1000,
708            last_funding_rate: 0,
709            side_mode_long: String::new(),
710            side_mode_short: String::new(),
711            oi_long_q: 0,
712            oi_short_q: 0,
713            lifetime_liquidations: 0,
714            accounts: vec![],
715        };
716        let h = snap.haircut_ratio_f64();
717        assert!(h == 1.0);
718    }
719
720    #[kani::proof]
721    fn prove_haircut_ratio_bounded() {
722        let num: u128 = kani::any();
723        let den: u128 = kani::any();
724        kani::assume(den > 0);
725        kani::assume(num <= den);
726
727        let snap = EngineSnapshot {
728            vault: 0,
729            insurance_fund: 0,
730            haircut_numerator: num,
731            haircut_denominator: den,
732            conservation: true,
733            current_slot: 0,
734            last_oracle_price: 1000,
735            last_funding_rate: 0,
736            side_mode_long: String::new(),
737            side_mode_short: String::new(),
738            oi_long_q: 0,
739            oi_short_q: 0,
740            lifetime_liquidations: 0,
741            accounts: vec![],
742        };
743        let h = snap.haircut_ratio_f64();
744        assert!(h >= 0.0 && h <= 1.0);
745    }
746
747    #[kani::proof]
748    fn prove_set_slot_always_succeeds() {
749        let mut eng = bounded_engine();
750        let slot: u64 = kani::any();
751        eng.set_slot(slot);
752        assert_eq!(eng.current_slot, slot);
753    }
754
755    #[kani::proof]
756    fn prove_set_funding_rate_always_succeeds() {
757        let mut eng = bounded_engine();
758        let rate: i64 = kani::any();
759        eng.set_funding_rate(rate);
760        assert_eq!(eng.last_funding_rate, rate);
761    }
762
763    #[kani::proof]
764    fn prove_params_to_risk_params_no_panic() {
765        let bps: u64 = kani::any();
766        kani::assume(bps <= 10_000);
767        let params = ParamsConfig {
768            maintenance_margin_bps: bps,
769            initial_margin_bps: bps,
770            max_accounts: MAX_ACCOUNTS as u64,
771            ..Default::default()
772        };
773        let _rp = params.to_risk_params();
774    }
775
776    // --- Slow proofs (CI-only, > 30s): require BTreeMap operations ---
777
778    #[kani::proof]
779    #[kani::unwind(6)]
780    fn prove_deposit_never_panics() {
781        let mut eng = bounded_engine();
782        let amount: u128 = kani::any();
783        kani::assume(amount <= MAX_VAULT_TVL);
784        let _ = eng.deposit("alice", amount);
785    }
786
787    #[kani::proof]
788    #[kani::unwind(6)]
789    fn prove_deposit_increases_vault() {
790        let mut eng = bounded_engine();
791        let amount: u128 = kani::any();
792        kani::assume(amount > 0 && amount <= 1_000_000_000);
793
794        let vault_before = eng.engine.vault.get();
795        let result = eng.deposit("alice", amount);
796        if result.is_ok() {
797            let vault_after = eng.engine.vault.get();
798            assert!(vault_after >= vault_before + amount);
799        }
800    }
801}