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