Skip to main content

hypercall_state_commitment/
pipeline.rs

1//! Commitment pipeline: converts engine state deltas into Merkle roots.
2//!
3//! The pipeline is the bridge between the engine (which knows nothing about
4//! Merkle trees) and the JMT store (which knows nothing about accounts or
5//! positions). The integration layer in the main binary feeds `StateDelta`s
6//! in; the pipeline produces `BatchCommitment`s out.
7//!
8//! ```text
9//! Engine → apply() → events → integration layer → StateDelta
10//!                                                      ↓
11//!                                              CommitmentPipeline
12//!                                                      ↓
13//!                                              BatchCommitment { state_root, risk_root, batch_root }
14//! ```
15
16use jmt::storage::{TreeReader, TreeUpdateBatch, TreeWriter};
17use jmt::{KeyHash, RootHash, Version};
18
19use crate::batch_root::compute_batch_root;
20use crate::keys::StateKey;
21use crate::leaves::*;
22use crate::mmr::{
23    HypercallMmr, MmrHash, MmrMetadataStore, PrepareMmrStore, PreparedMmrAppend, SupportedMmrStore,
24};
25#[cfg(feature = "rocksdb")]
26use crate::rocks_store::{RocksDbMmrStore, RocksDbStore};
27use crate::state_tree::new_jmt;
28
29/// A batch of state changes to commit.
30///
31/// The integration layer constructs this from engine events and state reads.
32/// The pipeline doesn't know or care where the data comes from — it just
33/// converts it to JMT leaf updates.
34///
35/// All monetary values are pre-scaled to 1e8 by the caller.
36#[derive(Debug, Default, Clone)]
37pub struct StateDelta {
38    pub commands: Vec<[u8; 32]>,
39    pub obligations: Vec<[u8; 32]>,
40    pub intents: Vec<[u8; 32]>,
41    pub accounts: Vec<AccountUpdate>,
42    pub option_positions: Vec<OptionPositionUpdate>,
43    pub perp_positions: Vec<PerpPositionUpdate>,
44    pub orders: Vec<OrderUpdate>,
45    pub instruments: Vec<InstrumentUpdate>,
46    pub oracles: Vec<OracleUpdate>,
47    pub mmp_configs: Vec<MmpConfigUpdate>,
48    pub risk: Vec<RiskUpdate>,
49    pub global: Option<GlobalLeaf>,
50}
51
52#[derive(Debug, Clone)]
53pub struct AccountUpdate {
54    pub address: [u8; 20],
55    pub leaf: AccountLeaf,
56}
57
58#[derive(Debug, Clone)]
59pub struct OptionPositionUpdate {
60    pub address: [u8; 20],
61    pub symbol: String,
62    /// None = position closed (delete leaf).
63    pub leaf: Option<OptionPositionLeaf>,
64}
65
66#[derive(Debug, Clone)]
67pub struct PerpPositionUpdate {
68    pub address: [u8; 20],
69    pub coin: String,
70    /// None = position closed (delete leaf).
71    pub leaf: Option<PerpPositionLeaf>,
72}
73
74#[derive(Debug, Clone)]
75pub struct OrderUpdate {
76    pub address: [u8; 20],
77    pub order_id: u64,
78    /// None = order cancelled/filled (delete leaf).
79    pub leaf: Option<OrderLeaf>,
80}
81
82#[derive(Debug, Clone)]
83pub struct InstrumentUpdate {
84    pub symbol: String,
85    pub leaf: InstrumentLeaf,
86}
87
88#[derive(Debug, Clone)]
89pub struct OracleUpdate {
90    pub underlying: String,
91    pub leaf: OracleLeaf,
92}
93
94#[derive(Debug, Clone)]
95pub struct MmpConfigUpdate {
96    pub address: [u8; 20],
97    pub currency: String,
98    pub leaf: MmpConfigLeaf,
99}
100
101#[derive(Debug, Clone)]
102pub struct RiskUpdate {
103    pub address: [u8; 20],
104    pub leaf: RiskLeaf,
105}
106
107/// Output of a batch commitment.
108#[derive(Debug, Clone)]
109pub struct BatchCommitment {
110    pub version: Version,
111    pub state_root: RootHash,
112    pub risk_root: RootHash,
113    pub command_mmr_root: MmrHash,
114    pub obligation_mmr_root: MmrHash,
115    pub intent_mmr_root: MmrHash,
116    pub batch_root: [u8; 32],
117    pub state_leaves_updated: usize,
118    pub risk_leaves_updated: usize,
119}
120
121#[derive(Debug)]
122pub struct PreparedBatchCommitment {
123    pub commitment: BatchCommitment,
124    state_update: Option<TreeUpdateBatch>,
125    risk_update: Option<TreeUpdateBatch>,
126    command_mmr: PreparedMmrAppend,
127    obligation_mmr: PreparedMmrAppend,
128    intent_mmr: PreparedMmrAppend,
129}
130
131/// The commitment pipeline.
132///
133/// Generic over the store backend (`S`) so it works with both
134/// `FastMemoryStore` (tests) and `RocksDbStore` (production).
135///
136/// Two separate JMTs:
137/// - **State tree**: accounts, positions, orders, instruments, oracles, global
138/// - **Risk tree**: per-account risk metrics (equity, margin, scanning risk)
139///
140/// Plus three MMRs for append-only data (command log, obligations, intents).
141pub struct CommitmentPipeline<S, M = crate::mmr::MemoryMmrStore> {
142    state_store: S,
143    risk_store: S,
144    command_mmr: HypercallMmr<M>,
145    obligation_mmr: HypercallMmr<M>,
146    intent_mmr: HypercallMmr<M>,
147    version: Version,
148    prune_window: u64,
149}
150
151#[cfg(feature = "rocksdb")]
152impl CommitmentPipeline<RocksDbStore, RocksDbMmrStore> {
153    pub fn checkpoint_inherited_state_root(&self, version: Version) -> anyhow::Result<bool> {
154        self.state_store.copy_latest_root_to_version(version)
155    }
156
157    pub fn checkpoint_inherited_risk_root(&self, version: Version) -> anyhow::Result<bool> {
158        self.risk_store.copy_latest_root_to_version(version)
159    }
160
161    pub fn prune_stores(&self, prune_to: Version) -> anyhow::Result<(usize, usize)> {
162        let (state_pruned, _) = self.state_store.prune(prune_to)?;
163        let (risk_pruned, _) = self.risk_store.prune(prune_to)?;
164        Ok((state_pruned, risk_pruned))
165    }
166
167    pub fn compact_stores(&self) -> anyhow::Result<()> {
168        self.state_store.compact_all()?;
169        self.risk_store.compact_all()?;
170        Ok(())
171    }
172}
173
174impl<S: TreeReader + TreeWriter> CommitmentPipeline<S, crate::mmr::MemoryMmrStore> {
175    /// Create a new pipeline starting at version 0.
176    pub fn new(state_store: S, risk_store: S, prune_window: u64) -> Self {
177        Self {
178            state_store,
179            risk_store,
180            command_mmr: HypercallMmr::new(),
181            obligation_mmr: HypercallMmr::new(),
182            intent_mmr: HypercallMmr::new(),
183            version: 0,
184            prune_window,
185        }
186    }
187}
188
189impl<S, M> CommitmentPipeline<S, M>
190where
191    S: TreeReader + TreeWriter,
192    M: MmrMetadataStore + SupportedMmrStore + PrepareMmrStore,
193    for<'a> &'a M: ckb_merkle_mountain_range::MMRStoreReadOps<MmrHash>
194        + ckb_merkle_mountain_range::MMRStoreWriteOps<MmrHash>,
195{
196    pub fn new_with_mmrs(
197        state_store: S,
198        risk_store: S,
199        command_mmr_store: M,
200        obligation_mmr_store: M,
201        intent_mmr_store: M,
202        prune_window: u64,
203    ) -> anyhow::Result<Self> {
204        Ok(Self {
205            state_store,
206            risk_store,
207            command_mmr: HypercallMmr::from_store(command_mmr_store)?,
208            obligation_mmr: HypercallMmr::from_store(obligation_mmr_store)?,
209            intent_mmr: HypercallMmr::from_store(intent_mmr_store)?,
210            version: 0,
211            prune_window,
212        })
213    }
214
215    /// Resume from a known version (e.g., after loading from disk).
216    pub fn with_version(mut self, version: Version) -> Self {
217        self.version = version;
218        self
219    }
220
221    /// Current version (number of committed batches).
222    pub fn version(&self) -> Version {
223        self.version
224    }
225
226    /// Configured number of recent versions to keep available for proofs.
227    pub fn prune_window(&self) -> u64 {
228        self.prune_window
229    }
230
231    /// Prepare a batch commitment without mutating the backing stores.
232    pub fn prepare_commit(&self, delta: &StateDelta) -> anyhow::Result<PreparedBatchCommitment> {
233        let version = self.version;
234
235        let (state_root, state_count, state_update) = self.prepare_state_delta(delta, version)?;
236        let (risk_root, risk_count, risk_update) = self.prepare_risk_delta(&delta.risk, version)?;
237        let command_mmr = self.command_mmr.prepare_append_many(&delta.commands)?;
238        let obligation_mmr = self
239            .obligation_mmr
240            .prepare_append_many(&delta.obligations)?;
241        let intent_mmr = self.intent_mmr.prepare_append_many(&delta.intents)?;
242
243        let command_mmr_root = command_mmr.root();
244        let obligation_mmr_root = obligation_mmr.root();
245        let intent_mmr_root = intent_mmr.root();
246
247        let batch_root = compute_batch_root(
248            &state_root,
249            &risk_root,
250            &command_mmr_root,
251            &obligation_mmr_root,
252            &intent_mmr_root,
253        );
254
255        let commitment = BatchCommitment {
256            version,
257            state_root,
258            risk_root,
259            command_mmr_root,
260            obligation_mmr_root,
261            intent_mmr_root,
262            batch_root,
263            state_leaves_updated: state_count,
264            risk_leaves_updated: risk_count,
265        };
266
267        Ok(PreparedBatchCommitment {
268            commitment,
269            state_update,
270            risk_update,
271            command_mmr,
272            obligation_mmr,
273            intent_mmr,
274        })
275    }
276
277    /// Apply a prepared batch commitment after the caller's durability boundary.
278    pub fn apply_prepared_commit(
279        &mut self,
280        prepared: PreparedBatchCommitment,
281    ) -> anyhow::Result<BatchCommitment> {
282        if prepared.commitment.version != self.version {
283            anyhow::bail!(
284                "prepared commitment version {} does not match pipeline version {}",
285                prepared.commitment.version,
286                self.version
287            );
288        }
289
290        if let Some(update) = prepared.state_update {
291            self.state_store.write_node_batch(&update.node_batch)?;
292        }
293        if let Some(update) = prepared.risk_update {
294            self.risk_store.write_node_batch(&update.node_batch)?;
295        }
296        self.command_mmr
297            .apply_prepared_append(prepared.command_mmr)?;
298        self.obligation_mmr
299            .apply_prepared_append(prepared.obligation_mmr)?;
300        self.intent_mmr.apply_prepared_append(prepared.intent_mmr)?;
301
302        self.version += 1;
303        Ok(prepared.commitment)
304    }
305
306    /// Commit a batch of state changes and return the new roots.
307    ///
308    /// This is the main entry point for callers that do not need to interpose
309    /// another durability boundary between root computation and store mutation.
310    pub fn commit(&mut self, delta: &StateDelta) -> anyhow::Result<BatchCommitment> {
311        let prepared = self.prepare_commit(delta)?;
312        self.apply_prepared_commit(prepared)
313    }
314
315    /// Append a command hash to the command MMR.
316    pub fn append_command(&mut self, command_hash: [u8; 32]) -> anyhow::Result<()> {
317        self.command_mmr.append(command_hash)
318    }
319
320    /// Append an obligation to the obligation MMR.
321    pub fn append_obligation(&mut self, obligation_hash: [u8; 32]) -> anyhow::Result<()> {
322        self.obligation_mmr.append(obligation_hash)
323    }
324
325    /// Append a user intent to the intent MMR.
326    pub fn append_intent(&mut self, intent_hash: [u8; 32]) -> anyhow::Result<()> {
327        self.intent_mmr.append(intent_hash)
328    }
329
330    fn latest_root(&self, store: &S, version: Version) -> anyhow::Result<RootHash> {
331        if version == 0 {
332            return Ok(RootHash([0u8; 32]));
333        }
334        let tree = new_jmt(store);
335        match tree.get_root_hash_option(version - 1)? {
336            Some(root) => Ok(root),
337            None => Ok(RootHash([0u8; 32])),
338        }
339    }
340
341    fn prepare_state_delta(
342        &self,
343        delta: &StateDelta,
344        version: Version,
345    ) -> anyhow::Result<(RootHash, usize, Option<TreeUpdateBatch>)> {
346        let mut entries: Vec<(KeyHash, Option<Vec<u8>>)> = Vec::new();
347
348        for u in &delta.accounts {
349            entries.push((StateKey::account(&u.address), Some(leaf_to_bytes(&u.leaf))));
350        }
351
352        for u in &delta.option_positions {
353            let key = StateKey::option_position(&u.address, &u.symbol);
354            entries.push((key, u.leaf.as_ref().map(leaf_to_bytes)));
355        }
356
357        for u in &delta.perp_positions {
358            let key = StateKey::perp_position(&u.address, &u.coin);
359            entries.push((key, u.leaf.as_ref().map(leaf_to_bytes)));
360        }
361
362        for u in &delta.orders {
363            let key = StateKey::order(&u.address, u.order_id);
364            entries.push((key, u.leaf.as_ref().map(leaf_to_bytes)));
365        }
366
367        for u in &delta.instruments {
368            entries.push((
369                StateKey::instrument(&u.symbol),
370                Some(leaf_to_bytes(&u.leaf)),
371            ));
372        }
373
374        for u in &delta.oracles {
375            entries.push((
376                StateKey::oracle(&u.underlying),
377                Some(leaf_to_bytes(&u.leaf)),
378            ));
379        }
380
381        for u in &delta.mmp_configs {
382            entries.push((
383                StateKey::mmp_config(&u.address, &u.currency),
384                Some(leaf_to_bytes(&u.leaf)),
385            ));
386        }
387
388        if let Some(ref g) = delta.global {
389            entries.push((StateKey::global(), Some(leaf_to_bytes(g))));
390        }
391
392        let count = entries.len();
393        if count == 0 {
394            return Ok((self.latest_root(&self.state_store, version)?, 0, None));
395        }
396
397        let tree = new_jmt(&self.state_store);
398        let (root, batch) = tree.put_value_set(entries, version)?;
399
400        Ok((root, count, Some(batch)))
401    }
402
403    fn prepare_risk_delta(
404        &self,
405        risk_updates: &[RiskUpdate],
406        version: Version,
407    ) -> anyhow::Result<(RootHash, usize, Option<TreeUpdateBatch>)> {
408        if risk_updates.is_empty() {
409            return Ok((self.latest_root(&self.risk_store, version)?, 0, None));
410        }
411
412        let entries: Vec<(KeyHash, Option<Vec<u8>>)> = risk_updates
413            .iter()
414            .map(|u| (StateKey::risk(&u.address), Some(leaf_to_bytes(&u.leaf))))
415            .collect();
416
417        let count = entries.len();
418        let tree = new_jmt(&self.risk_store);
419        let (root, batch) = tree.put_value_set(entries, version)?;
420
421        Ok((root, count, Some(batch)))
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use crate::store::FastMemoryStore;
429
430    fn test_pipeline() -> CommitmentPipeline<FastMemoryStore> {
431        CommitmentPipeline::new(FastMemoryStore::new(), FastMemoryStore::new(), 100)
432    }
433
434    #[test]
435    fn empty_commit_produces_deterministic_root() {
436        let mut pipeline = test_pipeline();
437        let c1 = pipeline.commit(&StateDelta::default()).unwrap();
438        assert_eq!(c1.version, 0);
439        assert_eq!(c1.state_leaves_updated, 0);
440        assert_eq!(c1.risk_leaves_updated, 0);
441    }
442
443    #[test]
444    fn single_account_commit() {
445        let mut pipeline = test_pipeline();
446        let delta = StateDelta {
447            accounts: vec![AccountUpdate {
448                address: [0x42; 20],
449                leaf: AccountLeaf {
450                    cash: 1_000_000 * PRICE_SCALE,
451                    nonce: 0,
452                    margin_mode: 0,
453                    liquidation_state: 0,
454                },
455            }],
456            ..Default::default()
457        };
458
459        let c = pipeline.commit(&delta).unwrap();
460        assert_eq!(c.version, 0);
461        assert_eq!(c.state_leaves_updated, 1);
462        assert_ne!(c.state_root.0, [0u8; 32]);
463        assert_ne!(c.batch_root, [0u8; 32]);
464    }
465
466    #[test]
467    fn prepare_commit_does_not_mutate_until_apply() {
468        let mut pipeline = test_pipeline();
469        let delta = StateDelta {
470            commands: vec![[0xAA; 32]],
471            accounts: vec![AccountUpdate {
472                address: [0x42; 20],
473                leaf: AccountLeaf {
474                    cash: 1_000_000 * PRICE_SCALE,
475                    nonce: 0,
476                    margin_mode: 0,
477                    liquidation_state: 0,
478                },
479            }],
480            ..Default::default()
481        };
482
483        let prepared = pipeline.prepare_commit(&delta).unwrap();
484        assert_eq!(prepared.commitment.version, 0);
485        assert_eq!(pipeline.version(), 0);
486        assert_eq!(pipeline.command_mmr.size(), 0);
487        assert!(
488            new_jmt(&pipeline.state_store)
489                .get_root_hash_option(0)
490                .unwrap()
491                .is_none(),
492            "prepare must not write the state root before apply"
493        );
494
495        let applied = pipeline.apply_prepared_commit(prepared).unwrap();
496        assert_eq!(applied.version, 0);
497        assert_eq!(pipeline.version(), 1);
498        assert_eq!(pipeline.command_mmr.size(), 1);
499        assert_eq!(
500            new_jmt(&pipeline.state_store)
501                .get_root_hash_option(0)
502                .unwrap(),
503            Some(applied.state_root)
504        );
505    }
506
507    #[test]
508    fn version_increments() {
509        let mut pipeline = test_pipeline();
510        let delta = StateDelta {
511            accounts: vec![AccountUpdate {
512                address: [0x01; 20],
513                leaf: AccountLeaf {
514                    cash: 100,
515                    nonce: 0,
516                    margin_mode: 0,
517                    liquidation_state: 0,
518                },
519            }],
520            ..Default::default()
521        };
522
523        let c0 = pipeline.commit(&delta).unwrap();
524        assert_eq!(c0.version, 0);
525        assert_eq!(pipeline.version(), 1);
526
527        let c1 = pipeline.commit(&delta).unwrap();
528        assert_eq!(c1.version, 1);
529        assert_eq!(pipeline.version(), 2);
530    }
531
532    #[test]
533    fn state_root_changes_on_balance_update() {
534        let mut pipeline = test_pipeline();
535        let addr = [0x42; 20];
536
537        let d1 = StateDelta {
538            accounts: vec![AccountUpdate {
539                address: addr,
540                leaf: AccountLeaf {
541                    cash: 1000,
542                    nonce: 0,
543                    margin_mode: 0,
544                    liquidation_state: 0,
545                },
546            }],
547            ..Default::default()
548        };
549        let c1 = pipeline.commit(&d1).unwrap();
550
551        let d2 = StateDelta {
552            accounts: vec![AccountUpdate {
553                address: addr,
554                leaf: AccountLeaf {
555                    cash: 2000,
556                    nonce: 1,
557                    margin_mode: 0,
558                    liquidation_state: 0,
559                },
560            }],
561            ..Default::default()
562        };
563        let c2 = pipeline.commit(&d2).unwrap();
564
565        assert_ne!(c1.state_root, c2.state_root);
566        assert_ne!(c1.batch_root, c2.batch_root);
567    }
568
569    #[test]
570    fn risk_root_changes_independently() {
571        let mut pipeline = test_pipeline();
572        let addr = [0x01; 20];
573
574        let d1 = StateDelta {
575            accounts: vec![AccountUpdate {
576                address: addr,
577                leaf: AccountLeaf {
578                    cash: 1000,
579                    nonce: 0,
580                    margin_mode: 0,
581                    liquidation_state: 0,
582                },
583            }],
584            risk: vec![RiskUpdate {
585                address: addr,
586                leaf: RiskLeaf {
587                    equity: 1000,
588                    initial_margin: 500,
589                    maintenance_margin: 250,
590                    scanning_risk: 400,
591                    health_nonce: 0,
592                },
593            }],
594            ..Default::default()
595        };
596        let c1 = pipeline.commit(&d1).unwrap();
597
598        let d2 = StateDelta {
599            risk: vec![RiskUpdate {
600                address: addr,
601                leaf: RiskLeaf {
602                    equity: 800,
603                    initial_margin: 500,
604                    maintenance_margin: 250,
605                    scanning_risk: 400,
606                    health_nonce: 1,
607                },
608            }],
609            ..Default::default()
610        };
611        let c2 = pipeline.commit(&d2).unwrap();
612
613        assert_eq!(c1.state_root, c2.state_root);
614        assert_ne!(c1.risk_root, c2.risk_root);
615        assert_ne!(c1.batch_root, c2.batch_root);
616    }
617
618    #[test]
619    fn command_mmr_affects_batch_root() {
620        let mut pipeline = test_pipeline();
621
622        let delta = StateDelta {
623            accounts: vec![AccountUpdate {
624                address: [0x01; 20],
625                leaf: AccountLeaf {
626                    cash: 100,
627                    nonce: 0,
628                    margin_mode: 0,
629                    liquidation_state: 0,
630                },
631            }],
632            ..Default::default()
633        };
634
635        let c1 = pipeline.commit(&delta).unwrap();
636        pipeline.append_command([0xAA; 32]).unwrap();
637        let c2 = pipeline.commit(&delta).unwrap();
638
639        assert_ne!(c1.batch_root, c2.batch_root);
640    }
641
642    #[test]
643    fn full_trading_scenario() {
644        let mut pipeline = test_pipeline();
645
646        let alice = [0x01; 20];
647        let bob = [0x02; 20];
648        let symbol = "BTC-20261231-100000-C";
649
650        // Batch 0: account setup + oracle
651        let d0 = StateDelta {
652            accounts: vec![
653                AccountUpdate {
654                    address: alice,
655                    leaf: AccountLeaf {
656                        cash: 100_000 * PRICE_SCALE,
657                        nonce: 0,
658                        margin_mode: 1,
659                        liquidation_state: 0,
660                    },
661                },
662                AccountUpdate {
663                    address: bob,
664                    leaf: AccountLeaf {
665                        cash: 500_000 * PRICE_SCALE,
666                        nonce: 0,
667                        margin_mode: 1,
668                        liquidation_state: 0,
669                    },
670                },
671            ],
672            instruments: vec![InstrumentUpdate {
673                symbol: symbol.to_string(),
674                leaf: InstrumentLeaf {
675                    expired: false,
676                    trading_mode: 2,
677                    expiry: 1798761600,
678                    strike: 100_000 * PRICE_SCALE,
679                    option_type: 0,
680                },
681            }],
682            oracles: vec![OracleUpdate {
683                underlying: "BTC".to_string(),
684                leaf: OracleLeaf {
685                    spot_price: 95_000 * PRICE_SCALE,
686                    iv_surface_hash: [0xAA; 32],
687                },
688            }],
689            global: Some(GlobalLeaf {
690                next_order_id: 1,
691                next_trade_id: 1,
692                command_chain_root: [0; 32],
693                command_chain_seq: 0,
694            }),
695            risk: vec![
696                RiskUpdate {
697                    address: alice,
698                    leaf: RiskLeaf {
699                        equity: 100_000 * PRICE_SCALE,
700                        initial_margin: 0,
701                        maintenance_margin: 0,
702                        scanning_risk: 0,
703                        health_nonce: 0,
704                    },
705                },
706                RiskUpdate {
707                    address: bob,
708                    leaf: RiskLeaf {
709                        equity: 500_000 * PRICE_SCALE,
710                        initial_margin: 0,
711                        maintenance_margin: 0,
712                        scanning_risk: 0,
713                        health_nonce: 0,
714                    },
715                },
716            ],
717            ..Default::default()
718        };
719        pipeline.append_command([0x01; 32]).unwrap();
720        let c0 = pipeline.commit(&d0).unwrap();
721        assert_eq!(c0.state_leaves_updated, 5);
722        assert_eq!(c0.risk_leaves_updated, 2);
723
724        // Batch 1: trade fills, positions created
725        let d1 = StateDelta {
726            accounts: vec![
727                AccountUpdate {
728                    address: alice,
729                    leaf: AccountLeaf {
730                        cash: 95_000 * PRICE_SCALE,
731                        nonce: 1,
732                        margin_mode: 1,
733                        liquidation_state: 0,
734                    },
735                },
736                AccountUpdate {
737                    address: bob,
738                    leaf: AccountLeaf {
739                        cash: 505_000 * PRICE_SCALE,
740                        nonce: 1,
741                        margin_mode: 1,
742                        liquidation_state: 0,
743                    },
744                },
745            ],
746            option_positions: vec![
747                OptionPositionUpdate {
748                    address: alice,
749                    symbol: symbol.to_string(),
750                    leaf: Some(OptionPositionLeaf {
751                        quantity: 10 * PRICE_SCALE,
752                        entry_price: 5_000 * PRICE_SCALE,
753                    }),
754                },
755                OptionPositionUpdate {
756                    address: bob,
757                    symbol: symbol.to_string(),
758                    leaf: Some(OptionPositionLeaf {
759                        quantity: -10 * PRICE_SCALE,
760                        entry_price: 5_000 * PRICE_SCALE,
761                    }),
762                },
763            ],
764            global: Some(GlobalLeaf {
765                next_order_id: 3,
766                next_trade_id: 2,
767                command_chain_root: [0x11; 32],
768                command_chain_seq: 2,
769            }),
770            risk: vec![
771                RiskUpdate {
772                    address: alice,
773                    leaf: RiskLeaf {
774                        equity: 95_000 * PRICE_SCALE,
775                        initial_margin: 20_000 * PRICE_SCALE,
776                        maintenance_margin: 10_000 * PRICE_SCALE,
777                        scanning_risk: 15_000 * PRICE_SCALE,
778                        health_nonce: 1,
779                    },
780                },
781                RiskUpdate {
782                    address: bob,
783                    leaf: RiskLeaf {
784                        equity: 505_000 * PRICE_SCALE,
785                        initial_margin: 50_000 * PRICE_SCALE,
786                        maintenance_margin: 25_000 * PRICE_SCALE,
787                        scanning_risk: 40_000 * PRICE_SCALE,
788                        health_nonce: 1,
789                    },
790                },
791            ],
792            ..Default::default()
793        };
794        pipeline.append_command([0x02; 32]).unwrap();
795        pipeline.append_command([0x03; 32]).unwrap();
796        let c1 = pipeline.commit(&d1).unwrap();
797        assert_ne!(c0.batch_root, c1.batch_root);
798        assert_eq!(c1.state_leaves_updated, 5);
799
800        // Batch 2: price move, risk update only
801        let d2 = StateDelta {
802            oracles: vec![OracleUpdate {
803                underlying: "BTC".to_string(),
804                leaf: OracleLeaf {
805                    spot_price: 100_000 * PRICE_SCALE,
806                    iv_surface_hash: [0xBB; 32],
807                },
808            }],
809            risk: vec![
810                RiskUpdate {
811                    address: alice,
812                    leaf: RiskLeaf {
813                        equity: 110_000 * PRICE_SCALE,
814                        initial_margin: 22_000 * PRICE_SCALE,
815                        maintenance_margin: 11_000 * PRICE_SCALE,
816                        scanning_risk: 16_000 * PRICE_SCALE,
817                        health_nonce: 2,
818                    },
819                },
820                RiskUpdate {
821                    address: bob,
822                    leaf: RiskLeaf {
823                        equity: 490_000 * PRICE_SCALE,
824                        initial_margin: 55_000 * PRICE_SCALE,
825                        maintenance_margin: 27_500 * PRICE_SCALE,
826                        scanning_risk: 44_000 * PRICE_SCALE,
827                        health_nonce: 2,
828                    },
829                },
830            ],
831            ..Default::default()
832        };
833        let c2 = pipeline.commit(&d2).unwrap();
834        assert_ne!(c1.batch_root, c2.batch_root);
835        assert_eq!(pipeline.version(), 3);
836    }
837
838    #[test]
839    fn position_delete_changes_root() {
840        let mut pipeline = test_pipeline();
841        let addr = [0x01; 20];
842
843        let d1 = StateDelta {
844            option_positions: vec![OptionPositionUpdate {
845                address: addr,
846                symbol: "BTC-C".to_string(),
847                leaf: Some(OptionPositionLeaf {
848                    quantity: 100,
849                    entry_price: 5000,
850                }),
851            }],
852            ..Default::default()
853        };
854        let c1 = pipeline.commit(&d1).unwrap();
855
856        let d2 = StateDelta {
857            option_positions: vec![OptionPositionUpdate {
858                address: addr,
859                symbol: "BTC-C".to_string(),
860                leaf: None,
861            }],
862            ..Default::default()
863        };
864        let c2 = pipeline.commit(&d2).unwrap();
865
866        assert_ne!(c1.state_root, c2.state_root);
867    }
868}