Skip to main content

melin_exchange_core/
snapshot.rs

1//! Payload codec for Exchange snapshot state.
2//!
3//! Snapshots bridge version boundaries: before an engine upgrade, snapshot
4//! current state; the new version loads the snapshot and starts a fresh
5//! journal. Old journals are archived for audit (replayed only with the
6//! matching engine version).
7//!
8//! Uses manual binary serialization (same approach as the journal codec)
9//! to avoid serde dependency.
10//!
11//! On-disk framing (magic, versions, sequence, chain hash, CRC, atomic
12//! rename) lives in `melin_transport_core::snapshot` — generic over the
13//! `melin_app::Application` trait, which `melin_server::exchange_app::ServerApp`
14//! implements as a thin newtype around `Exchange`. This module owns the
15//! engine-specific payload bytes only.
16
17use std::collections::HashMap as StdHashMap;
18use std::num::NonZeroU64;
19
20use crate::account::{AccountManager, Balance};
21use crate::exchange::Exchange;
22use crate::orderbook::OrderBook;
23use crate::scheduler::{ScheduledTask, ScheduledTaskHeap, ScheduledTaskKind};
24use crate::types::{
25    AccountId, CircuitBreakerConfig, CurrencyId, FeeSchedule, InstrumentSpec, OrderId, Price,
26    Quantity, ReservationSlot, RiskLimits, Side, Symbol, TimeInForce,
27};
28
29use crate::le;
30
31/// Failure modes for [`decode_exchange_payload`]. Engine-local so the
32/// snapshot codec doesn't depend on `melin-journal` — the journal layer
33/// has its own broader error type, but snapshot payload decoding only
34/// ever produces these two outcomes.
35#[derive(Debug)]
36pub enum SnapshotDecodeError {
37    /// Buffer ended before a section could be fully read.
38    Truncated,
39    /// Bytes were structurally invalid (bad count, overflow, unknown
40    /// discriminant, etc). `reason` is a static description suitable
41    /// for surfacing in `io::Error`'s message.
42    Corrupt { reason: &'static str },
43}
44
45impl std::fmt::Display for SnapshotDecodeError {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            Self::Truncated => write!(f, "truncated snapshot payload"),
49            Self::Corrupt { reason } => write!(f, "corrupt snapshot payload: {reason}"),
50        }
51    }
52}
53
54impl std::error::Error for SnapshotDecodeError {}
55
56/// Decoded book-side levels: Vec of (price, orders-at-that-level).
57type RestingLevels = Vec<(Price, Vec<RestingOrderSnapshot>)>;
58
59/// Decoded stop-side levels: Vec of (trigger_price, stops-at-that-level).
60type StopLevels = Vec<(Price, Vec<PendingStopSnapshot>)>;
61
62/// Current snapshot payload version. Surfaced through
63/// `<Exchange as Application>::APP_VERSION` and embedded in the on-disk
64/// frame by the transport.
65/// v1 → v2: added SelfTradeProtection byte to PendingStopSnapshot.
66/// v2 → v3: added per-account OrderId high-water marks for client dedup.
67/// v3 → v4: added per-instrument RiskLimits for fat finger checks.
68/// v4 → v5: added per-instrument CircuitBreakerConfig for price bands + halts.
69/// v5 → v6: added chain_hash for BLAKE3 hash chain continuity across snapshots.
70/// v6 → v7: order_sides keyed by (AccountId, OrderId), added fee schedules.
71/// v7 → v8: order_index and stop_index now store AccountId (21 bytes/entry vs 17).
72/// v8 → v9: added per-key request sequence HWMs for admin idempotency.
73/// v10 → v11: added expiry_ns to resting orders and pending stops (GTD support).
74/// v11 → v12: added per-instrument disabled flag for instrument lifecycle management.
75/// v12 → v13: added scheduled_tasks heap for the engine-internal scheduler.
76/// v13 → v14: scheduler heap removed from snapshot — rebuilt on restore from
77///            GTD orders + pending stops (derived state).
78/// v14 → v15: per-account OrderId HWMs removed — replaced by a live-orders-only
79///            `(AccountId, OrderId)` set rebuilt on restore from `order_index`.
80///            Dedup semantics changed to allow OrderId reuse after the original
81///            closes (previously forbidden for the lifetime of the account).
82/// v15 → v16: added per-currency fee-account deficits. The fee account is
83///            now a signed ledger (`available - deficit`); rebates that
84///            exceed `available` accumulate on `deficit` rather than
85///            silently shortchanging the trader.
86/// v16 → v17: reservation semantics changed. Reservations now lock pure
87///            notional (no fee cushion); fees are settled from the fill's
88///            received asset (buyer pays in base out of base credit;
89///            seller pays in quote out of proceeds). v16 reservations
90///            include a fee cushion and would over-reserve when read
91///            under v17 semantics — bumping the version so old snapshots
92///            are explicitly rejected.
93/// v17 → v18: SEC-04 per-account rate-limiter bucket state. Without it, a
94///            replica that restored from a snapshot taken while the
95///            primary had partially-depleted buckets would re-initialise
96///            buckets lazily as full and diverge on accept/reject
97///            decisions for the bounded `burst/rate` window after
98///            restore. v18 carries the bucket map (`account`, `tokens`,
99///            `last_refill_ns`) so primary and replica converge bit-for-
100///            bit on the very next event after restore.
101pub const PAYLOAD_VERSION: u16 = 18;
102
103/// Encode the Exchange's full state (the "payload" portion of a snapshot —
104/// everything between the header and the CRC) into a freshly allocated
105/// `Vec<u8>`. The caller owns framing and checksum.
106pub fn encode_exchange_payload(exchange: &Exchange) -> Vec<u8> {
107    let state = exchange.snapshot_state();
108    // Exchange snapshots grow with account/order count; start with a
109    // generously sized buffer to minimise reallocations but avoid
110    // pre-reserving the 256 MiB cap.
111    let mut buf = Vec::with_capacity(64 * 1024);
112    encode_exchange_state(&state, &mut buf);
113    buf
114}
115
116/// Decode an Exchange from the payload bytes produced by
117/// [`encode_exchange_payload`]. The caller is responsible for verifying
118/// framing and CRC before handing bytes to this function. Decoding is
119/// always at [`PAYLOAD_VERSION`]; the transport rejects mismatched
120/// `APP_VERSION` before this is ever called.
121pub fn decode_exchange_payload(buf: &[u8]) -> Result<Exchange, SnapshotDecodeError> {
122    let (_consumed, state) = decode_exchange_state(buf, PAYLOAD_VERSION)?;
123    Ok(Exchange::restore_state(state))
124}
125
126/// Serialized exchange state — all the data needed to reconstruct an Exchange.
127///
128/// Separate from Exchange to keep serialization concerns out of the core
129/// engine types. Uses Vec (not HashMap) for deterministic-order serialization.
130#[derive(Debug)]
131pub(crate) struct ExchangeSnapshot {
132    pub(crate) instruments: Vec<InstrumentSpec>,
133    pub(crate) balances: Vec<((AccountId, CurrencyId), Balance)>,
134    pub(crate) reservations: Vec<(OrderId, AccountId, CurrencyId, u64)>,
135    pub(crate) order_sides: Vec<((AccountId, OrderId), Side)>,
136    pub(crate) books: Vec<(Symbol, BookSnapshot)>,
137    /// Per-instrument fat finger risk limits.
138    pub(crate) risk_limits: Vec<(Symbol, RiskLimits)>,
139    /// Per-instrument circuit breaker configurations.
140    pub(crate) circuit_breakers: Vec<(Symbol, CircuitBreakerConfig)>,
141    /// Per-instrument maker/taker fee schedules.
142    pub(crate) fee_schedules: Vec<(Symbol, FeeSchedule)>,
143    /// Per-key request sequence HWMs for admin idempotency (v9+).
144    pub(crate) key_hwm: Vec<(u64, u64)>,
145    /// Set of disabled instrument symbols (v12+).
146    pub(crate) disabled_instruments: Vec<Symbol>,
147    /// Per-currency fee-account deficits (v16+). Records how much the
148    /// fee account owes for rebates paid in excess of accumulated fee
149    /// revenue. The logical fee balance is `available - deficit`. Sparse:
150    /// only currencies with a non-zero deficit are present.
151    pub(crate) fee_account_deficits: Vec<(CurrencyId, u64)>,
152    /// Per-account rate-limiter bucket state (v18+, SEC-04). Each entry
153    /// is `(account, tokens, last_refill_ns)`. Empty when the limiter
154    /// is disabled or no account has yet submitted an order. Carrying
155    /// this in the snapshot is what closes the SEC-04
156    /// divergence window — see the version-history comment on
157    /// `PAYLOAD_VERSION` for the v17 → v18 motivation.
158    pub(crate) order_buckets: Vec<(AccountId, u64, u64)>,
159}
160
161/// Serialized order book state for a single instrument.
162/// Uses Vec for each level to preserve insertion-order fidelity.
163#[derive(Debug)]
164pub(crate) struct BookSnapshot {
165    pub(crate) bids: Vec<(Price, Vec<RestingOrderSnapshot>)>,
166    pub(crate) asks: Vec<(Price, Vec<RestingOrderSnapshot>)>,
167    pub(crate) order_index: Vec<(OrderId, AccountId, Side, Price)>,
168    pub(crate) stop_buys: Vec<(Price, Vec<PendingStopSnapshot>)>,
169    pub(crate) stop_sells: Vec<(Price, Vec<PendingStopSnapshot>)>,
170    pub(crate) stop_index: Vec<(OrderId, AccountId, Side, Price)>,
171    pub(crate) last_trade_price: Option<Price>,
172}
173
174/// Serialized resting order.
175#[derive(Debug)]
176pub(crate) struct RestingOrderSnapshot {
177    pub(crate) id: OrderId,
178    pub(crate) account: AccountId,
179    pub(crate) remaining: Quantity,
180    pub(crate) time_in_force: TimeInForce,
181    pub(crate) expiry_ns: u64,
182}
183
184/// Serialized pending stop.
185#[derive(Debug)]
186pub(crate) struct PendingStopSnapshot {
187    pub(crate) id: OrderId,
188    pub(crate) account: AccountId,
189    pub(crate) side: Side,
190    pub(crate) trigger_price: Price,
191    pub(crate) quantity: Quantity,
192    pub(crate) time_in_force: crate::types::TimeInForce,
193    pub(crate) limit_price: Option<Price>,
194    /// Quote budget for buy-side market/stop-market orders.
195    pub(crate) quote_budget: Option<u64>,
196    /// Self-trade prevention mode.
197    pub(crate) stp: crate::types::SelfTradeProtection,
198    /// Expiry time in nanoseconds (GTD orders). Zero for non-GTD.
199    pub(crate) expiry_ns: u64,
200}
201
202// --- Encoding helpers ---
203
204// Encode an `Option<NonZeroU64>` as a 1-byte tag (0 = None, 1 = Some)
205// optionally followed by the 8-byte value. Dual of `decode_opt_nz_u64`.
206fn encode_opt_nz_u64(buf: &mut Vec<u8>, v: Option<NonZeroU64>) {
207    match v {
208        Some(n) => {
209            buf.push(1);
210            le::push_u64(buf, n.get());
211        }
212        None => buf.push(0),
213    }
214}
215
216// Each `encode_*` helper writes its section header (4-byte length) plus
217// the per-entry bytes. Helpers are split for symmetry with the matching
218// `decode_*` helpers — keeping the wire format auditable from both sides.
219
220fn encode_instruments(buf: &mut Vec<u8>, instruments: &[InstrumentSpec]) {
221    le::push_u32(buf, instruments.len() as u32);
222    for spec in instruments {
223        le::push_u32(buf, spec.symbol.0);
224        le::push_u32(buf, spec.base.0);
225        le::push_u32(buf, spec.quote.0);
226    }
227}
228
229fn encode_balances(buf: &mut Vec<u8>, balances: &[BalanceEntry]) {
230    le::push_u32(buf, balances.len() as u32);
231    for ((account, currency), balance) in balances {
232        le::push_u32(buf, account.0);
233        le::push_u32(buf, currency.0);
234        le::push_u64(buf, balance.available);
235        le::push_u64(buf, balance.reserved);
236    }
237}
238
239fn encode_reservations(buf: &mut Vec<u8>, reservations: &[ReservationEntry]) {
240    le::push_u32(buf, reservations.len() as u32);
241    for (order_id, account, currency, remaining) in reservations {
242        le::push_u64(buf, order_id.0);
243        le::push_u32(buf, account.0);
244        le::push_u32(buf, currency.0);
245        le::push_u64(buf, *remaining);
246    }
247}
248
249// Order sides: (account_id, order_id, side) per entry.
250fn encode_order_sides(buf: &mut Vec<u8>, order_sides: &[OrderSideEntry]) {
251    le::push_u32(buf, order_sides.len() as u32);
252    for ((account, order_id), side) in order_sides {
253        le::push_u32(buf, account.0);
254        le::push_u64(buf, order_id.0);
255        buf.push(le::encode_side(*side));
256    }
257}
258
259fn encode_books(buf: &mut Vec<u8>, books: &[(Symbol, BookSnapshot)]) {
260    le::push_u32(buf, books.len() as u32);
261    for (symbol, book) in books {
262        le::push_u32(buf, symbol.0);
263        encode_book_snapshot(book, buf);
264    }
265}
266
267fn encode_risk_limits(buf: &mut Vec<u8>, risk_limits: &[(Symbol, RiskLimits)]) {
268    le::push_u32(buf, risk_limits.len() as u32);
269    for (symbol, limits) in risk_limits {
270        le::push_u32(buf, symbol.0);
271        encode_opt_nz_u64(buf, limits.max_order_qty.map(|q| q.0));
272        match limits.max_order_notional {
273            Some(notional) => {
274                buf.push(1);
275                le::push_u64(buf, notional);
276            }
277            None => buf.push(0),
278        }
279    }
280}
281
282fn encode_circuit_breakers(buf: &mut Vec<u8>, circuit_breakers: &[(Symbol, CircuitBreakerConfig)]) {
283    le::push_u32(buf, circuit_breakers.len() as u32);
284    for (symbol, config) in circuit_breakers {
285        le::push_u32(buf, symbol.0);
286        encode_opt_nz_u64(buf, config.price_band_lower.map(|p| p.0));
287        encode_opt_nz_u64(buf, config.price_band_upper.map(|p| p.0));
288        buf.push(u8::from(config.halted));
289    }
290}
291
292fn encode_fee_schedules(buf: &mut Vec<u8>, fee_schedules: &[(Symbol, FeeSchedule)]) {
293    le::push_u32(buf, fee_schedules.len() as u32);
294    for (symbol, schedule) in fee_schedules {
295        le::push_u32(buf, symbol.0);
296        le::push_i16(buf, schedule.maker_fee_bps);
297        le::push_i16(buf, schedule.taker_fee_bps);
298    }
299}
300
301fn encode_key_hwm(buf: &mut Vec<u8>, key_hwm: &[(u64, u64)]) {
302    le::push_u32(buf, key_hwm.len() as u32);
303    for (key_hash, hwm) in key_hwm {
304        le::push_u64(buf, *key_hash);
305        le::push_u64(buf, *hwm);
306    }
307}
308
309fn encode_disabled_instruments(buf: &mut Vec<u8>, disabled: &[Symbol]) {
310    le::push_u32(buf, disabled.len() as u32);
311    for symbol in disabled {
312        le::push_u32(buf, symbol.0);
313    }
314}
315
316fn encode_fee_account_deficits(buf: &mut Vec<u8>, deficits: &[(CurrencyId, u64)]) {
317    le::push_u32(buf, deficits.len() as u32);
318    for (currency, amount) in deficits {
319        le::push_u32(buf, currency.0);
320        le::push_u64(buf, *amount);
321    }
322}
323
324// Per-account rate-limiter bucket state (SEC-04). Each entry is
325// account(4) + tokens(8) + last_refill_ns(8) = 20 bytes.
326fn encode_order_buckets(buf: &mut Vec<u8>, buckets: &[OrderBucketEntry]) {
327    le::push_u32(buf, buckets.len() as u32);
328    for (account, tokens, last_refill_ns) in buckets {
329        le::push_u32(buf, account.0);
330        le::push_u64(buf, *tokens);
331        le::push_u64(buf, *last_refill_ns);
332    }
333}
334
335fn encode_exchange_state(state: &ExchangeSnapshot, buf: &mut Vec<u8>) {
336    // Exhaustive destructure (no `..`): if a new field is added to
337    // `ExchangeSnapshot`, the compiler errors here, forcing us to update
338    // the wire format intentionally rather than silently shipping a
339    // snapshot that drops the new field.
340    let ExchangeSnapshot {
341        instruments,
342        balances,
343        reservations,
344        order_sides,
345        books,
346        risk_limits,
347        circuit_breakers,
348        fee_schedules,
349        key_hwm,
350        disabled_instruments,
351        fee_account_deficits,
352        order_buckets,
353    } = state;
354    encode_instruments(buf, instruments);
355    encode_balances(buf, balances);
356    encode_reservations(buf, reservations);
357    encode_order_sides(buf, order_sides);
358    encode_books(buf, books);
359    encode_risk_limits(buf, risk_limits);
360    encode_circuit_breakers(buf, circuit_breakers);
361    encode_fee_schedules(buf, fee_schedules);
362    encode_key_hwm(buf, key_hwm);
363    encode_disabled_instruments(buf, disabled_instruments);
364    encode_fee_account_deficits(buf, fee_account_deficits);
365    encode_order_buckets(buf, order_buckets);
366}
367
368fn encode_book_snapshot(book: &BookSnapshot, buf: &mut Vec<u8>) {
369    // Bids.
370    encode_book_side(&book.bids, buf);
371    // Asks.
372    encode_book_side(&book.asks, buf);
373
374    // Order index: (order_id, account_id, side, price) — 21 bytes each.
375    le::push_u32(buf, book.order_index.len() as u32);
376    for (order_id, account, side, price) in &book.order_index {
377        le::push_u64(buf, order_id.0);
378        le::push_u32(buf, account.0);
379        buf.push(le::encode_side(*side));
380        le::push_u64(buf, price.get());
381    }
382
383    // Stop buys.
384    encode_stop_side(&book.stop_buys, buf);
385    // Stop sells.
386    encode_stop_side(&book.stop_sells, buf);
387
388    // Stop index: (order_id, account_id, side, price) — 21 bytes each.
389    le::push_u32(buf, book.stop_index.len() as u32);
390    for (order_id, account, side, price) in &book.stop_index {
391        le::push_u64(buf, order_id.0);
392        le::push_u32(buf, account.0);
393        buf.push(le::encode_side(*side));
394        le::push_u64(buf, price.get());
395    }
396
397    // Last trade price.
398    match book.last_trade_price {
399        Some(p) => {
400            buf.push(1);
401            le::push_u64(buf, p.get());
402        }
403        None => buf.push(0),
404    }
405}
406
407fn encode_book_side(levels: &[(Price, Vec<RestingOrderSnapshot>)], buf: &mut Vec<u8>) {
408    le::push_u32(buf, levels.len() as u32);
409    for (price, orders) in levels {
410        le::push_u64(buf, price.get());
411        le::push_u32(buf, orders.len() as u32);
412        for order in orders {
413            le::push_u64(buf, order.id.0);
414            le::push_u32(buf, order.account.0);
415            le::push_u64(buf, order.remaining.get());
416            buf.push(le::encode_tif(order.time_in_force));
417            // expiry_ns (v11+): needed for GTD orders to survive snapshot/restore.
418            le::push_u64(buf, order.expiry_ns);
419        }
420    }
421}
422
423fn encode_stop_side(levels: &[(Price, Vec<PendingStopSnapshot>)], buf: &mut Vec<u8>) {
424    le::push_u32(buf, levels.len() as u32);
425    for (trigger_price, stops) in levels {
426        le::push_u64(buf, trigger_price.get());
427        le::push_u32(buf, stops.len() as u32);
428        for stop in stops {
429            le::push_u64(buf, stop.id.0);
430            le::push_u32(buf, stop.account.0);
431            buf.push(le::encode_side(stop.side));
432            le::push_u64(buf, stop.trigger_price.get());
433            le::push_u64(buf, stop.quantity.get());
434            buf.push(le::encode_tif(stop.time_in_force));
435            match stop.limit_price {
436                Some(p) => {
437                    buf.push(1);
438                    le::push_u64(buf, p.get());
439                }
440                None => buf.push(0),
441            }
442            match stop.quote_budget {
443                Some(budget) => {
444                    buf.push(1);
445                    le::push_u64(buf, budget);
446                }
447                None => buf.push(0),
448            }
449            buf.push(le::encode_stp(stop.stp));
450            // expiry_ns (v11+): needed for GTD stop orders to survive snapshot/restore.
451            le::push_u64(buf, stop.expiry_ns);
452        }
453    }
454}
455
456// --- Decoding helpers ---
457
458/// Validate that a claimed count `n` of items each `item_size` bytes can
459/// actually fit in the remaining buffer. Prevents memory exhaustion from
460/// crafted count values.
461fn validate_count(remaining: usize, n: usize, item_size: usize) -> Result<(), SnapshotDecodeError> {
462    let needed = n.saturating_mul(item_size);
463    if needed > remaining {
464        Err(SnapshotDecodeError::Corrupt {
465            reason: "count exceeds remaining buffer",
466        })
467    } else {
468        Ok(())
469    }
470}
471
472// Type aliases mirroring the corresponding `ExchangeSnapshot` fields, kept
473// here only to keep the decode helper signatures legible (clippy
474// type_complexity).
475type BalanceEntry = ((AccountId, CurrencyId), Balance);
476type ReservationEntry = (OrderId, AccountId, CurrencyId, u64);
477type OrderSideEntry = ((AccountId, OrderId), Side);
478type OrderBucketEntry = (AccountId, u64, u64);
479
480// Reusable corrupt-entry helper.
481fn corrupt(reason: &'static str) -> SnapshotDecodeError {
482    SnapshotDecodeError::Corrupt { reason }
483}
484
485// Bounds check: returns TruncatedEntry if `pos + need` exceeds the buffer.
486fn check(buf: &[u8], pos: usize, need: usize) -> Result<(), SnapshotDecodeError> {
487    if pos + need > buf.len() {
488        Err(SnapshotDecodeError::Truncated)
489    } else {
490        Ok(())
491    }
492}
493
494// Read the 4-byte length prefix at `buf[0..4]` and return (length, body)
495// where body is the slice past the prefix. Caller adds the consumed bytes
496// to its own cursor.
497fn read_section_len(buf: &[u8]) -> Result<usize, SnapshotDecodeError> {
498    check(buf, 0, 4)?;
499    Ok(le::get_u32(buf) as usize)
500}
501
502fn decode_instruments(buf: &[u8]) -> Result<(usize, Vec<InstrumentSpec>), SnapshotDecodeError> {
503    let n = read_section_len(buf)?;
504    let mut pos = 4;
505    validate_count(buf.len() - pos, n, 12)?;
506    let mut out = Vec::with_capacity(n);
507    for _ in 0..n {
508        check(buf, pos, 12)?;
509        out.push(InstrumentSpec {
510            symbol: Symbol(le::get_u32(&buf[pos..])),
511            base: CurrencyId(le::get_u32(&buf[pos + 4..])),
512            quote: CurrencyId(le::get_u32(&buf[pos + 8..])),
513        });
514        pos += 12;
515    }
516    Ok((pos, out))
517}
518
519fn decode_balances(buf: &[u8]) -> Result<(usize, Vec<BalanceEntry>), SnapshotDecodeError> {
520    let n = read_section_len(buf)?;
521    let mut pos = 4;
522    validate_count(buf.len() - pos, n, 24)?;
523    let mut out = Vec::with_capacity(n);
524    for _ in 0..n {
525        check(buf, pos, 24)?;
526        let account = AccountId(le::get_u32(&buf[pos..]));
527        let currency = CurrencyId(le::get_u32(&buf[pos + 4..]));
528        let available = le::get_u64(&buf[pos + 8..]);
529        let reserved = le::get_u64(&buf[pos + 16..]);
530        out.push((
531            (account, currency),
532            Balance {
533                available,
534                reserved,
535            },
536        ));
537        pos += 24;
538    }
539    Ok((pos, out))
540}
541
542fn decode_reservations(buf: &[u8]) -> Result<(usize, Vec<ReservationEntry>), SnapshotDecodeError> {
543    let n = read_section_len(buf)?;
544    let mut pos = 4;
545    validate_count(buf.len() - pos, n, 24)?;
546    let mut out = Vec::with_capacity(n);
547    for _ in 0..n {
548        check(buf, pos, 24)?;
549        let order_id = OrderId(le::get_u64(&buf[pos..]));
550        let account = AccountId(le::get_u32(&buf[pos + 8..]));
551        let currency = CurrencyId(le::get_u32(&buf[pos + 12..]));
552        let remaining = le::get_u64(&buf[pos + 16..]);
553        out.push((order_id, account, currency, remaining));
554        pos += 24;
555    }
556    Ok((pos, out))
557}
558
559// Order sides: v7+ stores (account_id(4) + order_id(8) + side(1)) = 13 bytes.
560// v5/v6 stores (order_id(8) + side(1)) = 9 bytes (no account in key) — uses
561// AccountId(0) as placeholder. Lossy but allows loading old snapshots; v6
562// will be re-saved as v7 on the next rotation.
563fn decode_order_sides(
564    buf: &[u8],
565    version: u16,
566) -> Result<(usize, Vec<OrderSideEntry>), SnapshotDecodeError> {
567    let n = read_section_len(buf)?;
568    let mut pos = 4;
569    let mut out = Vec::with_capacity(n);
570    if version >= 7 {
571        validate_count(buf.len() - pos, n, 13)?;
572        for _ in 0..n {
573            check(buf, pos, 13)?;
574            let account = AccountId(le::get_u32(&buf[pos..]));
575            let order_id = OrderId(le::get_u64(&buf[pos + 4..]));
576            let side = le::decode_side(buf[pos + 12]).ok_or(corrupt("invalid side in snapshot"))?;
577            out.push(((account, order_id), side));
578            pos += 13;
579        }
580    } else {
581        validate_count(buf.len() - pos, n, 9)?;
582        for _ in 0..n {
583            check(buf, pos, 9)?;
584            let order_id = OrderId(le::get_u64(&buf[pos..]));
585            let side = le::decode_side(buf[pos + 8]).ok_or(corrupt("invalid side in snapshot"))?;
586            out.push(((AccountId(0), order_id), side));
587            pos += 9;
588        }
589    }
590    Ok((pos, out))
591}
592
593fn decode_books(
594    buf: &[u8],
595    version: u16,
596) -> Result<(usize, Vec<(Symbol, BookSnapshot)>), SnapshotDecodeError> {
597    let n = read_section_len(buf)?;
598    let mut pos = 4;
599    // Minimum per-book overhead: at least a few bytes for the empty-book structure.
600    validate_count(buf.len() - pos, n, 4)?;
601    let mut out = Vec::with_capacity(n);
602    for _ in 0..n {
603        check(buf, pos, 4)?;
604        let symbol = Symbol(le::get_u32(&buf[pos..]));
605        pos += 4;
606        let (consumed, book) = decode_book_snapshot(&buf[pos..], version)?;
607        pos += consumed;
608        out.push((symbol, book));
609    }
610    Ok((pos, out))
611}
612
613// Decode an optional NonZeroU64 prefixed with a 1-byte tag (0 = None, 1 = Some).
614// Returns the new position and the parsed value.
615fn decode_opt_nz_u64(
616    buf: &[u8],
617    mut pos: usize,
618    invalid_tag_reason: &'static str,
619    zero_value_reason: &'static str,
620) -> Result<(usize, Option<NonZeroU64>), SnapshotDecodeError> {
621    check(buf, pos, 1)?;
622    match buf[pos] {
623        1 => {
624            pos += 1;
625            check(buf, pos, 8)?;
626            let v = NonZeroU64::new(le::get_u64(&buf[pos..])).ok_or(corrupt(zero_value_reason))?;
627            pos += 8;
628            Ok((pos, Some(v)))
629        }
630        0 => Ok((pos + 1, None)),
631        _ => Err(corrupt(invalid_tag_reason)),
632    }
633}
634
635fn decode_risk_limits(
636    buf: &[u8],
637) -> Result<(usize, Vec<(Symbol, RiskLimits)>), SnapshotDecodeError> {
638    let n = read_section_len(buf)?;
639    let mut pos = 4;
640    // Each entry is at least 6 bytes: symbol(4) + two option tags(1+1).
641    validate_count(buf.len() - pos, n, 6)?;
642    let mut out = Vec::with_capacity(n);
643    for _ in 0..n {
644        check(buf, pos, 6)?;
645        let symbol = Symbol(le::get_u32(&buf[pos..]));
646        pos += 4;
647        let (new_pos, max_order_qty) = decode_opt_nz_u64(
648            buf,
649            pos,
650            "invalid max_order_qty tag in risk limits",
651            "zero max_order_qty in risk limits",
652        )?;
653        pos = new_pos;
654        let max_order_qty = max_order_qty.map(Quantity);
655        check(buf, pos, 1)?;
656        let max_order_notional = match buf[pos] {
657            1 => {
658                pos += 1;
659                check(buf, pos, 8)?;
660                let v = le::get_u64(&buf[pos..]);
661                pos += 8;
662                Some(v)
663            }
664            0 => {
665                pos += 1;
666                None
667            }
668            _ => return Err(corrupt("invalid max_order_notional tag in risk limits")),
669        };
670        out.push((
671            symbol,
672            RiskLimits {
673                max_order_qty,
674                max_order_notional,
675            },
676        ));
677    }
678    Ok((pos, out))
679}
680
681fn decode_circuit_breakers(
682    buf: &[u8],
683) -> Result<(usize, Vec<(Symbol, CircuitBreakerConfig)>), SnapshotDecodeError> {
684    let n = read_section_len(buf)?;
685    let mut pos = 4;
686    // Each entry is at least 7 bytes: symbol(4) + two option tags(1+1) + halted(1).
687    validate_count(buf.len() - pos, n, 7)?;
688    let mut out = Vec::with_capacity(n);
689    for _ in 0..n {
690        check(buf, pos, 7)?;
691        let symbol = Symbol(le::get_u32(&buf[pos..]));
692        pos += 4;
693        let (new_pos, lower) = decode_opt_nz_u64(
694            buf,
695            pos,
696            "invalid price_band_lower tag in circuit breaker",
697            "zero price_band_lower in circuit breaker",
698        )?;
699        pos = new_pos;
700        let (new_pos, upper) = decode_opt_nz_u64(
701            buf,
702            pos,
703            "invalid price_band_upper tag in circuit breaker",
704            "zero price_band_upper in circuit breaker",
705        )?;
706        pos = new_pos;
707        check(buf, pos, 1)?;
708        let halted = buf[pos] != 0;
709        pos += 1;
710        out.push((
711            symbol,
712            CircuitBreakerConfig {
713                price_band_lower: lower.map(Price),
714                price_band_upper: upper.map(Price),
715                halted,
716            },
717        ));
718    }
719    Ok((pos, out))
720}
721
722fn decode_fee_schedules(
723    buf: &[u8],
724) -> Result<(usize, Vec<(Symbol, FeeSchedule)>), SnapshotDecodeError> {
725    let n = read_section_len(buf)?;
726    let mut pos = 4;
727    // Each fee schedule: symbol(4) + maker_bps(2) + taker_bps(2) = 8 bytes.
728    validate_count(buf.len() - pos, n, 8)?;
729    let mut out = Vec::with_capacity(n);
730    for _ in 0..n {
731        check(buf, pos, 8)?;
732        let symbol = Symbol(le::get_u32(&buf[pos..]));
733        pos += 4;
734        let maker_fee_bps = le::get_i16(&buf[pos..]);
735        pos += 2;
736        let taker_fee_bps = le::get_i16(&buf[pos..]);
737        pos += 2;
738        out.push((
739            symbol,
740            FeeSchedule {
741                maker_fee_bps,
742                taker_fee_bps,
743            },
744        ));
745    }
746    Ok((pos, out))
747}
748
749fn decode_key_hwm(buf: &[u8]) -> Result<(usize, Vec<(u64, u64)>), SnapshotDecodeError> {
750    let n = read_section_len(buf)?;
751    let mut pos = 4;
752    // Each entry: key_hash(8) + hwm(8) = 16 bytes.
753    validate_count(buf.len() - pos, n, 16)?;
754    let mut out = Vec::with_capacity(n);
755    for _ in 0..n {
756        check(buf, pos, 16)?;
757        let key_hash = le::get_u64(&buf[pos..]);
758        let hwm = le::get_u64(&buf[pos + 8..]);
759        out.push((key_hash, hwm));
760        pos += 16;
761    }
762    Ok((pos, out))
763}
764
765fn decode_disabled_instruments(buf: &[u8]) -> Result<(usize, Vec<Symbol>), SnapshotDecodeError> {
766    let n = read_section_len(buf)?;
767    let mut pos = 4;
768    // Each entry: symbol(4) = 4 bytes.
769    validate_count(buf.len() - pos, n, 4)?;
770    let mut out = Vec::with_capacity(n);
771    for _ in 0..n {
772        check(buf, pos, 4)?;
773        out.push(Symbol(le::get_u32(&buf[pos..])));
774        pos += 4;
775    }
776    Ok((pos, out))
777}
778
779fn decode_fee_account_deficits(
780    buf: &[u8],
781) -> Result<(usize, Vec<(CurrencyId, u64)>), SnapshotDecodeError> {
782    let n = read_section_len(buf)?;
783    let mut pos = 4;
784    // Each entry: currency(4) + amount(8) = 12 bytes.
785    validate_count(buf.len() - pos, n, 12)?;
786    let mut out = Vec::with_capacity(n);
787    for _ in 0..n {
788        check(buf, pos, 12)?;
789        let currency = CurrencyId(le::get_u32(&buf[pos..]));
790        let amount = le::get_u64(&buf[pos + 4..]);
791        out.push((currency, amount));
792        pos += 12;
793    }
794    Ok((pos, out))
795}
796
797// Per-account rate-limiter bucket state (SEC-04). Each entry is
798// account(4) + tokens(8) + last_refill_ns(8) = 20 bytes.
799fn decode_order_buckets(buf: &[u8]) -> Result<(usize, Vec<OrderBucketEntry>), SnapshotDecodeError> {
800    let n = read_section_len(buf)?;
801    let mut pos = 4;
802    validate_count(buf.len() - pos, n, 20)?;
803    let mut out = Vec::with_capacity(n);
804    // Track seen accounts to reject duplicate keys: the encoder writes
805    // each AccountId at most once (HashMap iteration), so a duplicate
806    // here means the snapshot is corrupt or tampered. Silent overwrite
807    // would let an attacker shadow a legitimate bucket with a synthetic
808    // full-credit one. HashSet is u32-keyed and only built during
809    // recovery — not on the hot path.
810    let mut seen: std::collections::HashSet<AccountId> =
811        std::collections::HashSet::with_capacity(n);
812    for _ in 0..n {
813        check(buf, pos, 20)?;
814        let account = AccountId(le::get_u32(&buf[pos..]));
815        let tokens = le::get_u64(&buf[pos + 4..]);
816        let last_refill_ns = le::get_u64(&buf[pos + 12..]);
817        if !seen.insert(account) {
818            return Err(corrupt("duplicate account in order_buckets section"));
819        }
820        out.push((account, tokens, last_refill_ns));
821        pos += 20;
822    }
823    Ok((pos, out))
824}
825
826fn decode_exchange_state(
827    buf: &[u8],
828    version: u16,
829) -> Result<(usize, ExchangeSnapshot), SnapshotDecodeError> {
830    let mut pos = 0;
831
832    let (consumed, instruments) = decode_instruments(&buf[pos..])?;
833    pos += consumed;
834    let (consumed, balances) = decode_balances(&buf[pos..])?;
835    pos += consumed;
836    let (consumed, reservations) = decode_reservations(&buf[pos..])?;
837    pos += consumed;
838    let (consumed, order_sides) = decode_order_sides(&buf[pos..], version)?;
839    pos += consumed;
840    let (consumed, books) = decode_books(&buf[pos..], version)?;
841    pos += consumed;
842    let (consumed, risk_limits) = decode_risk_limits(&buf[pos..])?;
843    pos += consumed;
844    let (consumed, circuit_breakers) = decode_circuit_breakers(&buf[pos..])?;
845    pos += consumed;
846
847    // v7+ and v9+ sections may be absent on legacy snapshots that ended
848    // before the section was introduced (encoder writes at least a 4-byte
849    // length when the section exists, so EOF here means the snapshot
850    // predates the section). Newer versioned sections below require the
851    // section to be present — physical truncation surfaces as
852    // `TruncatedEntry` rather than a silent empty vec.
853    let fee_schedules = if version >= 7 && pos < buf.len() {
854        let (consumed, v) = decode_fee_schedules(&buf[pos..])?;
855        pos += consumed;
856        v
857    } else {
858        Vec::new()
859    };
860
861    let key_hwm = if version >= 9 && pos < buf.len() {
862        let (consumed, v) = decode_key_hwm(&buf[pos..])?;
863        pos += consumed;
864        v
865    } else {
866        Vec::new()
867    };
868
869    let disabled_instruments = if version >= 12 {
870        let (consumed, v) = decode_disabled_instruments(&buf[pos..])?;
871        pos += consumed;
872        v
873    } else {
874        Vec::new()
875    };
876
877    let fee_account_deficits = if version >= 16 {
878        let (consumed, v) = decode_fee_account_deficits(&buf[pos..])?;
879        pos += consumed;
880        v
881    } else {
882        Vec::new()
883    };
884
885    let order_buckets = if version >= 18 {
886        let (consumed, v) = decode_order_buckets(&buf[pos..])?;
887        pos += consumed;
888        v
889    } else {
890        Vec::new()
891    };
892
893    Ok((
894        pos,
895        ExchangeSnapshot {
896            instruments,
897            balances,
898            reservations,
899            order_sides,
900            books,
901            risk_limits,
902            circuit_breakers,
903            fee_schedules,
904            key_hwm,
905            disabled_instruments,
906            fee_account_deficits,
907            order_buckets,
908        },
909    ))
910}
911
912fn decode_book_snapshot(
913    buf: &[u8],
914    version: u16,
915) -> Result<(usize, BookSnapshot), SnapshotDecodeError> {
916    let corrupt = |reason: &'static str| SnapshotDecodeError::Corrupt { reason };
917    let mut pos = 0;
918
919    let check = |pos: usize, need: usize| -> Result<(), SnapshotDecodeError> {
920        if pos + need > buf.len() {
921            Err(SnapshotDecodeError::Truncated)
922        } else {
923            Ok(())
924        }
925    };
926
927    // Bids.
928    let (consumed, bids) = decode_book_side_levels(&buf[pos..], version)?;
929    pos += consumed;
930
931    // Asks.
932    let (consumed, asks) = decode_book_side_levels(&buf[pos..], version)?;
933    pos += consumed;
934
935    // Order index: v8+ stores (order_id, account_id, side, price) — 21 bytes each.
936    // v5-v7 stores (order_id, side, price) — 17 bytes each (no account; uses AccountId(0) placeholder).
937    check(pos, 4)?;
938    let n_order_index = le::get_u32(&buf[pos..]) as usize;
939    pos += 4;
940    let mut order_index = Vec::with_capacity(n_order_index);
941    if version >= 8 {
942        validate_count(buf.len() - pos, n_order_index, 21)?;
943        for _ in 0..n_order_index {
944            check(pos, 21)?;
945            let order_id = OrderId(le::get_u64(&buf[pos..]));
946            let account = AccountId(le::get_u32(&buf[pos + 8..]));
947            let side = le::decode_side(buf[pos + 12]).ok_or(corrupt("invalid side"))?;
948            let price_val = NonZeroU64::new(le::get_u64(&buf[pos + 13..]))
949                .ok_or(corrupt("zero price in index"))?;
950            order_index.push((order_id, account, side, Price(price_val)));
951            pos += 21;
952        }
953    } else {
954        validate_count(buf.len() - pos, n_order_index, 17)?;
955        for _ in 0..n_order_index {
956            check(pos, 17)?;
957            let order_id = OrderId(le::get_u64(&buf[pos..]));
958            let side = le::decode_side(buf[pos + 8]).ok_or(corrupt("invalid side"))?;
959            let price_val = NonZeroU64::new(le::get_u64(&buf[pos + 9..]))
960                .ok_or(corrupt("zero price in index"))?;
961            // Pre-v8 snapshots lack AccountId in the index; use placeholder.
962            // The account can be recovered from the BookSide resting orders.
963            order_index.push((order_id, AccountId(0), side, Price(price_val)));
964            pos += 17;
965        }
966    }
967
968    // Stop buys.
969    let (consumed, stop_buys) = decode_stop_side_levels(&buf[pos..], version)?;
970    pos += consumed;
971
972    // Stop sells.
973    let (consumed, stop_sells) = decode_stop_side_levels(&buf[pos..], version)?;
974    pos += consumed;
975
976    // Stop index: v8+ stores (order_id, account_id, side, price) — 21 bytes each.
977    // v5-v7 stores (order_id, side, price) — 17 bytes each.
978    check(pos, 4)?;
979    let n_stop_index = le::get_u32(&buf[pos..]) as usize;
980    pos += 4;
981    let mut stop_index = Vec::with_capacity(n_stop_index);
982    if version >= 8 {
983        validate_count(buf.len() - pos, n_stop_index, 21)?;
984        for _ in 0..n_stop_index {
985            check(pos, 21)?;
986            let order_id = OrderId(le::get_u64(&buf[pos..]));
987            let account = AccountId(le::get_u32(&buf[pos + 8..]));
988            let side = le::decode_side(buf[pos + 12]).ok_or(corrupt("invalid side"))?;
989            let price_val = NonZeroU64::new(le::get_u64(&buf[pos + 13..]))
990                .ok_or(corrupt("zero price in stop index"))?;
991            stop_index.push((order_id, account, side, Price(price_val)));
992            pos += 21;
993        }
994    } else {
995        validate_count(buf.len() - pos, n_stop_index, 17)?;
996        for _ in 0..n_stop_index {
997            check(pos, 17)?;
998            let order_id = OrderId(le::get_u64(&buf[pos..]));
999            let side = le::decode_side(buf[pos + 8]).ok_or(corrupt("invalid side"))?;
1000            let price_val = NonZeroU64::new(le::get_u64(&buf[pos + 9..]))
1001                .ok_or(corrupt("zero price in stop index"))?;
1002            // Pre-v8 snapshots lack AccountId in the stop index; use placeholder.
1003            stop_index.push((order_id, AccountId(0), side, Price(price_val)));
1004            pos += 17;
1005        }
1006    }
1007
1008    // Last trade price.
1009    check(pos, 1)?;
1010    let last_trade_price = match buf[pos] {
1011        1 => {
1012            pos += 1;
1013            check(pos, 8)?;
1014            let p = NonZeroU64::new(le::get_u64(&buf[pos..]))
1015                .ok_or(corrupt("zero last trade price"))?;
1016            pos += 8;
1017            Some(Price(p))
1018        }
1019        0 => {
1020            pos += 1;
1021            None
1022        }
1023        _ => return Err(corrupt("invalid last_trade_price tag")),
1024    };
1025
1026    Ok((
1027        pos,
1028        BookSnapshot {
1029            bids,
1030            asks,
1031            order_index,
1032            stop_buys,
1033            stop_sells,
1034            stop_index,
1035            last_trade_price,
1036        },
1037    ))
1038}
1039
1040fn decode_book_side_levels(
1041    buf: &[u8],
1042    version: u16,
1043) -> Result<(usize, RestingLevels), SnapshotDecodeError> {
1044    let corrupt = |reason: &'static str| SnapshotDecodeError::Corrupt { reason };
1045    let mut pos = 0;
1046
1047    if buf.len() < 4 {
1048        return Err(SnapshotDecodeError::Truncated);
1049    }
1050    let n_levels = le::get_u32(&buf[pos..]) as usize;
1051    pos += 4;
1052    // Each level has at least 12 bytes (price + order count).
1053    validate_count(buf.len() - pos, n_levels, 12)?;
1054
1055    // Per-order size: v11+ adds expiry_ns(8) after tif.
1056    let order_size: usize = if version >= 11 { 29 } else { 21 };
1057
1058    let mut levels = Vec::with_capacity(n_levels);
1059    for _ in 0..n_levels {
1060        if pos + 12 > buf.len() {
1061            return Err(SnapshotDecodeError::Truncated);
1062        }
1063        let price_val =
1064            NonZeroU64::new(le::get_u64(&buf[pos..])).ok_or(corrupt("zero price in book level"))?;
1065        pos += 8;
1066        let n_orders = le::get_u32(&buf[pos..]) as usize;
1067        pos += 4;
1068
1069        // Each order is id(8) + account(4) + remaining(8) + tif(1) [+ expiry_ns(8) in v11+].
1070        validate_count(buf.len() - pos, n_orders, order_size)?;
1071        let mut orders = Vec::with_capacity(n_orders);
1072        for _ in 0..n_orders {
1073            if pos + order_size > buf.len() {
1074                return Err(SnapshotDecodeError::Truncated);
1075            }
1076            let id = OrderId(le::get_u64(&buf[pos..]));
1077            let account = AccountId(le::get_u32(&buf[pos + 8..]));
1078            let remaining_val = NonZeroU64::new(le::get_u64(&buf[pos + 12..]))
1079                .ok_or(corrupt("zero remaining quantity"))?;
1080            let time_in_force = le::decode_tif(buf[pos + 20])
1081                .ok_or(corrupt("invalid time-in-force on resting order"))?;
1082            pos += 21;
1083            let expiry_ns = if version >= 11 {
1084                let v = le::get_u64(&buf[pos..]);
1085                pos += 8;
1086                v
1087            } else {
1088                0
1089            };
1090            orders.push(RestingOrderSnapshot {
1091                id,
1092                account,
1093                remaining: Quantity(remaining_val),
1094                time_in_force,
1095                expiry_ns,
1096            });
1097        }
1098        levels.push((Price(price_val), orders));
1099    }
1100
1101    Ok((pos, levels))
1102}
1103
1104fn decode_stop_side_levels(
1105    buf: &[u8],
1106    version: u16,
1107) -> Result<(usize, StopLevels), SnapshotDecodeError> {
1108    let corrupt = |reason: &'static str| SnapshotDecodeError::Corrupt { reason };
1109    let mut pos = 0;
1110
1111    if buf.len() < 4 {
1112        return Err(SnapshotDecodeError::Truncated);
1113    }
1114    let n_levels = le::get_u32(&buf[pos..]) as usize;
1115    pos += 4;
1116    // Each level has at least 12 bytes (trigger price + stop count).
1117    validate_count(buf.len() - pos, n_levels, 12)?;
1118
1119    let mut levels = Vec::with_capacity(n_levels);
1120    for _ in 0..n_levels {
1121        if pos + 12 > buf.len() {
1122            return Err(SnapshotDecodeError::Truncated);
1123        }
1124        let trigger_val = NonZeroU64::new(le::get_u64(&buf[pos..]))
1125            .ok_or(corrupt("zero trigger price in stop level"))?;
1126        pos += 8;
1127        let n_stops = le::get_u32(&buf[pos..]) as usize;
1128        pos += 4;
1129
1130        // Each stop is at least 31 bytes.
1131        validate_count(buf.len() - pos, n_stops, 31)?;
1132        let mut stops = Vec::with_capacity(n_stops);
1133        for _ in 0..n_stops {
1134            // id(8) + account(4) + side(1) + trigger(8) + qty(8) + tif(1) + limit_tag(1) = 31 min
1135            if pos + 31 > buf.len() {
1136                return Err(SnapshotDecodeError::Truncated);
1137            }
1138            let id = OrderId(le::get_u64(&buf[pos..]));
1139            pos += 8;
1140            let account = AccountId(le::get_u32(&buf[pos..]));
1141            pos += 4;
1142            let side = le::decode_side(buf[pos]).ok_or(corrupt("invalid side in stop"))?;
1143            pos += 1;
1144            let tp = NonZeroU64::new(le::get_u64(&buf[pos..]))
1145                .ok_or(corrupt("zero trigger price in stop"))?;
1146            pos += 8;
1147            let qty = NonZeroU64::new(le::get_u64(&buf[pos..]))
1148                .ok_or(corrupt("zero quantity in stop"))?;
1149            pos += 8;
1150            let tif = le::decode_tif(buf[pos]).ok_or(corrupt("invalid tif in stop"))?;
1151            pos += 1;
1152
1153            let limit_price = match buf[pos] {
1154                1 => {
1155                    pos += 1;
1156                    if pos + 8 > buf.len() {
1157                        return Err(SnapshotDecodeError::Truncated);
1158                    }
1159                    let lp = NonZeroU64::new(le::get_u64(&buf[pos..]))
1160                        .ok_or(corrupt("zero limit price in stop"))?;
1161                    pos += 8;
1162                    Some(Price(lp))
1163                }
1164                0 => {
1165                    pos += 1;
1166                    None
1167                }
1168                _ => return Err(corrupt("invalid limit_price tag in stop")),
1169            };
1170
1171            // Decode quote_budget (Option<u64>).
1172            if pos >= buf.len() {
1173                return Err(SnapshotDecodeError::Truncated);
1174            }
1175            let quote_budget = match buf[pos] {
1176                1 => {
1177                    pos += 1;
1178                    if pos + 8 > buf.len() {
1179                        return Err(SnapshotDecodeError::Truncated);
1180                    }
1181                    let budget = le::get_u64(&buf[pos..]);
1182                    pos += 8;
1183                    Some(budget)
1184                }
1185                0 => {
1186                    pos += 1;
1187                    None
1188                }
1189                _ => return Err(corrupt("invalid quote_budget tag in stop")),
1190            };
1191
1192            if pos >= buf.len() {
1193                return Err(SnapshotDecodeError::Truncated);
1194            }
1195            let stp = le::decode_stp(buf[pos]).ok_or(corrupt("invalid stp in stop"))?;
1196            pos += 1;
1197
1198            // expiry_ns (v11+): needed for GTD stop orders.
1199            let expiry_ns = if version >= 11 {
1200                if pos + 8 > buf.len() {
1201                    return Err(SnapshotDecodeError::Truncated);
1202                }
1203                let v = le::get_u64(&buf[pos..]);
1204                pos += 8;
1205                v
1206            } else {
1207                0
1208            };
1209
1210            stops.push(PendingStopSnapshot {
1211                id,
1212                account,
1213                side,
1214                trigger_price: Price(tp),
1215                quantity: Quantity(qty),
1216                time_in_force: tif,
1217                limit_price,
1218                quote_budget,
1219                stp,
1220                expiry_ns,
1221            });
1222        }
1223        levels.push((Price(trigger_val), stops));
1224    }
1225
1226    Ok((pos, levels))
1227}
1228
1229// --- Conversion: ExchangeSnapshot <-> actual types ---
1230
1231/// Rebuild the engine's scheduler heap by walking every restored instrument
1232/// for GTD orders. The heap is derived state — not stored in the snapshot —
1233/// so a fresh restore must re-emit one `ExpireOrder` task per live GTD
1234/// resting order or pending stop.
1235fn rebuild_scheduler_heap(
1236    instruments: &[Option<Box<crate::exchange::InstrumentState>>],
1237) -> ScheduledTaskHeap {
1238    let mut heap = ScheduledTaskHeap::new();
1239    for inst in instruments.iter().flatten() {
1240        let symbol = inst.spec.symbol;
1241        for (account, order_id, expiry_ns) in inst.book.iter_gtd_orders() {
1242            heap.push(ScheduledTask {
1243                fire_ns: expiry_ns,
1244                kind: ScheduledTaskKind::ExpireOrder {
1245                    symbol,
1246                    account,
1247                    order_id,
1248                },
1249            });
1250        }
1251    }
1252    heap
1253}
1254
1255/// Assemble the symbol-indexed `InstrumentState` Vec from the flat snapshot
1256/// Vecs. The output is the storage shape the live Exchange uses: a sparse
1257/// `Vec<Option<Box<InstrumentState>>>` where `Symbol.0` is the index. We
1258/// pick sparse Vec over `HashMap<Symbol, InstrumentState>` because
1259/// instrument lookup happens on every order — a Vec indexing op is
1260/// cache-friendly and branch-light, whereas HashMap probing pays a hash +
1261/// possible collision chase per access. Wasted slots for sparse symbol
1262/// allocations are acceptable (32 bytes per gap; symbol space is small).
1263fn build_indexed_instruments(
1264    specs: Vec<InstrumentSpec>,
1265    books: Vec<(Symbol, BookSnapshot)>,
1266    risk_limits: Vec<(Symbol, RiskLimits)>,
1267    circuit_breakers: Vec<(Symbol, CircuitBreakerConfig)>,
1268    fee_schedules: Vec<(Symbol, FeeSchedule)>,
1269    disabled_instruments: Vec<Symbol>,
1270) -> Vec<Option<Box<crate::exchange::InstrumentState>>> {
1271    use crate::exchange::InstrumentState;
1272
1273    let mut books_map: StdHashMap<Symbol, OrderBook> = StdHashMap::new();
1274    for (symbol, book_snap) in books {
1275        books_map.insert(symbol, OrderBook::restore(symbol, book_snap));
1276    }
1277    let risk_map: StdHashMap<Symbol, RiskLimits> = risk_limits.into_iter().collect();
1278    let cb_map: StdHashMap<Symbol, CircuitBreakerConfig> = circuit_breakers.into_iter().collect();
1279    let fee_map: StdHashMap<Symbol, FeeSchedule> = fee_schedules.into_iter().collect();
1280    let disabled_set: std::collections::HashSet<Symbol> =
1281        disabled_instruments.into_iter().collect();
1282
1283    let max_sym = specs.iter().map(|s| s.symbol.0 as usize).max().unwrap_or(0);
1284    let mut instruments: Vec<Option<Box<InstrumentState>>> = Vec::new();
1285    instruments.resize_with(max_sym + 1, || None);
1286    for spec in &specs {
1287        let idx = spec.symbol.0 as usize;
1288        let book = books_map
1289            .remove(&spec.symbol)
1290            .unwrap_or_else(|| OrderBook::new(spec.symbol));
1291        instruments[idx] = Some(Box::new(InstrumentState {
1292            spec: *spec,
1293            book,
1294            risk_limits: risk_map.get(&spec.symbol).copied().unwrap_or_default(),
1295            circuit_breaker: cb_map.get(&spec.symbol).copied().unwrap_or_default(),
1296            fee_schedule: fee_map.get(&spec.symbol).copied().unwrap_or_default(),
1297            disabled: disabled_set.contains(&spec.symbol),
1298        }));
1299    }
1300    instruments
1301}
1302
1303/// Patch each instrument's `OrderBook` with the real reservation slots
1304/// produced by `AccountManager::from_parts`. Books are restored with
1305/// `ReservationSlot::DUMMY` placeholders; this step replaces them with the
1306/// live slab handles so settlements can release the reserved balance.
1307fn inject_reservation_slots_into_instruments(
1308    instruments: &mut [Option<Box<crate::exchange::InstrumentState>>],
1309    slot_assignments: &[((AccountId, OrderId), ReservationSlot)],
1310) {
1311    for inst in instruments {
1312        if let Some(inst) = inst.as_deref_mut() {
1313            inst.book.inject_reservation_slots(slot_assignments);
1314        }
1315    }
1316}
1317
1318impl Exchange {
1319    /// Create a snapshot of all internal state for serialization.
1320    pub(crate) fn snapshot_state(&self) -> ExchangeSnapshot {
1321        let instruments: Vec<InstrumentSpec> = self.instrument_specs().copied().collect();
1322        let balances = self.accounts().snapshot_balances();
1323        let reservations = self.snapshot_reservations();
1324        let order_sides: Vec<((AccountId, OrderId), Side)> = self.snapshot_order_sides();
1325
1326        let books: Vec<(Symbol, BookSnapshot)> = self
1327            .books()
1328            .map(|(symbol, book)| (symbol, book.snapshot()))
1329            .collect();
1330
1331        let risk_limits = self.snapshot_risk_limits();
1332        let circuit_breakers = self.snapshot_circuit_breakers();
1333        let fee_schedules = self.snapshot_fee_schedules();
1334        let key_hwm = self.snapshot_key_hwm();
1335        let disabled_instruments = self.snapshot_disabled_instruments();
1336        let fee_account_deficits = self.accounts().snapshot_fee_deficits();
1337        let order_buckets = self.snapshot_order_buckets();
1338
1339        ExchangeSnapshot {
1340            instruments,
1341            balances,
1342            reservations,
1343            order_sides,
1344            books,
1345            risk_limits,
1346            circuit_breakers,
1347            fee_schedules,
1348            key_hwm,
1349            disabled_instruments,
1350            fee_account_deficits,
1351            order_buckets,
1352        }
1353    }
1354
1355    /// Reconstruct an Exchange from a snapshot.
1356    pub(crate) fn restore_state(state: ExchangeSnapshot) -> Self {
1357        // Exhaustive destructure (no `..`): if a new field is added to
1358        // `ExchangeSnapshot`, the compiler errors here, forcing us to wire
1359        // it through `restore_state` instead of silently dropping it on
1360        // recovery.
1361        // `order_sides` is derived state — `Exchange::snapshot_order_sides`
1362        // regenerates it from each book's active order/stop slots, so the
1363        // rebuilt books below produce it identically. We don't *use* the
1364        // snapshot's copy to construct anything, but we do verify it
1365        // matches the regenerated value after restore as a corruption
1366        // detector (catches torn writes or encoder bugs where books and
1367        // order_sides disagree). See the assertion at the end of this
1368        // function.
1369        let ExchangeSnapshot {
1370            instruments: instrument_specs,
1371            balances,
1372            reservations,
1373            order_sides: snapshot_order_sides,
1374            books,
1375            risk_limits,
1376            circuit_breakers,
1377            fee_schedules,
1378            key_hwm: key_hwm_entries,
1379            disabled_instruments,
1380            fee_account_deficits,
1381            order_buckets,
1382        } = state;
1383
1384        let mut instruments = build_indexed_instruments(
1385            instrument_specs,
1386            books,
1387            risk_limits,
1388            circuit_breakers,
1389            fee_schedules,
1390            disabled_instruments,
1391        );
1392
1393        let (accounts, slot_assignments) =
1394            AccountManager::from_parts(balances, reservations, fee_account_deficits);
1395        inject_reservation_slots_into_instruments(&mut instruments, &slot_assignments);
1396
1397        // Per-key request sequence HWM map (v9+). Uses the same custom
1398        // hasher as the live map so lookup behavior matches the running
1399        // engine; capacity sized to the snapshot to avoid mid-restore
1400        // rehashes.
1401        let mut key_hwm: crate::types::HashMap<u64, u64> =
1402            crate::types::HashMap::with_capacity_and_hasher(
1403                key_hwm_entries.len(),
1404                Default::default(),
1405            );
1406        for (key_hash, hwm) in key_hwm_entries {
1407            key_hwm.insert(key_hash, hwm);
1408        }
1409
1410        // Rebuild the scheduler heap from order state. Every GTD order that
1411        // is currently resting (or pending as a stop) needs an ExpireOrder
1412        // task — the heap is derived state, never stored in the snapshot.
1413        // `live_order_ids` is rebuilt the same way inside `from_parts`,
1414        // straight from the per-instrument order_index.
1415        let scheduled_tasks = rebuild_scheduler_heap(&instruments);
1416
1417        let mut exchange = Self::from_parts(instruments, accounts, key_hwm, scheduled_tasks);
1418        // Restore per-account rate-limiter bucket state (v18+). Empty
1419        // for older snapshots, in which case the limiter starts with
1420        // every account at full burst — same shape as a fresh start.
1421        // The operator-config knobs (`max_orders_per_second`,
1422        // `max_orders_burst`) are reapplied separately by the receiver
1423        // wiring; the bucket state restored here will only be observed
1424        // by the limiter once those knobs are non-zero.
1425        exchange.restore_order_buckets(order_buckets);
1426
1427        // Snapshot-corruption detector: the rebuilt books must produce the
1428        // same `order_sides` set the snapshot serialized. A mismatch means
1429        // the snapshot is internally inconsistent (e.g., torn write, encoder
1430        // bug, or a books-but-not-order_sides drift in some future change)
1431        // and continuing would silently restore wrong state. Sort both
1432        // sides before comparing — HashMap iteration order in
1433        // `order_index` is non-deterministic, but the set of entries must
1434        // be identical. This runs once at restore (not the hot path).
1435        let mut regenerated = exchange.snapshot_order_sides();
1436        let mut from_snapshot = snapshot_order_sides;
1437        // Sort by (AccountId, OrderId) key — keys are unique per entry, so
1438        // post-sort the vectors are canonical and structural equality
1439        // detects any side or key disagreement. `Side` itself isn't `Ord`,
1440        // so we can't fall back to a derived total order on the full tuple.
1441        regenerated.sort_unstable_by_key(|(k, _)| *k);
1442        from_snapshot.sort_unstable_by_key(|(k, _)| *k);
1443        if regenerated != from_snapshot {
1444            // Localize the divergence so an operator has something to act
1445            // on. Prefer the first per-entry disagreement over the
1446            // shared-prefix length, since that's the actionable signal.
1447            let diff = regenerated
1448                .iter()
1449                .zip(from_snapshot.iter())
1450                .position(|(a, b)| a != b);
1451            match diff {
1452                Some(i) => panic!(
1453                    "snapshot corruption: order_sides mismatch at sorted index {i} — \
1454                     books regenerated {:?}, snapshot had {:?}",
1455                    regenerated[i], from_snapshot[i],
1456                ),
1457                None => panic!(
1458                    "snapshot corruption: order_sides length mismatch — \
1459                     books regenerated {} entries, snapshot had {}",
1460                    regenerated.len(),
1461                    from_snapshot.len(),
1462                ),
1463            }
1464        }
1465
1466        exchange
1467    }
1468
1469    /// Create a deep copy of this Exchange by round-tripping through the
1470    /// snapshot representation. Used by the shadow snapshot stage to obtain
1471    /// an independent replica of the exchange state at startup.
1472    ///
1473    /// Not suitable for the hot path — allocates extensively.
1474    pub fn clone_via_snapshot(&self) -> Self {
1475        let mut cloned = Self::restore_state(self.snapshot_state());
1476        // The cap is operator config, not journaled state, so it isn't in
1477        // the snapshot payload. Carry it over in-process so the shadow
1478        // clone applies the same Rejected reasons as the primary —
1479        // otherwise a capped account on the primary would be unbounded
1480        // on the shadow, and shadow validation would diverge.
1481        cloned.set_max_open_orders_per_account(self.max_open_orders_per_account());
1482        // Same reasoning as above for the SEC-04 rate-limit config: not
1483        // journaled (operator config), but Rejected reports differ if
1484        // the shadow clone runs unthrottled — carry it over so the
1485        // shadow makes identical accept/reject decisions. The cloned
1486        // engine starts at default `(0, 0)`; transitioning from
1487        // disabled-to-active does NOT clear buckets (see the rule on
1488        // `set_max_orders_per_second`), so the snapshot-restored bucket
1489        // state is preserved through this call.
1490        let (rate, burst) = self.max_orders_per_second();
1491        cloned.set_max_orders_per_second(rate, burst);
1492        cloned
1493    }
1494}
1495
1496impl OrderBook {
1497    /// Create a snapshot of the order book state.
1498    pub(crate) fn snapshot(&self) -> BookSnapshot {
1499        let snapshot_side =
1500            |side: &crate::orderbook::BookSide| -> Vec<(Price, Vec<RestingOrderSnapshot>)> {
1501                side.levels_snapshot()
1502                    .into_iter()
1503                    .map(|(price, orders)| {
1504                        let snaps = orders
1505                            .into_iter()
1506                            .map(|o| RestingOrderSnapshot {
1507                                id: o.id(),
1508                                account: o.account(),
1509                                remaining: o.remaining(),
1510                                time_in_force: o.time_in_force(),
1511                                expiry_ns: o.expiry_ns(),
1512                            })
1513                            .collect();
1514                        (price, snaps)
1515                    })
1516                    .collect()
1517            };
1518
1519        let snapshot_stops = |stops: &crate::orderbook::StopSide| {
1520            stops
1521                .levels_snapshot()
1522                .into_iter()
1523                .map(|(trigger_price, pending)| {
1524                    let snaps = pending
1525                        .into_iter()
1526                        .map(|s| PendingStopSnapshot {
1527                            id: s.id(),
1528                            account: s.account(),
1529                            side: s.side(),
1530                            trigger_price: s.trigger_price(),
1531                            quantity: s.quantity(),
1532                            time_in_force: s.time_in_force(),
1533                            limit_price: s.limit_price(),
1534                            quote_budget: s.quote_budget(),
1535                            stp: s.stp(),
1536                            expiry_ns: s.expiry_ns(),
1537                        })
1538                        .collect();
1539                    (trigger_price, snaps)
1540                })
1541                .collect()
1542        };
1543
1544        BookSnapshot {
1545            bids: snapshot_side(self.bids()),
1546            asks: snapshot_side(self.asks()),
1547            order_index: self.snapshot_order_index(),
1548            stop_buys: snapshot_stops(self.stop_buys()),
1549            stop_sells: snapshot_stops(self.stop_sells()),
1550            stop_index: self.snapshot_stop_index(),
1551            last_trade_price: self.last_trade_price(),
1552        }
1553    }
1554
1555    /// Restore an order book from a snapshot.
1556    pub(crate) fn restore(symbol: Symbol, snap: BookSnapshot) -> Self {
1557        // Reconstruct a side and return the slab-index assignments so the
1558        // caller can populate `order_index` with valid node handles.
1559        let restore_side = |levels: Vec<(Price, Vec<RestingOrderSnapshot>)>, side: Side| {
1560            let materialized: Vec<(Price, Vec<crate::orderbook::RestingOrder>)> = levels
1561                .into_iter()
1562                .map(|(price, orders)| {
1563                    let restored = orders
1564                        .into_iter()
1565                        .map(|o| {
1566                            crate::orderbook::RestingOrder::new(
1567                                o.id,
1568                                o.account,
1569                                o.remaining,
1570                                o.time_in_force,
1571                                o.expiry_ns,
1572                                side,
1573                                ReservationSlot::DUMMY,
1574                            )
1575                        })
1576                        .collect();
1577                    (price, restored)
1578                })
1579                .collect();
1580            crate::orderbook::BookSide::from_levels_snapshot(side, materialized)
1581        };
1582
1583        let restore_stops = |levels: Vec<(Price, Vec<PendingStopSnapshot>)>| {
1584            let materialized: Vec<(Price, Vec<crate::orderbook::PendingStop>)> = levels
1585                .into_iter()
1586                .map(|(trigger_price, stops)| {
1587                    let pending = stops
1588                        .into_iter()
1589                        .map(|s| {
1590                            crate::orderbook::PendingStop::new(
1591                                s.id,
1592                                s.account,
1593                                s.side,
1594                                s.trigger_price,
1595                                s.quantity,
1596                                s.time_in_force,
1597                                s.limit_price,
1598                                s.quote_budget,
1599                                s.stp,
1600                                s.expiry_ns,
1601                                ReservationSlot::DUMMY,
1602                            )
1603                        })
1604                        .collect();
1605                    (trigger_price, pending)
1606                })
1607                .collect();
1608            crate::orderbook::StopSide::from_levels_snapshot(materialized)
1609        };
1610
1611        // Build sides first; they tell us each order's slab index, which
1612        // we need to populate `order_index` so cancel/amend stay O(1).
1613        let (bids, bid_node_idx) = restore_side(snap.bids, Side::Buy);
1614        let (asks, ask_node_idx) = restore_side(snap.asks, Side::Sell);
1615
1616        // Combine slab-index assignments into a lookup keyed by
1617        // (account, order_id). Both sides share the (account, order_id)
1618        // namespace via the snapshot codec, but each order lives in
1619        // exactly one side, so there are no key collisions.
1620        let mut node_for: std::collections::HashMap<(AccountId, OrderId), u32> =
1621            std::collections::HashMap::with_capacity(bid_node_idx.len() + ask_node_idx.len());
1622        node_for.extend(bid_node_idx);
1623        node_for.extend(ask_node_idx);
1624
1625        let order_index: crate::slab_map::SlabMap<(Side, Price, ReservationSlot, u32)> = snap
1626            .order_index
1627            .into_iter()
1628            .map(|(id, account, side, price)| {
1629                let node_idx = node_for
1630                    .get(&(account, id))
1631                    .copied()
1632                    // Snapshot self-consistency: every order_index entry
1633                    // must correspond to a resting order in the same
1634                    // snapshot. If it doesn't, the snapshot is corrupt and
1635                    // we'd rather fail loudly than silently skip cancels.
1636                    .expect("snapshot order_index references missing book entry");
1637                (
1638                    (account, id),
1639                    (side, price, ReservationSlot::DUMMY, node_idx),
1640                )
1641            })
1642            .collect();
1643
1644        // Build stop sides; collect the slab-index mapping the same way
1645        // as for resting orders so we can populate `stop_index` with
1646        // valid handles. Buy and sell stops live in disjoint slabs but
1647        // share the (account, order_id) namespace via the snapshot.
1648        let (stop_buys, buy_stop_idx) = restore_stops(snap.stop_buys);
1649        let (stop_sells, sell_stop_idx) = restore_stops(snap.stop_sells);
1650        let mut stop_node_for: std::collections::HashMap<(AccountId, OrderId), u32> =
1651            std::collections::HashMap::with_capacity(buy_stop_idx.len() + sell_stop_idx.len());
1652        stop_node_for.extend(buy_stop_idx);
1653        stop_node_for.extend(sell_stop_idx);
1654
1655        let stop_index: crate::slab_map::SlabMap<(Side, Price, u32)> = snap
1656            .stop_index
1657            .into_iter()
1658            .map(|(id, account, side, price)| {
1659                let node_idx = stop_node_for
1660                    .get(&(account, id))
1661                    .copied()
1662                    .expect("snapshot stop_index references missing stop entry");
1663                ((account, id), (side, price, node_idx))
1664            })
1665            .collect();
1666
1667        Self::from_parts(
1668            symbol,
1669            bids,
1670            asks,
1671            order_index,
1672            stop_buys,
1673            stop_sells,
1674            stop_index,
1675            snap.last_trade_price,
1676        )
1677    }
1678}
1679
1680#[cfg(test)]
1681mod tests {
1682    use std::num::NonZeroU64;
1683    use std::path::Path;
1684
1685    use super::*;
1686    use crate::exchange::Exchange;
1687    use crate::types::*;
1688
1689    // Engine-local round-trip framing for the snapshot codec tests
1690    // below. The production on-disk path lives in
1691    // `melin_transport_core::snapshot` (generic over `Application`,
1692    // including CRC32C framing) and is exercised by the integration
1693    // tests in `melin-server/tests/`. Engine tests only need to verify
1694    // the payload codec (`encode_exchange_payload` /
1695    // `decode_exchange_payload`) — the seq + chain_hash are persisted
1696    // alongside so existing tests that assert on them keep working.
1697    type SnapResult<T> = std::io::Result<T>;
1698
1699    fn save(exchange: &Exchange, seq: u64, chain_hash: [u8; 32], path: &Path) -> SnapResult<()> {
1700        let payload = encode_exchange_payload(exchange);
1701        let mut framed = Vec::with_capacity(40 + payload.len());
1702        framed.extend_from_slice(&seq.to_le_bytes());
1703        framed.extend_from_slice(&chain_hash);
1704        framed.extend_from_slice(&payload);
1705        std::fs::write(path, framed)
1706    }
1707
1708    fn load(path: &Path) -> SnapResult<(Exchange, u64, [u8; 32])> {
1709        let bytes = std::fs::read(path)?;
1710        if bytes.len() < 40 {
1711            return Err(std::io::Error::other("truncated test snapshot header"));
1712        }
1713        let seq = u64::from_le_bytes(bytes[..8].try_into().unwrap());
1714        let mut hash = [0u8; 32];
1715        hash.copy_from_slice(&bytes[8..40]);
1716        let exchange = decode_exchange_payload(&bytes[40..])
1717            .map_err(|e| std::io::Error::other(e.to_string()))?;
1718        Ok((exchange, seq, hash))
1719    }
1720
1721    const ACCT_A: AccountId = AccountId(1);
1722    const ACCT_B: AccountId = AccountId(2);
1723    const BTC: CurrencyId = CurrencyId(0);
1724    const USD: CurrencyId = CurrencyId(1);
1725
1726    fn btc_usd_spec() -> InstrumentSpec {
1727        InstrumentSpec {
1728            symbol: Symbol(1),
1729            base: BTC,
1730            quote: USD,
1731        }
1732    }
1733
1734    fn qty(n: u64) -> Quantity {
1735        Quantity(NonZeroU64::new(n).unwrap())
1736    }
1737
1738    fn price_val(n: u64) -> Price {
1739        Price(NonZeroU64::new(n).unwrap())
1740    }
1741
1742    fn limit_order(id: u64, account: AccountId, side: Side, p: u64, q: u64) -> Order {
1743        Order {
1744            id: OrderId(id),
1745            account,
1746            side,
1747            order_type: OrderType::Limit {
1748                price: price_val(p),
1749                post_only: false,
1750            },
1751            time_in_force: TimeInForce::GTC,
1752            quantity: qty(q),
1753            stp: SelfTradeProtection::Allow,
1754            expiry_ns: 0,
1755        }
1756    }
1757
1758    // Note: the previous engine-side `checksum_mismatch_surfaces_as_snapshot_error`
1759    // test was deleted in the engine ↔ core decoupling. That guarantee
1760    // belongs to `melin_transport_core::snapshot`, which has its own
1761    // framing-corruption tests, and `melin-server/tests/` exercises the
1762    // full production framing end-to-end via `Application`.
1763
1764    #[test]
1765    fn snapshot_save_load_round_trip() {
1766        let dir = tempfile::tempdir().unwrap();
1767        let path = dir.path().join("test.snapshot");
1768
1769        let mut exchange = Exchange::new();
1770        exchange.add_instrument(btc_usd_spec());
1771        exchange.deposit(ACCT_A, USD, 100_000);
1772        exchange.deposit(ACCT_B, BTC, 500);
1773
1774        let mut reports = Vec::new();
1775        exchange.execute(
1776            Symbol(1),
1777            limit_order(1, ACCT_B, Side::Sell, 100, 50),
1778            &mut reports,
1779        );
1780        exchange.execute(
1781            Symbol(1),
1782            limit_order(2, ACCT_A, Side::Buy, 100, 30),
1783            &mut reports,
1784        );
1785
1786        save(&exchange, 42, [0u8; 32], &path).unwrap();
1787
1788        let (restored, seq, _chain_hash) = load(&path).unwrap();
1789        assert_eq!(seq, 42);
1790        assert_eq!(
1791            restored.accounts().balance(ACCT_A, USD).available,
1792            exchange.accounts().balance(ACCT_A, USD).available
1793        );
1794        assert_eq!(
1795            restored.accounts().balance(ACCT_A, USD).reserved,
1796            exchange.accounts().balance(ACCT_A, USD).reserved
1797        );
1798        assert_eq!(
1799            restored.accounts().balance(ACCT_A, BTC).available,
1800            exchange.accounts().balance(ACCT_A, BTC).available
1801        );
1802        assert_eq!(
1803            restored.accounts().balance(ACCT_B, USD).available,
1804            exchange.accounts().balance(ACCT_B, USD).available
1805        );
1806        assert_eq!(
1807            restored.accounts().balance(ACCT_B, BTC).available,
1808            exchange.accounts().balance(ACCT_B, BTC).available
1809        );
1810        assert_eq!(
1811            restored.accounts().balance(ACCT_B, BTC).reserved,
1812            exchange.accounts().balance(ACCT_B, BTC).reserved
1813        );
1814    }
1815
1816    #[test]
1817    fn snapshot_with_resting_orders_replays_correctly() {
1818        let dir = tempfile::tempdir().unwrap();
1819        let path = dir.path().join("resting.snapshot");
1820
1821        let mut exchange = Exchange::new();
1822        exchange.add_instrument(btc_usd_spec());
1823        exchange.deposit(ACCT_A, USD, 100_000);
1824        exchange.deposit(ACCT_B, BTC, 500);
1825
1826        let mut reports = Vec::new();
1827        // Place resting sell.
1828        exchange.execute(
1829            Symbol(1),
1830            limit_order(1, ACCT_B, Side::Sell, 100, 50),
1831            &mut reports,
1832        );
1833        reports.clear();
1834
1835        save(&exchange, 10, [0u8; 32], &path).unwrap();
1836
1837        let (mut restored, _seq, _chain_hash) = load(&path).unwrap();
1838
1839        // Buy should match against the resting sell from snapshot.
1840        let mut new_reports = Vec::new();
1841        restored.execute(
1842            Symbol(1),
1843            limit_order(2, ACCT_A, Side::Buy, 100, 20),
1844            &mut new_reports,
1845        );
1846
1847        assert!(matches!(new_reports[0], ExecutionReport::Fill { .. }));
1848        assert_eq!(restored.accounts().balance(ACCT_A, BTC).available, 20);
1849    }
1850
1851    #[test]
1852    fn snapshot_preserves_circuit_breaker_state() {
1853        let dir = tempfile::tempdir().unwrap();
1854        let path = dir.path().join("cb.snapshot");
1855
1856        let mut exchange = Exchange::new();
1857        exchange.add_instrument(btc_usd_spec());
1858        exchange.deposit(ACCT_A, USD, 100_000);
1859
1860        // Set circuit breaker with price bands + halt.
1861        exchange.set_circuit_breaker(
1862            Symbol(1),
1863            CircuitBreakerConfig {
1864                price_band_lower: Some(price_val(90)),
1865                price_band_upper: Some(price_val(110)),
1866                halted: true,
1867            },
1868        );
1869
1870        save(&exchange, 5, [0u8; 32], &path).unwrap();
1871        let (mut restored, _, _) = load(&path).unwrap();
1872
1873        // Halt should still be active after restore.
1874        let mut reports = Vec::new();
1875        restored.execute(
1876            Symbol(1),
1877            limit_order(1, ACCT_A, Side::Buy, 100, 10),
1878            &mut reports,
1879        );
1880        assert!(matches!(
1881            reports[0],
1882            ExecutionReport::Rejected {
1883                reason: RejectReason::TradingHalted,
1884                ..
1885            }
1886        ));
1887
1888        // Unhalt, price bands should still be active.
1889        restored.set_circuit_breaker(
1890            Symbol(1),
1891            CircuitBreakerConfig {
1892                price_band_lower: Some(price_val(90)),
1893                price_band_upper: Some(price_val(110)),
1894                halted: false,
1895            },
1896        );
1897
1898        reports.clear();
1899        restored.execute(
1900            Symbol(1),
1901            limit_order(2, ACCT_A, Side::Buy, 80, 10),
1902            &mut reports,
1903        );
1904        assert!(matches!(
1905            reports[0],
1906            ExecutionReport::Rejected {
1907                reason: RejectReason::OutsidePriceBand,
1908                ..
1909            }
1910        ));
1911
1912        // In-range order should succeed.
1913        reports.clear();
1914        restored.execute(
1915            Symbol(1),
1916            limit_order(3, ACCT_A, Side::Buy, 100, 10),
1917            &mut reports,
1918        );
1919        assert!(matches!(reports[0], ExecutionReport::Placed { .. }));
1920    }
1921
1922    #[test]
1923    fn snapshot_preserves_gtd_expiry() {
1924        let dir = tempfile::tempdir().unwrap();
1925        let path = dir.path().join("gtd.snapshot");
1926
1927        let mut exchange = Exchange::new();
1928        exchange.add_instrument(btc_usd_spec());
1929        exchange.deposit(ACCT_A, USD, 100_000);
1930
1931        let mut reports = Vec::new();
1932
1933        // Place a GTD order with expiry_ns = 5_000_000.
1934        exchange.execute(
1935            Symbol(1),
1936            Order {
1937                id: OrderId(1),
1938                account: ACCT_A,
1939                side: Side::Buy,
1940                order_type: OrderType::Limit {
1941                    price: price_val(100),
1942                    post_only: false,
1943                },
1944                time_in_force: TimeInForce::GTD,
1945                quantity: qty(10),
1946                stp: SelfTradeProtection::Allow,
1947                expiry_ns: 5_000_000,
1948            },
1949            &mut reports,
1950        );
1951        assert!(matches!(reports[0], ExecutionReport::Placed { .. }));
1952        reports.clear();
1953
1954        save(&exchange, 20, [0u8; 32], &path).unwrap();
1955        let (mut restored, _, _) = load(&path).unwrap();
1956
1957        // The GTD order should still be on the book and the scheduler heap
1958        // must have been rebuilt from order state. A pre-expiry tick is a
1959        // no-op; an at-expiry tick fires the rebuilt task and cancels.
1960        restored.drain_due_scheduled_tasks(4_999_999, &mut reports);
1961        assert!(reports.is_empty(), "should not expire before timestamp");
1962
1963        restored.drain_due_scheduled_tasks(5_000_000, &mut reports);
1964        assert_eq!(reports.len(), 1);
1965        assert!(matches!(
1966            reports[0],
1967            ExecutionReport::Cancelled {
1968                order_id: OrderId(1),
1969                ..
1970            }
1971        ));
1972    }
1973
1974    #[test]
1975    fn clone_via_snapshot_produces_identical_state() {
1976        let mut exchange = Exchange::new();
1977        exchange.add_instrument(btc_usd_spec());
1978        exchange.deposit(ACCT_A, USD, 100_000);
1979        exchange.deposit(ACCT_B, BTC, 500);
1980
1981        let mut reports = Vec::new();
1982        exchange.execute(
1983            Symbol(1),
1984            limit_order(1, ACCT_B, Side::Sell, 100, 50),
1985            &mut reports,
1986        );
1987        reports.clear();
1988
1989        let cloned = exchange.clone_via_snapshot();
1990
1991        // Balances should match.
1992        assert_eq!(
1993            cloned.accounts().balance(ACCT_A, USD).available,
1994            exchange.accounts().balance(ACCT_A, USD).available,
1995        );
1996        assert_eq!(
1997            cloned.accounts().balance(ACCT_B, BTC).reserved,
1998            exchange.accounts().balance(ACCT_B, BTC).reserved,
1999        );
2000
2001        // Resting order should match — buy against it on the clone.
2002        let mut clone_reports = Vec::new();
2003        let mut mutable_clone = cloned;
2004        mutable_clone.execute(
2005            Symbol(1),
2006            limit_order(2, ACCT_A, Side::Buy, 100, 10),
2007            &mut clone_reports,
2008        );
2009        assert!(matches!(clone_reports[0], ExecutionReport::Fill { .. }));
2010    }
2011
2012    #[test]
2013    #[should_panic(expected = "snapshot corruption: order_sides mismatch")]
2014    fn restore_detects_order_sides_mismatch() {
2015        // Build an exchange with one resting order so `order_sides` is
2016        // non-empty and the mismatch is observable.
2017        let mut exchange = Exchange::new();
2018        exchange.add_instrument(btc_usd_spec());
2019        exchange.deposit(ACCT_B, BTC, 500);
2020        let mut reports = Vec::new();
2021        exchange.execute(
2022            Symbol(1),
2023            limit_order(1, ACCT_B, Side::Sell, 100, 50),
2024            &mut reports,
2025        );
2026
2027        // Mutate the snapshot's `order_sides` so it disagrees with what
2028        // the rebuilt books will regenerate. Flipping the recorded side
2029        // is enough — the entry count still matches, but the value set
2030        // doesn't.
2031        let mut state = exchange.snapshot_state();
2032        assert!(!state.order_sides.is_empty(), "test prerequisite");
2033        state.order_sides[0].1 = Side::Buy;
2034
2035        // `restore_state` must panic on the inconsistency rather than
2036        // silently restore wrong state.
2037        let _ = Exchange::restore_state(state);
2038    }
2039
2040    #[test]
2041    fn snapshot_rebuilds_scheduler_heap_from_gtd_orders() {
2042        let dir = tempfile::tempdir().unwrap();
2043        let path = dir.path().join("rebuild.snapshot");
2044
2045        let mut exchange = Exchange::new();
2046        exchange.add_instrument(btc_usd_spec());
2047        exchange.deposit(ACCT_A, USD, 10_000_000);
2048
2049        // Mix resting GTD limits with a GTD pending stop so the rebuild
2050        // path covers both `iter_gtd_orders` branches (book + stop_index).
2051        let mut reports = Vec::new();
2052        // Resting GTD limit at expiry 5_000.
2053        exchange.execute(
2054            Symbol(1),
2055            Order {
2056                id: OrderId(1),
2057                account: ACCT_A,
2058                side: Side::Buy,
2059                order_type: OrderType::Limit {
2060                    price: price_val(100),
2061                    post_only: false,
2062                },
2063                time_in_force: TimeInForce::GTD,
2064                quantity: qty(1),
2065                stp: SelfTradeProtection::Allow,
2066                expiry_ns: 5_000,
2067            },
2068            &mut reports,
2069        );
2070        // Pending GTD stop-limit at expiry 6_000. Stop-limit (rather than
2071        // bare Stop) keeps the reservation bounded to trigger_price × qty
2072        // so the third order below can also reserve.
2073        exchange.execute(
2074            Symbol(1),
2075            Order {
2076                id: OrderId(2),
2077                account: ACCT_A,
2078                side: Side::Buy,
2079                order_type: OrderType::StopLimit {
2080                    trigger_price: price_val(200),
2081                    limit_price: price_val(200),
2082                },
2083                time_in_force: TimeInForce::GTD,
2084                quantity: qty(1),
2085                stp: SelfTradeProtection::Allow,
2086                expiry_ns: 6_000,
2087            },
2088            &mut reports,
2089        );
2090        // Second resting GTD limit at expiry 8_000.
2091        exchange.execute(
2092            Symbol(1),
2093            Order {
2094                id: OrderId(3),
2095                account: ACCT_A,
2096                side: Side::Buy,
2097                order_type: OrderType::Limit {
2098                    price: price_val(101),
2099                    post_only: false,
2100                },
2101                time_in_force: TimeInForce::GTD,
2102                quantity: qty(1),
2103                stp: SelfTradeProtection::Allow,
2104                expiry_ns: 8_000,
2105            },
2106            &mut reports,
2107        );
2108        reports.clear();
2109
2110        // Sanity: all 3 orders should have scheduled tasks before the snapshot.
2111        assert_eq!(exchange.scheduled_task_count(), 3, "pre-snapshot heap");
2112
2113        save(&exchange, 7, [0u8; 32], &path).unwrap();
2114        let (mut restored, _, _) = load(&path).unwrap();
2115
2116        // Sanity: rebuild restored all 3 tasks from the order books.
2117        assert_eq!(restored.scheduled_task_count(), 3, "post-restore heap");
2118
2119        // Pre-expiry tick: nothing fires.
2120        restored.drain_due_scheduled_tasks(4_999, &mut reports);
2121        assert!(reports.is_empty());
2122
2123        // Drain at 5_000: only the first limit fires.
2124        restored.drain_due_scheduled_tasks(5_000, &mut reports);
2125        assert_eq!(reports.len(), 1);
2126        assert!(matches!(
2127            reports[0],
2128            ExecutionReport::Cancelled {
2129                order_id: OrderId(1),
2130                ..
2131            }
2132        ));
2133        reports.clear();
2134
2135        // Drain at 6_000: the pending stop fires (rebuilt from stop_index).
2136        restored.drain_due_scheduled_tasks(6_000, &mut reports);
2137        assert_eq!(reports.len(), 1);
2138        assert!(matches!(
2139            reports[0],
2140            ExecutionReport::Cancelled {
2141                order_id: OrderId(2),
2142                ..
2143            }
2144        ));
2145        reports.clear();
2146
2147        // Drain at 8_000: the second resting limit fires.
2148        restored.drain_due_scheduled_tasks(8_000, &mut reports);
2149        assert_eq!(reports.len(), 1);
2150        assert!(matches!(
2151            reports[0],
2152            ExecutionReport::Cancelled {
2153                order_id: OrderId(3),
2154                ..
2155            }
2156        ));
2157    }
2158
2159    /// SEC-04 v18+ regression: per-account rate-limiter bucket state must
2160    /// survive a snapshot round-trip so a replica restoring from a
2161    /// snapshot taken mid-throttle sees the same `tokens` /
2162    /// `last_refill_ns` the primary had — and therefore makes identical
2163    /// accept/reject decisions on the very next event. Without this,
2164    /// the replica would re-initialise buckets lazily as full and
2165    /// diverge for the bounded `burst/rate` window.
2166    #[test]
2167    fn snapshot_round_trip_preserves_rate_limit_buckets() {
2168        let dir = tempfile::tempdir().unwrap();
2169        let path = dir.path().join("rate_limit.snapshot");
2170
2171        let mut exchange = Exchange::new();
2172        exchange.set_max_orders_per_second(1_000, 5);
2173        exchange.add_instrument(btc_usd_spec());
2174        exchange.deposit(ACCT_A, USD, 1_000_000);
2175        exchange.deposit(ACCT_B, USD, 1_000_000);
2176
2177        // Drive the limiter so both accounts have non-trivial bucket
2178        // state. ACCT_A burns 3/5 of its burst at t=1s; ACCT_B burns
2179        // 1/5 at t=2s. Distinct `last_refill_ns` per bucket so a wrong
2180        // restore (e.g. snapping to 0) would be caught by the equality
2181        // assertion below.
2182        let mut reports = Vec::new();
2183        for i in 0..3u64 {
2184            exchange.set_current_event_ts_ns(1_000_000_000);
2185            exchange.execute(
2186                Symbol(1),
2187                limit_order(i + 1, ACCT_A, Side::Buy, 100, 1),
2188                &mut reports,
2189            );
2190        }
2191        exchange.set_current_event_ts_ns(2_000_000_000);
2192        exchange.execute(
2193            Symbol(1),
2194            limit_order(100, ACCT_B, Side::Buy, 101, 1),
2195            &mut reports,
2196        );
2197
2198        let pre = exchange.snapshot_order_buckets();
2199        assert_eq!(pre.len(), 2, "two buckets should be populated");
2200
2201        save(&exchange, 1, [0u8; 32], &path).unwrap();
2202        let (mut restored, _seq, _hash) = load(&path).unwrap();
2203        // The receiver wiring re-applies the operator config after
2204        // load. Use the same values to exercise the no-clear path.
2205        restored.set_max_orders_per_second(1_000, 5);
2206
2207        let post = restored.snapshot_order_buckets();
2208        // Bucket maps must be equal as sets (HashMap iteration order is
2209        // unspecified); compare via sorted Vecs.
2210        let mut pre_sorted = pre.clone();
2211        let mut post_sorted = post;
2212        pre_sorted.sort_by_key(|(a, _, _)| a.0);
2213        post_sorted.sort_by_key(|(a, _, _)| a.0);
2214        assert_eq!(pre_sorted, post_sorted);
2215
2216        // Functional check: an immediate next event on the restored
2217        // engine must see the same accept/reject decision the primary
2218        // would. ACCT_A burned 3 of 5 tokens at t=1s, so at t=1s+1ns it
2219        // has 2 tokens left — exactly two more accepts before rejection.
2220        let mut after = Vec::new();
2221        for i in 0..2u64 {
2222            restored.set_current_event_ts_ns(1_000_000_000 + 1 + i);
2223            restored.execute(
2224                Symbol(1),
2225                limit_order(200 + i, ACCT_A, Side::Buy, 102 + i, 1),
2226                &mut after,
2227            );
2228        }
2229        assert!(
2230            !after
2231                .iter()
2232                .any(|r| matches!(r, ExecutionReport::Rejected { .. })),
2233            "two more orders should fit in the restored bucket: {after:?}",
2234        );
2235        after.clear();
2236        // Third post-restore order with negligible elapsed time must
2237        // reject — proves the bucket really was at 2 tokens, not 5.
2238        restored.set_current_event_ts_ns(1_000_000_000 + 10);
2239        restored.execute(
2240            Symbol(1),
2241            limit_order(999, ACCT_A, Side::Buy, 200, 1),
2242            &mut after,
2243        );
2244        assert!(
2245            matches!(
2246                after[0],
2247                ExecutionReport::Rejected {
2248                    reason: RejectReason::ExceedsOrderRate,
2249                    ..
2250                }
2251            ),
2252            "restored bucket lost throttle state: {after:?}",
2253        );
2254    }
2255
2256    /// A v18 snapshot whose bucket section is missing — physically
2257    /// truncated mid-stream — must fail decode rather than silently
2258    /// returning empty buckets. The pre-SF2 guard
2259    /// `if version >= 18 && pos < buf.len()` swallowed truncation as
2260    /// "no entries", which would let a corrupt snapshot restore an
2261    /// exchange that diverges from the primary on the very next event.
2262    #[test]
2263    fn truncated_v18_snapshot_payload_errors_instead_of_emptying_buckets() {
2264        let mut exchange = Exchange::new();
2265        exchange.set_max_orders_per_second(1_000, 5);
2266        exchange.add_instrument(btc_usd_spec());
2267        exchange.deposit(ACCT_A, USD, 1_000_000);
2268        let mut reports = Vec::new();
2269        exchange.set_current_event_ts_ns(1_000_000_000);
2270        exchange.execute(
2271            Symbol(1),
2272            limit_order(1, ACCT_A, Side::Buy, 100, 1),
2273            &mut reports,
2274        );
2275
2276        // Encode, then strip the trailing rate-limiter bucket section
2277        // (length u32 + 1 entry of 20 bytes = 24 bytes). The truncated
2278        // payload looks valid up to the bucket boundary, mirroring a
2279        // real on-disk truncation.
2280        let full = encode_exchange_payload(&exchange);
2281        let truncated = &full[..full.len() - 24];
2282        match decode_exchange_payload(truncated) {
2283            Err(SnapshotDecodeError::Truncated) => {}
2284            Err(other) => panic!("expected TruncatedEntry, got {other:?}"),
2285            Ok(_) => panic!("truncated v18 payload must not decode silently as empty"),
2286        }
2287    }
2288
2289    /// SEC-04 v18+: the decoder must reject a payload that contains the
2290    /// same `AccountId` twice in the rate-limiter bucket section. The
2291    /// encoder writes each account at most once (HashMap iteration), so
2292    /// a duplicate means the snapshot was tampered or corrupted. Silent
2293    /// overwrite would let an attacker shadow a depleted bucket with a
2294    /// synthetic full-credit one.
2295    #[test]
2296    fn duplicate_account_in_v18_bucket_section_rejected() {
2297        let mut exchange = Exchange::new();
2298        exchange.set_max_orders_per_second(1_000, 5);
2299        exchange.add_instrument(btc_usd_spec());
2300        exchange.deposit(ACCT_A, USD, 1_000_000);
2301        let mut reports = Vec::new();
2302        exchange.set_current_event_ts_ns(1_000_000_000);
2303        exchange.execute(
2304            Symbol(1),
2305            limit_order(1, ACCT_A, Side::Buy, 100, 1),
2306            &mut reports,
2307        );
2308
2309        let mut payload = encode_exchange_payload(&exchange);
2310        // Bucket section is the trailing run: [u32 count][entry × count],
2311        // entry = AccountId(u32) + tokens(u64) + last_refill_ns(u64) = 20 B.
2312        // Bump the count by one and append a duplicate of the existing entry.
2313        let entry_start = payload.len() - 20;
2314        let dup_entry = payload[entry_start..].to_vec();
2315        let count_pos = entry_start - 4;
2316        let count = le::get_u32(&payload[count_pos..]);
2317        // u32 is the on-wire count type; if this ever overflows the test
2318        // setup is the bug, not the production code.
2319        let new_count = count
2320            .checked_add(1)
2321            .expect("test fixture must keep count within u32");
2322        payload[count_pos..count_pos + 4].copy_from_slice(&new_count.to_le_bytes());
2323        payload.extend_from_slice(&dup_entry);
2324
2325        match decode_exchange_payload(&payload) {
2326            Err(SnapshotDecodeError::Corrupt { reason, .. }) => {
2327                assert!(
2328                    reason.contains("duplicate account"),
2329                    "expected duplicate-account corruption, got: {reason}",
2330                );
2331            }
2332            Err(other) => panic!("expected CorruptEntry, got {other:?}"),
2333            Ok(_) => panic!("duplicate-account payload must not decode silently"),
2334        }
2335    }
2336
2337    /// SEC-04 v18+: `set_max_orders_per_second` must NOT clear bucket
2338    /// state when called with the same `(rate, burst)` already in
2339    /// effect. This is what allows the receiver wiring to re-apply
2340    /// operator config after a snapshot restore without wiping the
2341    /// state we just restored.
2342    #[test]
2343    fn rate_limit_set_idempotent_preserves_buckets() {
2344        let mut exchange = Exchange::new();
2345        exchange.set_max_orders_per_second(500, 3);
2346        exchange.add_instrument(btc_usd_spec());
2347        exchange.deposit(ACCT_A, USD, 1_000_000);
2348        let mut reports = Vec::new();
2349        exchange.set_current_event_ts_ns(1_000);
2350        exchange.execute(
2351            Symbol(1),
2352            limit_order(1, ACCT_A, Side::Buy, 100, 1),
2353            &mut reports,
2354        );
2355        let before = exchange.snapshot_order_buckets();
2356        assert_eq!(before.len(), 1);
2357        // Same values — must be a no-op for buckets.
2358        exchange.set_max_orders_per_second(500, 3);
2359        let after = exchange.snapshot_order_buckets();
2360        assert_eq!(before, after, "same-config call must not clear");
2361        // Different values — must clear.
2362        exchange.set_max_orders_per_second(500, 4);
2363        assert!(
2364            exchange.snapshot_order_buckets().is_empty(),
2365            "changed-config call must clear",
2366        );
2367    }
2368}