Skip to main content

hypercall/rsm/
engine_snapshot.rs

1use crate::vol_oracle::vol_surface_cache::VolatilitySurface;
2use async_trait::async_trait;
3use hypercall_runtime_api::{
4    AgentAuthProvider, BookSnapshotState, OrderSnapshotProvider, QuoteProvider, QuoteSnapshot,
5    RuntimeOrderSummary, SnapshotBookQuote,
6};
7use hypercall_types::api_models::Order as ApiOrder;
8use hypercall_types::WalletAddress;
9use rust_decimal::prelude::ToPrimitive;
10use rust_decimal_macros::dec;
11use serde::{Deserialize, Serialize};
12use sha3::{Digest, Keccak256};
13use std::collections::HashMap;
14use std::sync::Arc;
15use std::time::{Duration, Instant};
16
17use arc_swap::ArcSwap;
18use hypercall_recovery::RestartStateComponent;
19
20use super::ledger::{BalanceLedger, BalanceProvider, LedgerError};
21use super::restart_components::{EngineRecoveryCapture, PersistentEngineStateComponent};
22use hypercall_engine::{EngineOrderIndex, OrderBook, OrderSummary};
23
24/// Read-only snapshot of engine orderbook state, published via ArcSwap
25/// for lock-free reads by API handlers.
26#[derive(Clone, Debug)]
27pub struct EngineSnapshot {
28    pub quotes: HashMap<String, SnapshotBookQuote>,
29    pub orders_by_wallet: HashMap<WalletAddress, Vec<OrderSummary>>,
30    pub balance_ledger: BalanceLedger,
31    /// All order IDs from orderbooks with their wallet addresses.
32    /// Sourced directly from orderbook data, not the order index,
33    /// so it includes orders placed before the index was built.
34    pub all_orderbook_orders: Vec<(u64, WalletAddress)>,
35    /// Agent authorizations: owner wallet → list of (agent wallet, optional expiry ms).
36    pub agent_authorizations: HashMap<WalletAddress, Vec<(WalletAddress, Option<u64>)>>,
37    pub snapshot_published: bool,
38    pub l2_seq: i64,
39    pub published_at: Instant,
40    pub engine_state_digest: EngineStateDigest,
41}
42
43impl EngineSnapshot {
44    pub fn is_agent_authorized(&self, wallet: &WalletAddress, agent: &WalletAddress) -> bool {
45        if wallet == agent {
46            return true;
47        }
48        let now_ms = std::time::SystemTime::now()
49            .duration_since(std::time::UNIX_EPOCH)
50            .map(|d| d.as_millis() as u64)
51            .unwrap_or(0);
52        self.agent_authorizations
53            .get(wallet)
54            .and_then(|agents| {
55                agents.iter().find_map(|(a, expires)| {
56                    if a != agent {
57                        return None;
58                    }
59                    match expires {
60                        None => Some(true),
61                        Some(ts) => Some(*ts > now_ms),
62                    }
63                })
64            })
65            .unwrap_or(false)
66    }
67}
68
69/// Deterministic digest of engine-owned state at a published snapshot boundary.
70///
71/// This intentionally excludes freshness-only process state such as quote
72/// staleness timestamps. It is meant for primary-vs-standby parity checks when
73/// both sides report the same sequence.
74#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)]
75pub struct EngineStateDigest {
76    pub l2_seq: i64,
77    pub next_order_id: u64,
78    pub next_trade_id: u64,
79    pub overall_digest: String,
80    pub orders_digest: String,
81    pub orders_count: usize,
82    pub positions_digest: String,
83    pub positions_count: usize,
84    pub cash_digest: String,
85    pub cash_wallet_count: usize,
86    pub markets_digest: String,
87    pub expired_instruments_count: usize,
88    pub trading_modes_count: usize,
89    pub prices_digest: String,
90    pub spot_price_count: usize,
91    pub iv_surface_count: usize,
92    pub iv_source_timestamps_digest: String,
93    pub iv_source_timestamp_count: usize,
94    pub perp_positions_digest: String,
95    pub perp_positions_count: usize,
96    pub hypercore_equity_digest: String,
97    pub hypercore_equity_count: usize,
98    pub mmp_digest: String,
99    pub mmp_state_count: usize,
100    pub mmp_enabled_digest: String,
101    pub mmp_enabled_count: usize,
102    pub liquidation_states_digest: String,
103    pub liquidation_state_count: usize,
104    pub wallet_margin_modes_digest: String,
105    pub wallet_margin_mode_count: usize,
106    pub wallet_trading_limits_digest: String,
107    pub wallet_trading_limits_count: usize,
108    pub wallet_tiers_digest: String,
109    pub wallet_tier_count: usize,
110    pub deposit_watermarks_digest: String,
111    pub deposit_watermark_count: usize,
112    pub agent_auth_digest: String,
113    pub agent_auth_count: usize,
114    pub nonce_sets_digest: String,
115    pub nonce_signer_count: usize,
116    pub pm_settlement_digest: String,
117    pub pm_settlement_pool_count: usize,
118    pub pm_settlement_account_count: usize,
119    pub pm_settlement_request_count: usize,
120}
121
122impl EngineStateDigest {
123    pub fn empty() -> Self {
124        let empty = hex_digest(|_| {});
125        Self {
126            l2_seq: 0,
127            next_order_id: 0,
128            next_trade_id: 0,
129            overall_digest: empty.clone(),
130            orders_digest: empty.clone(),
131            orders_count: 0,
132            positions_digest: empty.clone(),
133            positions_count: 0,
134            cash_digest: empty.clone(),
135            cash_wallet_count: 0,
136            markets_digest: empty.clone(),
137            expired_instruments_count: 0,
138            trading_modes_count: 0,
139            prices_digest: empty.clone(),
140            spot_price_count: 0,
141            iv_surface_count: 0,
142            iv_source_timestamps_digest: empty.clone(),
143            iv_source_timestamp_count: 0,
144            perp_positions_digest: empty.clone(),
145            perp_positions_count: 0,
146            hypercore_equity_digest: empty.clone(),
147            hypercore_equity_count: 0,
148            mmp_digest: empty.clone(),
149            mmp_state_count: 0,
150            mmp_enabled_digest: empty.clone(),
151            mmp_enabled_count: 0,
152            liquidation_states_digest: empty.clone(),
153            liquidation_state_count: 0,
154            wallet_margin_modes_digest: empty.clone(),
155            wallet_margin_mode_count: 0,
156            wallet_trading_limits_digest: empty.clone(),
157            wallet_trading_limits_count: 0,
158            wallet_tiers_digest: empty.clone(),
159            wallet_tier_count: 0,
160            deposit_watermarks_digest: empty.clone(),
161            deposit_watermark_count: 0,
162            agent_auth_digest: empty.clone(),
163            agent_auth_count: 0,
164            nonce_sets_digest: empty,
165            nonce_signer_count: 0,
166            pm_settlement_digest: hex_digest(|_| {}),
167            pm_settlement_pool_count: 0,
168            pm_settlement_account_count: 0,
169            pm_settlement_request_count: 0,
170        }
171    }
172
173    pub fn from_ctx(ctx: &crate::rsm::engine_deps::EngineCtx, l2_seq: i64) -> Self {
174        let mut orders_count = 0usize;
175        let orders_digest = hex_digest(|h| {
176            let mut orderbooks: Vec<_> = ctx.orderbooks.iter().collect();
177            orderbooks.sort_by(|a, b| a.0.cmp(b.0));
178            for (symbol, book) in orderbooks {
179                update_str(h, symbol);
180                let mut orders = book.get_all_orders();
181                orders.sort_by_key(|order| order.order_id);
182                update_u64(h, orders.len() as u64);
183                orders_count += orders.len();
184                for order in orders {
185                    update_u64(h, order.order_id);
186                    update_decimal(h, &order.price);
187                    update_decimal(h, &order.quantity);
188                    update_str(h, &format!("{:?}", order.side));
189                    update_wallet(h, &order.wallet);
190                    update_u64(h, order.timestamp);
191                    update_opt_str(h, order.client_id.as_deref());
192                    update_bool(h, order.mmp_enabled);
193                    update_decimal(h, &order.original_size);
194                }
195            }
196        });
197
198        let positions_digest = hex_digest(|h| {
199            let mut positions: Vec<_> = ctx.engine_positions.iter().collect();
200            positions.sort_by(|((wallet_a, symbol_a), _), ((wallet_b, symbol_b), _)| {
201                wallet_a.cmp(wallet_b).then_with(|| symbol_a.cmp(symbol_b))
202            });
203            for ((wallet, symbol), position) in positions {
204                update_wallet(h, wallet);
205                update_str(h, symbol);
206                update_decimal(h, &position.quantity);
207                update_decimal(h, &position.entry_price);
208            }
209        });
210
211        let cash_digest = hex_digest(|h| {
212            let cash = ctx.balance_ledger.sorted_nonzero_entries();
213            for (wallet, balance) in cash {
214                update_wallet(h, &wallet);
215                update_decimal(h, &balance);
216            }
217        });
218
219        let markets_digest = hex_digest(|h| {
220            let mut expired: Vec<_> = ctx.expired_instruments.iter().collect();
221            expired.sort_by(|a, b| a.0.cmp(b.0));
222            for (symbol, is_expired) in expired {
223                update_str(h, symbol);
224                update_bool(h, *is_expired);
225            }
226
227            let mut trading_modes: Vec<_> = ctx.instrument_trading_modes.iter().collect();
228            trading_modes.sort_by(|a, b| a.0.cmp(b.0));
229            for (symbol, mode) in trading_modes {
230                update_str(h, symbol);
231                update_str(h, &format!("{:?}", mode));
232            }
233        });
234
235        let prices_digest = hex_digest(|h| {
236            let mut spot_prices: Vec<_> = ctx.spot_prices.iter().collect();
237            spot_prices.sort_by(|a, b| a.0.cmp(b.0));
238            for (underlying, price) in spot_prices {
239                update_str(h, underlying);
240                update_decimal(h, price);
241            }
242
243            let mut iv_surfaces: Vec<_> = ctx.iv_surfaces.iter().collect();
244            iv_surfaces.sort_by(|a, b| a.0.cmp(b.0));
245            for (underlying, surface) in iv_surfaces {
246                update_str(h, underlying);
247                update_volatility_surface(h, surface);
248            }
249        });
250
251        let iv_source_timestamps_digest = hex_digest(|h| {
252            let mut timestamps: Vec<_> = ctx.iv_source_timestamps.iter().collect();
253            timestamps.sort_by(|a, b| a.0.cmp(b.0));
254            for (underlying, timestamp) in timestamps {
255                update_str(h, underlying);
256                update_i64(h, *timestamp);
257            }
258        });
259
260        let perp_positions_digest = hex_digest(|h| {
261            let mut positions: Vec<_> = ctx.deps.perp_positions.iter().collect();
262            positions.sort_by(|((account_a, coin_a), _), ((account_b, coin_b), _)| {
263                account_a.cmp(account_b).then_with(|| coin_a.cmp(coin_b))
264            });
265            for ((account, coin), position) in positions {
266                update_str(h, account);
267                update_str(h, coin);
268                update_str(h, &position.coin);
269                update_f64(h, position.size);
270                update_opt_f64(h, position.entry_price);
271                update_f64(h, position.position_value);
272                update_f64(h, position.unrealized_pnl);
273                update_f64(h, position.margin_used);
274                update_opt_f64(h, position.liquidation_price);
275            }
276        });
277
278        let hypercore_equity_digest = hex_digest(|h| {
279            let mut equities: Vec<_> = ctx.deps.hypercore_account_equity.iter().collect();
280            equities.sort_by_key(|(wallet, _)| **wallet);
281            for (wallet, equity) in equities {
282                update_wallet(h, wallet);
283                update_decimal(h, equity);
284                let timestamp = ctx.deps.hypercore_equity_timestamps.get(wallet);
285                update_opt_u64(h, timestamp.copied());
286            }
287
288            let mut timestamp_only_wallets: Vec<_> = ctx
289                .deps
290                .hypercore_equity_timestamps
291                .iter()
292                .filter(|(wallet, _)| !ctx.deps.hypercore_account_equity.contains_key(wallet))
293                .collect();
294            timestamp_only_wallets.sort_by_key(|(wallet, _)| **wallet);
295            for (wallet, timestamp) in timestamp_only_wallets {
296                update_wallet(h, wallet);
297                update_u64(h, *timestamp);
298            }
299        });
300
301        let mmp_digest = hex_digest(|h| {
302            let mut states: Vec<_> = ctx.mmp_state.iter().collect();
303            states.sort_by(|((wallet_a, currency_a), _), ((wallet_b, currency_b), _)| {
304                wallet_a
305                    .cmp(wallet_b)
306                    .then_with(|| currency_a.cmp(currency_b))
307            });
308            for ((wallet, currency), state) in states {
309                update_wallet(h, wallet);
310                update_str(h, currency);
311                update_bool(h, state.enabled);
312                update_i64(h, state.interval_ms);
313                update_i64(h, state.frozen_time_ms);
314                update_opt_f64(h, state.qty_limit);
315                update_opt_f64(h, state.delta_limit);
316                update_opt_f64(h, state.vega_limit);
317                update_u64(h, state.cumulative_qty);
318                update_f64(h, state.cumulative_delta);
319                update_f64(h, state.cumulative_vega);
320                update_opt_u64(h, state.frozen_until);
321                update_u64(h, state.fills.len() as u64);
322                for fill in &state.fills {
323                    update_u64(h, fill.timestamp_ms);
324                    update_u64(h, fill.quantity);
325                    update_f64(h, fill.delta);
326                    update_f64(h, fill.vega);
327                }
328            }
329        });
330
331        let mmp_enabled_digest = hex_digest(|h| {
332            let mut configs: Vec<_> = ctx.deps.mmp_enabled.iter().collect();
333            configs.sort_by(|((wallet_a, currency_a), _), ((wallet_b, currency_b), _)| {
334                wallet_a
335                    .cmp(wallet_b)
336                    .then_with(|| currency_a.cmp(currency_b))
337            });
338            for ((wallet, currency), enabled) in configs {
339                update_wallet(h, wallet);
340                update_str(h, currency);
341                update_bool(h, *enabled);
342            }
343        });
344
345        let liquidation_states_digest = hex_digest(|h| {
346            let mut states: Vec<_> = ctx.deps.liquidation_states.iter().collect();
347            states.sort_by_key(|(wallet, _)| **wallet);
348            for (wallet, state) in states {
349                update_wallet(h, wallet);
350                update_str(h, &format!("{:?}", state));
351            }
352        });
353
354        let wallet_margin_modes_digest = hex_digest(|h| {
355            let mut modes: Vec<_> = ctx.deps.wallet_margin_modes.iter().collect();
356            modes.sort_by_key(|(wallet, _)| **wallet);
357            for (wallet, mode) in modes {
358                update_wallet(h, wallet);
359                update_str(h, &format!("{:?}", mode));
360            }
361        });
362
363        let wallet_trading_limits_digest = hex_digest(|h| {
364            update_trading_limits(h, &ctx.deps.default_trading_limits);
365            let mut limits: Vec<_> = ctx.deps.wallet_trading_limits.iter().collect();
366            limits.sort_by_key(|(wallet, _)| **wallet);
367            for (wallet, trading_limits) in limits {
368                update_wallet(h, wallet);
369                update_trading_limits(h, trading_limits);
370            }
371        });
372
373        let wallet_tiers_digest = hex_digest(|h| {
374            let mut tiers: Vec<_> = ctx.deps.wallet_tiers.iter().collect();
375            tiers.sort_by_key(|(wallet, _)| **wallet);
376            for (wallet, tier) in tiers {
377                update_wallet(h, wallet);
378                update_str(h, tier);
379            }
380        });
381
382        let deposit_watermarks_digest = hex_digest(|h| {
383            let mut watermarks: Vec<_> = ctx.deposit_update_watermarks.iter().collect();
384            watermarks.sort_by_key(|(wallet, _)| **wallet);
385            for (wallet, watermark) in watermarks {
386                update_wallet(h, wallet);
387                update_opt_u64(h, watermark.sequence);
388                update_u64(h, watermark.timestamp_ms);
389                update_decimal(h, &watermark.balance_after);
390            }
391            update_len(h, ctx.applied_deposit_source_event_hashes.len());
392            for source_hash in &ctx.applied_deposit_source_event_hashes {
393                h.update(source_hash.as_slice());
394            }
395        });
396
397        let agent_auth_count: usize = ctx.agent_authorizations.values().map(|s| s.len()).sum();
398        let agent_auth_digest = hex_digest(|h| {
399            let mut wallets: Vec<_> = ctx.agent_authorizations.iter().collect();
400            wallets.sort_by_key(|(w, _)| **w);
401            for (wallet, agents) in wallets {
402                update_wallet(h, wallet);
403                let mut sorted_agents: Vec<_> = agents.iter().collect();
404                sorted_agents.sort_by_key(|(a, _)| *a);
405                update_u64(h, sorted_agents.len() as u64);
406                for (agent, expires) in sorted_agents {
407                    update_wallet(h, agent);
408                    update_opt_u64(h, *expires);
409                }
410            }
411        });
412
413        let nonce_signer_count = ctx.nonce_sets.len();
414        let nonce_sets_digest = hex_digest(|h| {
415            let mut signers: Vec<_> = ctx.nonce_sets.iter().collect();
416            signers.sort_by_key(|(w, _)| **w);
417            for (signer, set) in signers {
418                update_wallet(h, signer);
419                update_u64(h, set.count() as u64);
420                for &nonce in set.iter() {
421                    update_u64(h, nonce);
422                }
423            }
424        });
425
426        let pm_settlement_digest = hex_digest(|h| {
427            update_pm_settlement_state(h, &ctx.pm_settlement_state);
428        });
429
430        let next_order_id = ctx.next_order_id;
431        let next_trade_id = ctx.next_trade_id;
432        let overall_digest = hex_digest(|h| {
433            update_i64(h, l2_seq);
434            update_u64(h, next_order_id);
435            update_u64(h, next_trade_id);
436            update_str(h, &orders_digest);
437            update_str(h, &positions_digest);
438            update_str(h, &cash_digest);
439            update_str(h, &markets_digest);
440            update_str(h, &prices_digest);
441            update_str(h, &iv_source_timestamps_digest);
442            update_str(h, &perp_positions_digest);
443            update_str(h, &hypercore_equity_digest);
444            update_str(h, &mmp_digest);
445            update_str(h, &mmp_enabled_digest);
446            update_str(h, &liquidation_states_digest);
447            update_str(h, &wallet_margin_modes_digest);
448            update_str(h, &wallet_trading_limits_digest);
449            update_str(h, &wallet_tiers_digest);
450            update_str(h, &deposit_watermarks_digest);
451            update_str(h, &agent_auth_digest);
452            update_str(h, &nonce_sets_digest);
453            update_str(h, &pm_settlement_digest);
454        });
455
456        Self {
457            l2_seq,
458            next_order_id,
459            next_trade_id,
460            overall_digest,
461            orders_digest,
462            orders_count,
463            positions_digest,
464            positions_count: ctx.engine_positions.len(),
465            cash_digest,
466            cash_wallet_count: ctx.balance_ledger.len(),
467            markets_digest,
468            expired_instruments_count: ctx.expired_instruments.len(),
469            trading_modes_count: ctx.instrument_trading_modes.len(),
470            prices_digest,
471            spot_price_count: ctx.spot_prices.len(),
472            iv_surface_count: ctx.iv_surfaces.len(),
473            iv_source_timestamps_digest,
474            iv_source_timestamp_count: ctx.iv_source_timestamps.len(),
475            perp_positions_digest,
476            perp_positions_count: ctx.deps.perp_positions.len(),
477            hypercore_equity_digest,
478            hypercore_equity_count: ctx.deps.hypercore_account_equity.len(),
479            mmp_digest,
480            mmp_state_count: ctx.mmp_state.len(),
481            mmp_enabled_digest,
482            mmp_enabled_count: ctx.deps.mmp_enabled.len(),
483            liquidation_states_digest,
484            liquidation_state_count: ctx.deps.liquidation_states.len(),
485            wallet_margin_modes_digest,
486            wallet_margin_mode_count: ctx.deps.wallet_margin_modes.len(),
487            wallet_trading_limits_digest,
488            wallet_trading_limits_count: ctx.deps.wallet_trading_limits.len(),
489            wallet_tiers_digest,
490            wallet_tier_count: ctx.deps.wallet_tiers.len(),
491            deposit_watermarks_digest,
492            deposit_watermark_count: ctx.deposit_update_watermarks.len(),
493            agent_auth_digest,
494            agent_auth_count,
495            nonce_sets_digest,
496            nonce_signer_count,
497            pm_settlement_digest,
498            pm_settlement_pool_count: ctx.pm_settlement_state.pools.len(),
499            pm_settlement_account_count: ctx.pm_settlement_state.accounts.len(),
500            pm_settlement_request_count: ctx.pm_settlement_state.idempotency.len(),
501        }
502    }
503}
504
505impl EngineSnapshot {
506    /// Build a snapshot from the engine's live orderbooks and order index.
507    pub fn build(
508        orderbooks: &HashMap<String, OrderBook>,
509        order_index: &EngineOrderIndex,
510        l2_seq: i64,
511    ) -> Self {
512        Self::build_with_digest(
513            orderbooks,
514            order_index,
515            BalanceLedger::new(),
516            HashMap::new(),
517            l2_seq,
518            EngineStateDigest::empty(),
519        )
520    }
521
522    /// Build a snapshot from the full engine context, including parity digest state.
523    pub fn build_with_ctx(ctx: &crate::rsm::engine_deps::EngineCtx, l2_seq: i64) -> Self {
524        let agent_authorizations: HashMap<WalletAddress, Vec<(WalletAddress, Option<u64>)>> = ctx
525            .agent_authorizations
526            .iter()
527            .map(|(wallet, agents)| {
528                let mut sorted: Vec<(WalletAddress, Option<u64>)> =
529                    agents.iter().map(|(a, e)| (*a, *e)).collect();
530                sorted.sort_by_key(|(a, _)| *a);
531                (*wallet, sorted)
532            })
533            .collect();
534
535        Self::build_with_digest(
536            &ctx.orderbooks,
537            &ctx.order_index,
538            ctx.balance_ledger.clone(),
539            agent_authorizations,
540            l2_seq,
541            PersistentEngineStateComponent::digest(&EngineRecoveryCapture::for_digest(ctx, l2_seq)),
542        )
543    }
544
545    fn build_with_digest(
546        orderbooks: &HashMap<String, OrderBook>,
547        order_index: &EngineOrderIndex,
548        balance_ledger: BalanceLedger,
549        agent_authorizations: HashMap<WalletAddress, Vec<(WalletAddress, Option<u64>)>>,
550        l2_seq: i64,
551        engine_state_digest: EngineStateDigest,
552    ) -> Self {
553        let mut quotes = HashMap::with_capacity(orderbooks.len());
554
555        for (symbol, book) in orderbooks {
556            let best_bid = book.get_best_bid().and_then(|d| d.to_f64());
557            let best_ask = book.get_best_ask().and_then(|d| d.to_f64());
558
559            let bid_depth = book.get_bid_depth();
560            let ask_depth = book.get_ask_depth();
561
562            let best_bid_size = bid_depth.first().and_then(|(_, sz)| sz.to_f64());
563            let best_ask_size = ask_depth.first().and_then(|(_, sz)| sz.to_f64());
564
565            let mid = match (best_bid, best_ask) {
566                (Some(b), Some(a)) if b > 0.0 && a > 0.0 => Some((b + a) / 2.0),
567                (Some(b), None) if b > 0.0 => Some(b),
568                (None, Some(a)) if a > 0.0 => Some(a),
569                _ => None,
570            };
571
572            let bids: Vec<(f64, f64)> = bid_depth
573                .iter()
574                .filter_map(|(p, s)| Some((p.to_f64()?, s.to_f64()?)))
575                .collect();
576
577            let asks: Vec<(f64, f64)> = ask_depth
578                .iter()
579                .filter_map(|(p, s)| Some((p.to_f64()?, s.to_f64()?)))
580                .collect();
581
582            quotes.insert(
583                symbol.clone(),
584                SnapshotBookQuote {
585                    best_bid,
586                    best_bid_size,
587                    best_ask,
588                    best_ask_size,
589                    mid,
590                    bids,
591                    asks,
592                },
593            );
594        }
595
596        let orders_by_wallet = order_index.snapshot_orders();
597
598        // Collect all orders directly from orderbooks (not order index).
599        // This captures orders that predate the order index (e.g., orphans
600        // from previous MM instances that were loaded from the WAL snapshot).
601        let total_orderbook_orders = orderbooks.values().map(OrderBook::order_count).sum();
602        let mut all_orderbook_orders = Vec::with_capacity(total_orderbook_orders);
603        for book in orderbooks.values() {
604            book.append_order_wallets(&mut all_orderbook_orders);
605        }
606
607        Self {
608            quotes,
609            orders_by_wallet,
610            balance_ledger,
611            all_orderbook_orders,
612            agent_authorizations,
613            snapshot_published: true,
614            l2_seq,
615            published_at: Instant::now(),
616            engine_state_digest,
617        }
618    }
619
620    /// Create an empty snapshot (used at startup before first publish).
621    pub fn empty() -> Self {
622        Self {
623            quotes: HashMap::new(),
624            orders_by_wallet: HashMap::new(),
625            balance_ledger: BalanceLedger::new(),
626            all_orderbook_orders: Vec::new(),
627            agent_authorizations: HashMap::new(),
628            snapshot_published: false,
629            l2_seq: 0,
630            published_at: Instant::now(),
631            engine_state_digest: EngineStateDigest::empty(),
632        }
633    }
634}
635
636fn hex_digest(build: impl FnOnce(&mut Keccak256)) -> String {
637    let mut hasher = Keccak256::new();
638    build(&mut hasher);
639    format!("0x{}", hex::encode(hasher.finalize()))
640}
641
642fn update_len(hasher: &mut Keccak256, len: usize) {
643    hasher.update((len as u64).to_le_bytes());
644}
645
646fn update_str(hasher: &mut Keccak256, value: &str) {
647    update_len(hasher, value.len());
648    hasher.update(value.as_bytes());
649}
650
651fn update_uuid(hasher: &mut Keccak256, value: &uuid::Uuid) {
652    hasher.update(value.as_bytes());
653}
654
655fn update_opt_str(hasher: &mut Keccak256, value: Option<&str>) {
656    update_bool(hasher, value.is_some());
657    if let Some(value) = value {
658        update_str(hasher, value);
659    }
660}
661
662fn update_bool(hasher: &mut Keccak256, value: bool) {
663    hasher.update([value as u8]);
664}
665
666fn update_u64(hasher: &mut Keccak256, value: u64) {
667    hasher.update(value.to_le_bytes());
668}
669
670fn update_opt_u64(hasher: &mut Keccak256, value: Option<u64>) {
671    update_bool(hasher, value.is_some());
672    if let Some(value) = value {
673        update_u64(hasher, value);
674    }
675}
676
677fn update_i64(hasher: &mut Keccak256, value: i64) {
678    hasher.update(value.to_le_bytes());
679}
680
681fn update_opt_i64(hasher: &mut Keccak256, value: Option<i64>) {
682    update_bool(hasher, value.is_some());
683    if let Some(value) = value {
684        update_i64(hasher, value);
685    }
686}
687
688fn update_i32(hasher: &mut Keccak256, value: i32) {
689    hasher.update(value.to_le_bytes());
690}
691
692fn update_f64(hasher: &mut Keccak256, value: f64) {
693    hasher.update(value.to_bits().to_le_bytes());
694}
695
696fn update_opt_f64(hasher: &mut Keccak256, value: Option<f64>) {
697    update_bool(hasher, value.is_some());
698    if let Some(value) = value {
699        update_f64(hasher, value);
700    }
701}
702
703fn update_decimal(hasher: &mut Keccak256, value: &rust_decimal::Decimal) {
704    update_str(hasher, &value.normalize().to_string());
705}
706
707fn update_wallet(hasher: &mut Keccak256, wallet: &WalletAddress) {
708    update_str(hasher, &wallet.to_string());
709}
710
711fn update_u32(hasher: &mut Keccak256, value: u32) {
712    hasher.update(value.to_le_bytes());
713}
714
715fn update_opt_decimal(hasher: &mut Keccak256, value: Option<&rust_decimal::Decimal>) {
716    update_bool(hasher, value.is_some());
717    if let Some(value) = value {
718        update_decimal(hasher, value);
719    }
720}
721
722fn update_pm_settlement_config(
723    hasher: &mut Keccak256,
724    config: &hypercall_margin::portfolio::PmSettlementPoolConfig,
725) {
726    update_decimal(hasher, &config.target_short_oi_notional_multiplier);
727    update_decimal(hasher, &config.utilization_kink);
728    update_decimal(hasher, &config.apr_at_kink);
729    update_decimal(hasher, &config.max_apr);
730    update_decimal(hasher, &config.normal_utilization_cap);
731    update_decimal(hasher, &config.crisis_utilization_cap);
732    update_i64(hasher, config.bridge_window_ms);
733    update_u32(hasher, config.policy_version);
734}
735
736fn update_pm_settlement_state(
737    hasher: &mut Keccak256,
738    state: &crate::rsm::portfolio_margin::settlement_state::PmSettlementState,
739) {
740    update_u64(hasher, state.pools.len() as u64);
741    for (underlying, pool) in &state.pools {
742        update_str(hasher, underlying);
743        update_str(hasher, &pool.underlying);
744        update_decimal(hasher, &pool.pool_available_usdc);
745        update_decimal(hasher, &pool.pool_target_usdc);
746        update_decimal(hasher, &pool.active_timing_bridge_usdc);
747        update_decimal(hasher, &pool.active_settlement_debt_usdc);
748        update_u32(hasher, pool.config_version);
749        update_u32(hasher, pool.policy_version);
750        update_bool(hasher, pool.config.is_some());
751        if let Some(config) = &pool.config {
752            update_pm_settlement_config(hasher, config);
753        }
754        update_opt_decimal(hasher, pool.utilization.as_ref());
755        update_u64(hasher, pool.updated_at_ms);
756    }
757
758    update_u64(hasher, state.accounts.len() as u64);
759    for (key, account) in &state.accounts {
760        update_wallet(hasher, &key.wallet);
761        update_str(hasher, &key.underlying);
762        update_wallet(hasher, &account.wallet);
763        update_str(hasher, &account.underlying);
764        update_decimal(hasher, &account.bridge_principal_usdc);
765        update_decimal(hasher, &account.debt_principal_usdc);
766        update_decimal(hasher, &account.accrued_interest_usdc);
767        update_i64(hasher, account.last_interest_accrual_ms);
768        update_u32(hasher, account.policy_version);
769        update_opt_i64(hasher, account.bridge_deadline_ms);
770        update_str(hasher, &format!("{:?}", account.status));
771        update_opt_str(hasher, account.active_recovery_plan_id.as_deref());
772        update_u64(hasher, account.updated_at_ms);
773    }
774
775    update_u64(hasher, state.events.len() as u64);
776    for (key, event) in &state.events {
777        update_wallet(hasher, &key.wallet);
778        update_str(hasher, &key.market_id);
779        update_i64(hasher, key.expiry_ts_ms);
780        update_str(hasher, &key.margin_mode);
781        update_u64(hasher, key.settlement_event_sequence);
782        update_wallet(hasher, &event.event_key.wallet);
783        update_str(hasher, &event.event_key.market_id);
784        update_i64(hasher, event.event_key.expiry_ts_ms);
785        update_str(hasher, &event.event_key.margin_mode);
786        update_u64(hasher, event.event_key.settlement_event_sequence);
787        update_str(hasher, &event.input_digest);
788        update_str(hasher, &event.output_digest);
789        update_str(hasher, &event.status);
790        update_uuid(hasher, &event.request_id);
791        update_str(hasher, &event.underlying);
792        update_str(hasher, &event.event_type);
793        update_decimal(hasher, &event.amount_usdc);
794    }
795
796    update_u64(hasher, state.idempotency.len() as u64);
797    for (request_id, record) in &state.idempotency {
798        update_str(hasher, request_id);
799        update_uuid(hasher, &record.request_id);
800        update_str(hasher, &record.input_digest);
801        update_str(hasher, &record.output_digest);
802        update_str(hasher, &record.command_type);
803        update_u64(hasher, record.applied_at_ms);
804    }
805
806    update_u64(hasher, state.recovery_plans.len() as u64);
807    for (plan_id, plan) in &state.recovery_plans {
808        update_str(hasher, plan_id);
809        update_str(hasher, &plan.plan_id);
810        update_wallet(hasher, &plan.wallet);
811        update_str(hasher, &plan.underlying);
812        update_str(hasher, &plan.trigger);
813        update_str(hasher, &plan.reason);
814        update_u32(hasher, plan.policy_version);
815        update_u32(hasher, plan.recovery_priority_version);
816        update_str(hasher, &plan.status);
817        update_str(hasher, &plan.input_digest);
818        update_decimal(hasher, &plan.target_reduction_usdc);
819        update_decimal(hasher, &plan.expected_usdc_recovered);
820        update_decimal(hasher, &plan.expected_obligation_reduced);
821        update_decimal(hasher, &plan.expected_impact_usdc);
822        update_opt_decimal(hasher, plan.post_plan_utilization.as_ref());
823        update_u64(hasher, plan.actions.len() as u64);
824        for action in &plan.actions {
825            update_u32(hasher, action.action_index);
826            update_str(hasher, &action.action_type);
827            update_str(hasher, &action.target);
828            update_str(hasher, &action.action_payload);
829            update_str(hasher, &action.status);
830            update_decimal(hasher, &action.expected_usdc_recovered);
831            update_decimal(hasher, &action.expected_obligation_reduced);
832            update_decimal(hasher, &action.expected_impact_usdc);
833            update_u32(hasher, action.attempt);
834            update_opt_str(hasher, action.submitted_external_id.as_deref());
835            update_opt_str(hasher, action.external_kind.as_deref());
836            update_opt_str(hasher, action.result.as_deref());
837            update_opt_str(hasher, action.result_external_id.as_deref());
838            update_decimal(hasher, &action.recovered_usdc);
839            update_decimal(hasher, &action.liability_reduction_usdc);
840            update_u64(hasher, action.updated_at_ms);
841        }
842        update_u64(hasher, plan.updated_at_ms);
843    }
844}
845
846fn update_trading_limits(
847    hasher: &mut Keccak256,
848    limits: &hypercall_types::api_models::TradingLimits,
849) {
850    update_i32(hasher, limits.max_open_orders);
851    update_i32(hasher, limits.max_open_positions);
852    update_i32(hasher, limits.orders_per_minute);
853    update_i32(hasher, limits.cancels_per_minute);
854    update_i32(hasher, limits.api_requests_per_minute);
855}
856
857fn update_volatility_surface(hasher: &mut Keccak256, surface: &VolatilitySurface) {
858    let mut strike_points = surface.export_all_points();
859    strike_points.sort_by(|a, b| {
860        a.expiry
861            .cmp(&b.expiry)
862            .then_with(|| a.strike.total_cmp(&b.strike))
863            .then_with(|| a.iv.total_cmp(&b.iv))
864    });
865    update_u64(hasher, strike_points.len() as u64);
866    for point in strike_points {
867        update_f64(hasher, point.strike);
868        update_i64(hasher, point.expiry);
869        update_f64(hasher, point.iv);
870    }
871
872    let mut atm_vols = surface.export_atm_vols();
873    atm_vols.sort_by_key(|(expiry, _)| *expiry);
874    update_u64(hasher, atm_vols.len() as u64);
875    for (expiry, iv) in atm_vols {
876        update_i64(hasher, expiry);
877        update_f64(hasher, iv);
878    }
879
880    let mut delta_curves = surface.export_delta_curves();
881    delta_curves.sort_by_key(|curve| curve.expiry);
882    update_u64(hasher, delta_curves.len() as u64);
883    for mut curve in delta_curves {
884        update_i64(hasher, curve.expiry);
885        curve.points.sort_by(|a, b| {
886            a.delta
887                .total_cmp(&b.delta)
888                .then_with(|| a.iv.total_cmp(&b.iv))
889        });
890        update_u64(hasher, curve.points.len() as u64);
891        for point in curve.points {
892            update_f64(hasher, point.delta);
893            update_f64(hasher, point.iv);
894        }
895    }
896}
897
898pub trait EngineStateDigestProvider: Send + Sync {
899    fn engine_state_digest(&self) -> EngineStateDigest;
900}
901
902/// Production implementation of QuoteProvider backed by ArcSwap<EngineSnapshot>.
903///
904/// The engine publishes snapshots into the ArcSwap; this provider loads them
905/// lock-free for API reads.
906pub struct SnapshotQuoteProvider {
907    snapshot: Arc<ArcSwap<EngineSnapshot>>,
908}
909
910impl SnapshotQuoteProvider {
911    pub fn new(snapshot: Arc<ArcSwap<EngineSnapshot>>) -> Self {
912        Self { snapshot }
913    }
914
915    /// Get the underlying ArcSwap handle (needed by the engine to publish into).
916    pub fn snapshot_handle(&self) -> &Arc<ArcSwap<EngineSnapshot>> {
917        &self.snapshot
918    }
919
920    pub fn balance_ledger_sync_snapshot(&self) -> (crate::rsm::ledger::BalanceLedger, u64) {
921        let snapshot = self.snapshot.load();
922        (
923            snapshot.balance_ledger.clone(),
924            snapshot.balance_ledger.last_balance_update_seq(),
925        )
926    }
927}
928
929impl QuoteProvider for SnapshotQuoteProvider {
930    fn get_quote(&self, symbol: &str) -> Option<SnapshotBookQuote> {
931        self.snapshot.load().quotes.get(symbol).cloned()
932    }
933
934    fn get_quote_with_seq(&self, symbol: &str) -> (Option<SnapshotBookQuote>, i64) {
935        let snap = self.snapshot.load();
936        (snap.quotes.get(symbol).cloned(), snap.l2_seq)
937    }
938
939    fn book_snapshot_state(&self, symbol: &str) -> BookSnapshotState {
940        let snap = self.snapshot.load();
941        if !snap.snapshot_published {
942            return BookSnapshotState::NotReady {
943                l2_seq: snap.l2_seq,
944            };
945        }
946
947        BookSnapshotState::Ready {
948            quote: snap
949                .quotes
950                .get(symbol)
951                .cloned()
952                .unwrap_or_else(SnapshotBookQuote::empty),
953            l2_seq: snap.l2_seq,
954        }
955    }
956
957    fn all_quotes(&self) -> HashMap<String, SnapshotBookQuote> {
958        self.snapshot.load().quotes.clone()
959    }
960
961    fn l2_seq(&self) -> i64 {
962        self.snapshot.load().l2_seq
963    }
964
965    fn snapshot(&self) -> QuoteSnapshot {
966        let snap = self.snapshot.load();
967        QuoteSnapshot {
968            quotes: snap.quotes.clone(),
969            l2_seq: snap.l2_seq,
970        }
971    }
972
973    fn staleness(&self) -> Duration {
974        self.snapshot.load().published_at.elapsed()
975    }
976}
977
978impl EngineStateDigestProvider for SnapshotQuoteProvider {
979    fn engine_state_digest(&self) -> EngineStateDigest {
980        self.snapshot.load().engine_state_digest.clone()
981    }
982}
983
984impl AgentAuthProvider for SnapshotQuoteProvider {
985    fn is_agent_authorized(&self, wallet: &WalletAddress, agent: &WalletAddress) -> bool {
986        self.snapshot.load().is_agent_authorized(wallet, agent)
987    }
988
989    fn get_authorized_agents(&self, wallet: &WalletAddress) -> Vec<WalletAddress> {
990        let now_ms = std::time::SystemTime::now()
991            .duration_since(std::time::UNIX_EPOCH)
992            .map(|d| d.as_millis() as u64)
993            .unwrap_or(0);
994        self.snapshot
995            .load()
996            .agent_authorizations
997            .get(wallet)
998            .map(|agents| {
999                agents
1000                    .iter()
1001                    .filter(|(_, expires)| expires.map_or(true, |ts| ts > now_ms))
1002                    .map(|(a, _)| *a)
1003                    .collect()
1004            })
1005            .unwrap_or_default()
1006    }
1007}
1008
1009impl OrderSnapshotProvider for SnapshotQuoteProvider {
1010    fn get_open_orders_for_wallet(&self, wallet: &WalletAddress) -> Vec<RuntimeOrderSummary> {
1011        self.snapshot
1012            .load()
1013            .orders_by_wallet
1014            .get(wallet)
1015            .cloned()
1016            .map(|orders| orders.into_iter().map(Into::into).collect())
1017            .unwrap_or_default()
1018    }
1019
1020    fn get_all_orders(&self) -> Vec<(RuntimeOrderSummary, WalletAddress)> {
1021        let snap = self.snapshot.load();
1022        // Prefer orders_by_wallet (has client_id for filtering),
1023        // but fall back to all_orderbook_orders for orphans not in the index.
1024        let mut from_index: Vec<_> = snap
1025            .orders_by_wallet
1026            .iter()
1027            .flat_map(|(wallet, orders)| {
1028                orders
1029                    .iter()
1030                    .cloned()
1031                    .map(move |o| (RuntimeOrderSummary::from(o), *wallet))
1032            })
1033            .collect();
1034
1035        // Add any orderbook orders not already in the index
1036        let indexed_ids: std::collections::HashSet<u64> =
1037            from_index.iter().map(|(o, _)| o.order_id).collect();
1038        for (order_id, wallet) in &snap.all_orderbook_orders {
1039            if !indexed_ids.contains(order_id) {
1040                from_index.push((
1041                    RuntimeOrderSummary {
1042                        order_id: *order_id,
1043                        symbol: String::new(),
1044                        side: hypercall_types::Side::Buy,
1045                        price: rust_decimal::Decimal::ZERO,
1046                        original_size: rust_decimal::Decimal::ZERO,
1047                        remaining_size: rust_decimal::Decimal::ZERO,
1048                        is_perp: false,
1049                        mmp_enabled: false,
1050                        client_id: None, // unknown — will NOT match cli_* filter
1051                        created_at: 0,
1052                    },
1053                    *wallet,
1054                ));
1055            }
1056        }
1057        from_index
1058    }
1059}
1060
1061#[async_trait]
1062impl BalanceProvider for SnapshotQuoteProvider {
1063    async fn get_balance(
1064        &self,
1065        wallet: &WalletAddress,
1066    ) -> Result<rust_decimal::Decimal, LedgerError> {
1067        let snapshot = self.snapshot.load();
1068        let balance = snapshot.balance_ledger.balance(wallet);
1069        tracing::trace!(
1070            wallet = %wallet,
1071            balance = %balance,
1072            l2_seq = snapshot.l2_seq,
1073            cash_wallet_count = snapshot.engine_state_digest.cash_wallet_count,
1074            "Read balance from EngineSnapshot balance_ledger"
1075        );
1076        Ok(balance)
1077    }
1078}
1079
1080/// Adapter that exposes snapshot open orders through the `OpenOrdersSource` trait.
1081///
1082/// This lets `RiskAccountBuilder` consume engine-truth open orders from
1083/// the engine snapshot.
1084pub struct SnapshotOpenOrdersSource {
1085    order_snapshot: Arc<dyn OrderSnapshotProvider>,
1086}
1087
1088impl SnapshotOpenOrdersSource {
1089    pub fn new(order_snapshot: Arc<dyn OrderSnapshotProvider>) -> Self {
1090        Self { order_snapshot }
1091    }
1092}
1093
1094#[async_trait]
1095impl crate::rsm::portfolio_margin::risk_account_builder::OpenOrdersSource
1096    for SnapshotOpenOrdersSource
1097{
1098    async fn get_open_orders(&self, wallet: &WalletAddress) -> Vec<ApiOrder> {
1099        self.order_snapshot
1100            .get_open_orders_for_wallet(wallet)
1101            .into_iter()
1102            .map(|summary| {
1103                let filled_size = (summary.original_size - summary.remaining_size).max(dec!(0));
1104                let status = if summary.remaining_size < summary.original_size {
1105                    Some("partially_filled".to_string())
1106                } else {
1107                    Some("open".to_string())
1108                };
1109
1110                ApiOrder {
1111                    order_id: i64::try_from(summary.order_id)
1112                        .expect("Engine order_id exceeded i64::MAX"),
1113                    wallet_address: *wallet,
1114                    symbol: summary.symbol,
1115                    side: format!("{:?}", summary.side),
1116                    price: summary.price,
1117                    size: summary.original_size,
1118                    // EngineOrderIndex currently tracks only resting open orders,
1119                    // which are normalized to GTC semantics in this read model.
1120                    tif: "gtc".to_string(),
1121                    status,
1122                    created_at: summary.created_at,
1123                    updated_at: None,
1124                    filled_size: Some(filled_size),
1125                    mmp_enabled: summary.mmp_enabled,
1126                }
1127            })
1128            .collect()
1129    }
1130}
1131
1132/// Mock QuoteProvider for tests — returns configurable quotes.
1133#[cfg(any(test, feature = "test-utils"))]
1134pub struct MockQuoteProvider {
1135    pub quotes: std::sync::RwLock<HashMap<String, SnapshotBookQuote>>,
1136    pub l2_seq: std::sync::atomic::AtomicI64,
1137    pub created_at: Instant,
1138}
1139
1140#[cfg(any(test, feature = "test-utils"))]
1141impl Default for MockQuoteProvider {
1142    fn default() -> Self {
1143        Self::new()
1144    }
1145}
1146
1147#[cfg(any(test, feature = "test-utils"))]
1148impl MockQuoteProvider {
1149    pub fn new() -> Self {
1150        Self {
1151            quotes: std::sync::RwLock::new(HashMap::new()),
1152            l2_seq: std::sync::atomic::AtomicI64::new(0),
1153            created_at: Instant::now(),
1154        }
1155    }
1156
1157    pub fn set_quote(&self, symbol: &str, quote: SnapshotBookQuote) {
1158        self.quotes
1159            .write()
1160            .unwrap()
1161            .insert(symbol.to_string(), quote);
1162    }
1163
1164    pub fn set_l2_seq(&self, seq: i64) {
1165        self.l2_seq.store(seq, std::sync::atomic::Ordering::SeqCst);
1166    }
1167}
1168
1169#[cfg(any(test, feature = "test-utils"))]
1170impl QuoteProvider for MockQuoteProvider {
1171    fn get_quote(&self, symbol: &str) -> Option<SnapshotBookQuote> {
1172        self.quotes.read().unwrap().get(symbol).cloned()
1173    }
1174
1175    fn get_quote_with_seq(&self, symbol: &str) -> (Option<SnapshotBookQuote>, i64) {
1176        let quotes = self.quotes.read().unwrap();
1177        let seq = self.l2_seq.load(std::sync::atomic::Ordering::SeqCst);
1178        (quotes.get(symbol).cloned(), seq)
1179    }
1180
1181    fn book_snapshot_state(&self, symbol: &str) -> BookSnapshotState {
1182        let quotes = self.quotes.read().unwrap();
1183        let seq = self.l2_seq.load(std::sync::atomic::Ordering::SeqCst);
1184        if seq <= 0 {
1185            return BookSnapshotState::NotReady { l2_seq: seq };
1186        }
1187
1188        BookSnapshotState::Ready {
1189            quote: quotes
1190                .get(symbol)
1191                .cloned()
1192                .unwrap_or_else(SnapshotBookQuote::empty),
1193            l2_seq: seq,
1194        }
1195    }
1196
1197    fn all_quotes(&self) -> HashMap<String, SnapshotBookQuote> {
1198        self.quotes.read().unwrap().clone()
1199    }
1200
1201    fn l2_seq(&self) -> i64 {
1202        self.l2_seq.load(std::sync::atomic::Ordering::SeqCst)
1203    }
1204
1205    fn snapshot(&self) -> QuoteSnapshot {
1206        QuoteSnapshot {
1207            quotes: self.quotes.read().unwrap().clone(),
1208            l2_seq: self.l2_seq.load(std::sync::atomic::Ordering::SeqCst),
1209        }
1210    }
1211
1212    fn staleness(&self) -> Duration {
1213        self.created_at.elapsed()
1214    }
1215}
1216
1217#[cfg(any(test, feature = "test-utils"))]
1218impl EngineStateDigestProvider for MockQuoteProvider {
1219    fn engine_state_digest(&self) -> EngineStateDigest {
1220        EngineStateDigest::empty()
1221    }
1222}
1223
1224/// Mock AgentAuthProvider for tests — self-signing only (no agents authorized).
1225#[cfg(any(test, feature = "test-utils"))]
1226pub struct MockAgentAuthProvider;
1227
1228#[cfg(any(test, feature = "test-utils"))]
1229impl AgentAuthProvider for MockAgentAuthProvider {
1230    fn is_agent_authorized(&self, wallet: &WalletAddress, agent: &WalletAddress) -> bool {
1231        wallet == agent
1232    }
1233
1234    fn get_authorized_agents(&self, _wallet: &WalletAddress) -> Vec<WalletAddress> {
1235        Vec::new()
1236    }
1237}
1238
1239/// Mock OrderSnapshotProvider for tests — returns empty orders by default.
1240#[cfg(any(test, feature = "test-utils"))]
1241pub struct MockOrderSnapshotProvider;
1242
1243#[cfg(any(test, feature = "test-utils"))]
1244impl OrderSnapshotProvider for MockOrderSnapshotProvider {
1245    fn get_open_orders_for_wallet(&self, _wallet: &WalletAddress) -> Vec<RuntimeOrderSummary> {
1246        Vec::new()
1247    }
1248
1249    fn get_all_orders(&self) -> Vec<(RuntimeOrderSummary, WalletAddress)> {
1250        Vec::new()
1251    }
1252}
1253
1254#[cfg(test)]
1255mod tests {
1256    use super::*;
1257
1258    #[test]
1259    fn empty_engine_state_digest_is_stable_and_non_ambiguous() {
1260        let a = EngineStateDigest::empty();
1261        let b = EngineStateDigest::empty();
1262
1263        assert_eq!(a.overall_digest, b.overall_digest);
1264        assert_eq!(a.orders_digest, b.orders_digest);
1265        assert!(a.overall_digest.starts_with("0x"));
1266        assert_eq!(a.overall_digest.len(), 66);
1267        assert_eq!(a.orders_count, 0);
1268        assert_eq!(a.positions_count, 0);
1269        assert_eq!(a.cash_wallet_count, 0);
1270    }
1271
1272    #[test]
1273    fn snapshot_quote_provider_exposes_latest_engine_state_digest() {
1274        let snapshot = Arc::new(ArcSwap::from_pointee(EngineSnapshot::empty()));
1275        let provider = SnapshotQuoteProvider::new(snapshot.clone());
1276
1277        let mut next = EngineSnapshot::empty();
1278        next.engine_state_digest.next_order_id = 42;
1279        next.engine_state_digest.overall_digest =
1280            "0x1111111111111111111111111111111111111111111111111111111111111111".to_string();
1281        snapshot.store(Arc::new(next));
1282
1283        let digest = provider.engine_state_digest();
1284        assert_eq!(digest.next_order_id, 42);
1285        assert_eq!(
1286            digest.overall_digest,
1287            "0x1111111111111111111111111111111111111111111111111111111111111111"
1288        );
1289    }
1290
1291    #[test]
1292    fn snapshot_quote_provider_treats_published_seq_zero_as_ready_empty_book() {
1293        let snapshot = Arc::new(ArcSwap::from_pointee(EngineSnapshot::empty()));
1294        let provider = SnapshotQuoteProvider::new(snapshot.clone());
1295
1296        assert!(matches!(
1297            provider.book_snapshot_state("SPCX-20260611-140-C"),
1298            BookSnapshotState::NotReady { l2_seq: 0 }
1299        ));
1300
1301        snapshot.store(Arc::new(EngineSnapshot::build(
1302            &HashMap::new(),
1303            &EngineOrderIndex::new(),
1304            0,
1305        )));
1306
1307        match provider.book_snapshot_state("SPCX-20260611-140-C") {
1308            BookSnapshotState::Ready { quote, l2_seq } => {
1309                assert_eq!(l2_seq, 0);
1310                assert!(quote.is_empty_book());
1311            }
1312            BookSnapshotState::NotReady { l2_seq } => {
1313                panic!("published seq-zero snapshot should be ready, got {l2_seq}")
1314            }
1315        }
1316    }
1317
1318    #[test]
1319    fn volatility_surface_digest_tracks_pricing_values() {
1320        let mut surface = VolatilitySurface::new();
1321        surface.insert(100_000.0, 1_800_000_000, 0.65);
1322        surface.set_atm_vol(1_800_000_000, 0.62);
1323        surface.set_delta_iv(1_800_000_000, 0.25, 0.70);
1324
1325        let base_digest = hex_digest(|h| update_volatility_surface(h, &surface));
1326
1327        let mut changed_strike_point = surface.clone();
1328        changed_strike_point.insert(100_000.0, 1_800_000_000, 0.66);
1329        assert_ne!(
1330            base_digest,
1331            hex_digest(|h| update_volatility_surface(h, &changed_strike_point))
1332        );
1333
1334        let mut changed_atm = surface.clone();
1335        changed_atm.set_atm_vol(1_800_000_000, 0.63);
1336        assert_ne!(
1337            base_digest,
1338            hex_digest(|h| update_volatility_surface(h, &changed_atm))
1339        );
1340
1341        let mut changed_delta = surface;
1342        changed_delta.set_delta_iv(1_800_000_000, 0.25, 0.71);
1343        assert_ne!(
1344            base_digest,
1345            hex_digest(|h| update_volatility_surface(h, &changed_delta))
1346        );
1347    }
1348}