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