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#[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 pub all_orderbook_orders: Vec<(u64, WalletAddress)>,
35 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#[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 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 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 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 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
902pub 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 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 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 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, 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
1080pub 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 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#[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#[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#[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}