Skip to main content

miden_client/sync/
state_sync.rs

1use alloc::boxed::Box;
2use alloc::collections::{BTreeMap, BTreeSet};
3use alloc::sync::Arc;
4use alloc::vec::Vec;
5
6use async_trait::async_trait;
7use miden_protocol::Word;
8use miden_protocol::account::{Account, AccountHeader, AccountId};
9use miden_protocol::block::{BlockHeader, BlockNumber};
10use miden_protocol::crypto::merkle::mmr::{MmrDelta, PartialMmr};
11use miden_protocol::note::{Note, NoteId, NoteTag, NoteType, Nullifier};
12use miden_protocol::transaction::InputNoteCommitment;
13use tracing::info;
14
15use super::state_sync_update::TransactionUpdateTracker;
16use super::{AccountUpdates, StateSyncUpdate};
17use crate::ClientError;
18use crate::note::NoteUpdateTracker;
19use crate::rpc::NodeRpcClient;
20use crate::rpc::domain::note::{CommittedNote, NoteSyncBlock};
21use crate::rpc::domain::transaction::{
22    TransactionInclusion,
23    TransactionRecord as RpcTransactionRecord,
24};
25use crate::store::{InputNoteRecord, OutputNoteRecord, StoreError};
26use crate::transaction::TransactionRecord;
27
28// STATE UPDATE DATA
29// ================================================================================================
30
31/// Raw data fetched from the node needed to sync the client to the chain tip.
32///
33/// Aggregates the responses of `sync_chain_mmr`, `sync_notes`, `get_notes_by_id`, and
34/// `sync_transactions`. This may contain more data than a particular client needs to store — it is
35/// filtered and transformed into a [`StateSyncUpdate`] before being applied.
36struct RawStateSyncData {
37    /// MMR delta covering the full range from `current_block` to `chain_tip`.
38    mmr_delta: MmrDelta,
39    /// Chain tip block header.
40    chain_tip_header: BlockHeader,
41    /// Blocks with matching notes that the client is interested in.
42    note_blocks: Vec<NoteSyncBlock>,
43    /// Full note bodies for public notes, keyed by note ID.
44    public_notes: BTreeMap<NoteId, Note>,
45    /// Account commitment updates for the synced range.
46    account_commitment_updates: Vec<(AccountId, Word)>,
47    /// Transaction inclusions for the synced range.
48    transactions: Vec<TransactionInclusion>,
49    /// Nullifiers for the synced range.
50    nullifiers: Vec<Nullifier>,
51}
52
53// SYNC REQUEST
54// ================================================================================================
55
56/// Bundles the client state needed to perform a sync operation.
57///
58/// The sync process uses these inputs to:
59/// - Request account commitment updates from the node for the provided accounts.
60/// - Filter which note inclusions the node returns based on the provided note tags.
61/// - Follow the lifecycle of every tracked note (input and output), transitioning them from pending
62///   to committed to consumed as the network state advances.
63/// - Track uncommitted transactions so they can be marked as committed when the node confirms them,
64///   or discarded when they become stale.
65///
66/// Use [`Client::build_sync_input()`](`crate::Client::build_sync_input()`) to build a default input
67/// from the client state, or construct this struct manually for custom sync scenarios.
68pub struct StateSyncInput {
69    /// Account headers to request commitment updates for.
70    pub accounts: Vec<AccountHeader>,
71    /// Note tags that the node uses to filter which note inclusions to return.
72    pub note_tags: BTreeSet<NoteTag>,
73    /// Input notes whose lifecycle should be followed during sync.
74    pub input_notes: Vec<InputNoteRecord>,
75    /// Output notes whose lifecycle should be followed during sync.
76    pub output_notes: Vec<OutputNoteRecord>,
77    /// Transactions to track for commitment or discard during sync.
78    pub uncommitted_transactions: Vec<TransactionRecord>,
79}
80
81// SYNC CALLBACKS
82// ================================================================================================
83
84/// The action to be taken when a note update is received as part of the sync response.
85#[allow(clippy::large_enum_variant)]
86pub enum NoteUpdateAction {
87    /// The note commit update is relevant and the specified note should be marked as committed in
88    /// the store, storing its inclusion proof.
89    Commit(CommittedNote),
90    /// The public note is relevant and should be inserted into the store.
91    Insert(InputNoteRecord),
92    /// The note update is not relevant and should be discarded.
93    Discard,
94}
95
96#[async_trait(?Send)]
97pub trait OnNoteReceived {
98    /// Callback that gets executed when a new note is received as part of the sync response.
99    ///
100    /// It receives:
101    ///
102    /// - The committed note received from the network.
103    /// - An optional note record that corresponds to the state of the note in the network (only if
104    ///   the note is public).
105    ///
106    /// It returns an enum indicating the action to be taken for the received note update. Whether
107    /// the note updated should be committed, new public note inserted, or ignored.
108    async fn on_note_received(
109        &self,
110        committed_note: CommittedNote,
111        public_note: Option<InputNoteRecord>,
112    ) -> Result<NoteUpdateAction, ClientError>;
113}
114// STATE SYNC
115// ================================================================================================
116
117/// The state sync component encompasses the client's sync logic. It is then used to request
118/// updates from the node and apply them to the relevant elements. The updates are then returned and
119/// can be applied to the store to persist the changes.
120#[derive(Clone)]
121pub struct StateSync {
122    /// The RPC client used to communicate with the node.
123    rpc_api: Arc<dyn NodeRpcClient>,
124    /// Responsible for checking the relevance of notes and executing the
125    /// [`OnNoteReceived`] callback when a new note inclusion is received.
126    note_screener: Arc<dyn OnNoteReceived>,
127    /// Number of blocks after which pending transactions are considered stale and discarded.
128    /// If `None`, there is no limit and transactions will be kept indefinitely.
129    tx_discard_delta: Option<u32>,
130    /// Whether to check for nullifiers during state sync. When enabled, the component will query
131    /// the nullifiers for unspent notes at each sync step. This allows to detect when tracked
132    /// notes have been consumed externally and discard local transactions that depend on them.
133    sync_nullifiers: bool,
134}
135
136impl StateSync {
137    /// Creates a new instance of the state sync component.
138    ///
139    /// The nullifiers sync is enabled by default. To disable it, see
140    /// [`Self::disable_nullifier_sync`].
141    ///
142    /// # Arguments
143    ///
144    /// * `rpc_api` - The RPC client used to communicate with the node.
145    /// * `note_screener` - The note screener used to check the relevance of notes.
146    /// * `tx_discard_delta` - Number of blocks after which pending transactions are discarded.
147    pub fn new(
148        rpc_api: Arc<dyn NodeRpcClient>,
149        note_screener: Arc<dyn OnNoteReceived>,
150        tx_discard_delta: Option<u32>,
151    ) -> Self {
152        Self {
153            rpc_api,
154            note_screener,
155            tx_discard_delta,
156            sync_nullifiers: true,
157        }
158    }
159
160    /// Disables the nullifier sync.
161    ///
162    /// When disabled, the component will not query the node for new nullifiers after each sync
163    /// step. This is useful for clients that don't need to track note consumption, such as
164    /// faucets.
165    pub fn disable_nullifier_sync(&mut self) {
166        self.sync_nullifiers = false;
167    }
168
169    /// Enables the nullifier sync.
170    pub fn enable_nullifier_sync(&mut self) {
171        self.sync_nullifiers = true;
172    }
173
174    /// Syncs the state of the client with the chain tip of the node, returning the updates that
175    /// should be applied to the store.
176    ///
177    /// Use [`Client::build_sync_input()`](`crate::Client::build_sync_input()`) to build the default
178    /// input, or assemble it manually for custom sync. The `current_partial_mmr` is taken by
179    /// mutable reference so callers can keep it in memory across syncs.
180    ///
181    /// During the sync process, the following steps are performed:
182    /// 1. A request is sent to the node to get the state updates. This request includes tracked
183    ///    account IDs and the tags of notes that might have changed or that might be of interest to
184    ///    the client.
185    /// 2. A response is received with the current state of the network. The response includes
186    ///    information about new and committed notes, updated accounts, and committed transactions.
187    /// 3. Tracked public accounts are updated and private accounts are validated against the node
188    ///    state.
189    /// 4. Tracked notes are updated with their new states. Notes might be committed or nullified
190    ///    during the sync processing.
191    /// 5. New notes are checked, and only relevant ones are stored. Relevance is determined by the
192    ///    [`OnNoteReceived`] callback.
193    /// 6. Transactions are updated with their new states. Transactions might be committed or
194    ///    discarded.
195    /// 7. The MMR is updated with the new peaks and authentication nodes.
196    pub async fn sync_state(
197        &self,
198        current_partial_mmr: &mut PartialMmr,
199        input: StateSyncInput,
200    ) -> Result<StateSyncUpdate, ClientError> {
201        let StateSyncInput {
202            accounts,
203            note_tags,
204            input_notes,
205            output_notes,
206            uncommitted_transactions,
207        } = input;
208        let block_num = u32::try_from(current_partial_mmr.forest().num_leaves().saturating_sub(1))
209            .map_err(|_| ClientError::InvalidPartialMmrForest)?
210            .into();
211
212        let mut state_sync_update = StateSyncUpdate {
213            block_num,
214            note_updates: NoteUpdateTracker::new(input_notes, output_notes),
215            transaction_updates: TransactionUpdateTracker::new(uncommitted_transactions),
216            ..Default::default()
217        };
218
219        let note_tags = Arc::new(note_tags);
220        let account_ids: Vec<AccountId> = accounts.iter().map(AccountHeader::id).collect();
221        let Some(mut sync_data) = self
222            .fetch_sync_data(state_sync_update.block_num, &account_ids, &note_tags)
223            .await?
224        else {
225            // No progress — already at the tip.
226            return Ok(state_sync_update);
227        };
228
229        state_sync_update.block_num = sync_data.chain_tip_header.block_num();
230
231        // Build input note records for public notes from the fetched note bodies and the
232        // inclusion proofs already present in the note blocks.
233        let mut public_note_records: BTreeMap<NoteId, InputNoteRecord> = BTreeMap::new();
234        for (note_id, note) in core::mem::take(&mut sync_data.public_notes) {
235            let inclusion_proof = sync_data
236                .note_blocks
237                .iter()
238                .find_map(|b| b.notes.get(&note_id))
239                .map(|committed| committed.inclusion_proof().clone());
240
241            if let Some(inclusion_proof) = inclusion_proof {
242                let state = crate::store::input_note_states::UnverifiedNoteState {
243                    metadata: note.metadata().clone(),
244                    inclusion_proof,
245                }
246                .into();
247                let record = InputNoteRecord::new(note.into(), None, state);
248                public_note_records.insert(record.id(), record);
249            }
250        }
251
252        self.account_state_sync(
253            &mut state_sync_update.account_updates,
254            &accounts,
255            &sync_data.account_commitment_updates,
256        )
257        .await?;
258
259        // Apply local changes: update the MMR, screen notes, and apply state transitions.
260        self.apply_sync_result(
261            sync_data,
262            &public_note_records,
263            &mut state_sync_update,
264            current_partial_mmr,
265        )
266        .await?;
267
268        if self.sync_nullifiers {
269            self.nullifiers_state_sync(&mut state_sync_update, block_num).await?;
270        }
271
272        Ok(state_sync_update)
273    }
274
275    /// Fetches the sync data from the node by calling the following endpoints:
276    /// 1. `sync_chain_mmr` — discovers the chain tip, gets the MMR delta and chain tip header.
277    /// 2. `sync_notes` — loops until the full range to the chain tip is covered (handles paginated
278    ///    responses).
279    /// 3. `get_notes_by_id` — fetches full metadata for notes with attachments.
280    /// 4. `sync_transactions` — gets transaction data for the full range.
281    ///
282    /// Returns `None` when the client is already at the chain tip (no progress).
283    async fn fetch_sync_data(
284        &self,
285        current_block_num: BlockNumber,
286        account_ids: &[AccountId],
287        note_tags: &Arc<BTreeSet<NoteTag>>,
288    ) -> Result<Option<RawStateSyncData>, ClientError> {
289        // Step 1: Fetch the MMR delta and chain tip header.
290        let chain_mmr_info = self.rpc_api.sync_chain_mmr(current_block_num, None).await?;
291        let chain_tip = chain_mmr_info.block_to;
292
293        // No progress — already at the tip.
294        if chain_tip == current_block_num {
295            info!(block_num = %current_block_num, "Already at chain tip, nothing to sync.");
296            return Ok(None);
297        }
298
299        info!(
300            block_from = %current_block_num,
301            block_to = %chain_tip,
302            "Syncing state.",
303        );
304
305        // Step 2: Paginate sync_notes using the same chain tip so MMR paths are opened at
306        // a consistent forest.
307        let sync_notes_result = self
308            .rpc_api
309            .sync_notes_with_details(current_block_num, Some(chain_tip), note_tags.as_ref())
310            .await?;
311
312        let note_count: usize = sync_notes_result.blocks.iter().map(|b| b.notes.len()).sum();
313        info!(
314            blocks_with_notes = sync_notes_result.blocks.len(),
315            notes = note_count,
316            public_notes = sync_notes_result.public_notes.len(),
317            "Fetched note sync data.",
318        );
319
320        // Step 3: Gather transactions for tracked accounts over the full range.
321        let (account_commitment_updates, transactions, nullifiers) =
322            self.fetch_transaction_data(current_block_num, chain_tip, account_ids).await?;
323
324        Ok(Some(RawStateSyncData {
325            mmr_delta: chain_mmr_info.mmr_delta,
326            chain_tip_header: chain_mmr_info.block_header,
327            note_blocks: sync_notes_result.blocks,
328            public_notes: sync_notes_result.public_notes,
329            account_commitment_updates,
330            transactions,
331            nullifiers,
332        }))
333    }
334
335    /// Fetches transaction data for the given range and account IDs.
336    async fn fetch_transaction_data(
337        &self,
338        block_from: BlockNumber,
339        block_to: BlockNumber,
340        account_ids: &[AccountId],
341    ) -> Result<(Vec<(AccountId, Word)>, Vec<TransactionInclusion>, Vec<Nullifier>), ClientError>
342    {
343        if account_ids.is_empty() {
344            return Ok((vec![], vec![], vec![]));
345        }
346
347        let tx_info = self
348            .rpc_api
349            .sync_transactions(block_from, Some(block_to), account_ids.to_vec())
350            .await?;
351
352        let transaction_records = tx_info.transaction_records;
353
354        let account_updates = derive_account_commitment_updates(&transaction_records);
355        let nullifiers = compute_ordered_nullifiers(&transaction_records);
356
357        let tx_inclusions = transaction_records
358            .into_iter()
359            .map(|r| {
360                let nullifiers = r
361                    .transaction_header
362                    .input_notes()
363                    .iter()
364                    .map(InputNoteCommitment::nullifier)
365                    .collect();
366                TransactionInclusion {
367                    transaction_id: r.transaction_header.id(),
368                    block_num: r.block_num,
369                    account_id: r.transaction_header.account_id(),
370                    initial_state_commitment: r.transaction_header.initial_state_commitment(),
371                    nullifiers,
372                    output_notes: r.output_notes,
373                }
374            })
375            .collect();
376
377        Ok((account_updates, tx_inclusions, nullifiers))
378    }
379
380    // HELPERS
381    // --------------------------------------------------------------------------------------------
382
383    /// Applies sync results to the local state update.
384    ///
385    /// Applies fetched sync data to the local state:
386    /// 1. Advances the partial MMR (delta + chain tip leaf).
387    /// 2. Screens note blocks and tracks relevant ones in the MMR.
388    /// 3. Applies transaction and nullifier updates.
389    async fn apply_sync_result(
390        &self,
391        sync_data: RawStateSyncData,
392        public_note_records: &BTreeMap<NoteId, InputNoteRecord>,
393        state_sync_update: &mut StateSyncUpdate,
394        current_partial_mmr: &mut PartialMmr,
395    ) -> Result<(), ClientError> {
396        let RawStateSyncData {
397            mmr_delta,
398            chain_tip_header,
399            note_blocks,
400            nullifiers,
401            transactions,
402            ..
403        } = sync_data;
404
405        // Advance the partial MMR: apply delta (up to chain_tip - 1), capture peaks for
406        // storage, then add the chain tip leaf (which the delta excludes due to the
407        // one-block lag in block header MMR commitments).
408        let mut new_authentication_nodes =
409            current_partial_mmr.apply(mmr_delta).map_err(StoreError::MmrError)?;
410        let new_peaks = current_partial_mmr.peaks();
411        new_authentication_nodes
412            .append(&mut current_partial_mmr.add(chain_tip_header.commitment(), false));
413
414        state_sync_update.block_updates.insert(
415            chain_tip_header.clone(),
416            false,
417            new_peaks,
418            new_authentication_nodes,
419        );
420
421        // Screen each note block and track relevant ones in the partial MMR using the
422        // authentication path from the sync_notes response.
423        for block in note_blocks {
424            let found_relevant_note = self
425                .note_state_sync(
426                    &mut state_sync_update.note_updates,
427                    block.notes,
428                    &block.block_header,
429                    public_note_records,
430                )
431                .await?;
432
433            if found_relevant_note {
434                let block_pos = block.block_header.block_num().as_usize();
435
436                let track_auth_nodes = if current_partial_mmr.is_tracked(block_pos) {
437                    vec![]
438                } else {
439                    let nodes_before: BTreeMap<_, _> =
440                        current_partial_mmr.nodes().map(|(k, v)| (*k, *v)).collect();
441                    current_partial_mmr
442                        .track(block_pos, block.block_header.commitment(), &block.mmr_path)
443                        .map_err(StoreError::MmrError)?;
444                    current_partial_mmr
445                        .nodes()
446                        .filter(|(k, _)| !nodes_before.contains_key(k))
447                        .map(|(k, v)| (*k, *v))
448                        .collect()
449                };
450
451                state_sync_update.block_updates.insert(
452                    block.block_header,
453                    true,
454                    current_partial_mmr.peaks(),
455                    track_auth_nodes,
456                );
457            }
458        }
459
460        // Apply transaction and nullifier data.
461        state_sync_update.note_updates.extend_nullifiers(nullifiers);
462        self.transaction_state_sync(
463            &mut state_sync_update.transaction_updates,
464            &chain_tip_header,
465            &transactions,
466        );
467
468        // Transition tracked output notes to Committed using inclusion proofs from the
469        // transaction sync response. This covers output notes regardless of whether their
470        // tags were tracked in the note sync.
471        for transaction in &transactions {
472            state_sync_update
473                .note_updates
474                .apply_output_note_inclusion_proofs(&transaction.output_notes)?;
475        }
476
477        Ok(())
478    }
479
480    /// Compares the state of tracked accounts with the updates received from the node. The method
481    /// updates the `state_sync_update` field with the details of the accounts that need to be
482    /// updated.
483    ///
484    /// The account updates might include:
485    /// * Public accounts that have been updated in the node.
486    /// * Network accounts that have been updated in the node and are being tracked by the client.
487    /// * Private accounts that have been marked as mismatched because the current commitment
488    ///   doesn't match the one received from the node. The client will need to handle these cases
489    ///   as they could be a stale account state or a reason to lock the account.
490    async fn account_state_sync(
491        &self,
492        account_updates: &mut AccountUpdates,
493        accounts: &[AccountHeader],
494        account_commitment_updates: &[(AccountId, Word)],
495    ) -> Result<(), ClientError> {
496        let (public_accounts, private_accounts): (Vec<_>, Vec<_>) =
497            accounts.iter().partition(|account_header| !account_header.id().is_private());
498
499        let updated_public_accounts = self
500            .get_updated_public_accounts(account_commitment_updates, &public_accounts)
501            .await?;
502
503        let mismatched_private_accounts = account_commitment_updates
504            .iter()
505            .filter(|(account_id, digest)| {
506                private_accounts.iter().any(|account| {
507                    account.id() == *account_id && &account.to_commitment() != digest
508                })
509            })
510            .copied()
511            .collect::<Vec<_>>();
512
513        account_updates
514            .extend(AccountUpdates::new(updated_public_accounts, mismatched_private_accounts));
515
516        Ok(())
517    }
518
519    /// Queries the node for the latest state of the public accounts that don't match the current
520    /// state of the client.
521    async fn get_updated_public_accounts(
522        &self,
523        account_updates: &[(AccountId, Word)],
524        current_public_accounts: &[&AccountHeader],
525    ) -> Result<Vec<Account>, ClientError> {
526        let mut mismatched_public_accounts = vec![];
527
528        for (id, commitment) in account_updates {
529            // check if this updated account state is tracked by the client
530            if let Some(account) = current_public_accounts
531                .iter()
532                .find(|acc| *id == acc.id() && *commitment != acc.to_commitment())
533            {
534                mismatched_public_accounts.push(*account);
535            }
536        }
537
538        self.rpc_api
539            .get_updated_public_accounts(&mismatched_public_accounts)
540            .await
541            .map_err(ClientError::RpcError)
542    }
543
544    /// Applies the changes received from the sync response to the notes and transactions tracked
545    /// by the client and updates the `note_updates` accordingly.
546    ///
547    /// This method uses the callbacks provided to the [`StateSync`] component to check if the
548    /// updates received are relevant to the client.
549    ///
550    /// The note updates might include:
551    /// * New notes that we received from the node and might be relevant to the client.
552    /// * Tracked expected notes that were committed in the block.
553    /// * Tracked notes that were being processed by a transaction that got committed.
554    /// * Tracked notes that were nullified by an external transaction.
555    ///
556    /// The `public_notes` parameter provides cached public note details for the current sync
557    /// iteration so the node is only queried once per batch.
558    async fn note_state_sync(
559        &self,
560        note_updates: &mut NoteUpdateTracker,
561        note_inclusions: BTreeMap<NoteId, CommittedNote>,
562        block_header: &BlockHeader,
563        public_notes: &BTreeMap<NoteId, InputNoteRecord>,
564    ) -> Result<bool, ClientError> {
565        // `found_relevant_note` tracks whether we want to persist the block header in the end
566        let mut found_relevant_note = false;
567
568        for (_, committed_note) in note_inclusions {
569            let public_note = (committed_note.note_type() != NoteType::Private)
570                .then(|| public_notes.get(committed_note.note_id()))
571                .flatten()
572                .cloned();
573
574            match self.note_screener.on_note_received(committed_note, public_note).await? {
575                NoteUpdateAction::Commit(committed_note) => {
576                    // Only mark the downloaded block header as relevant if we are talking about
577                    // an input note (output notes get marked as committed but we don't need the
578                    // block for anything there)
579                    found_relevant_note |= note_updates
580                        .apply_committed_note_state_transitions(&committed_note, block_header)?;
581                },
582                NoteUpdateAction::Insert(public_note) => {
583                    found_relevant_note = true;
584
585                    note_updates.apply_new_public_note(public_note, block_header)?;
586                },
587                NoteUpdateAction::Discard => {},
588            }
589        }
590
591        Ok(found_relevant_note)
592    }
593
594    /// Collects the nullifier tags for the notes that were updated in the sync response and uses
595    /// the `sync_nullifiers` endpoint to check if there are new nullifiers for these
596    /// notes. It then processes the nullifiers to apply the state transitions on the note updates.
597    ///
598    /// The `state_sync_update` parameter will be updated to track the new discarded transactions.
599    async fn nullifiers_state_sync(
600        &self,
601        state_sync_update: &mut StateSyncUpdate,
602        current_block_num: BlockNumber,
603    ) -> Result<(), ClientError> {
604        // To receive information about added nullifiers, we reduce them to the higher 16 bits
605        // Note that besides filtering by nullifier prefixes, the node also filters by block number
606        // (it only returns nullifiers from current_block_num until
607        // response.block_header.block_num())
608
609        // Check for new nullifiers for input notes that were updated
610        let nullifiers_tags: Vec<u16> = state_sync_update
611            .note_updates
612            .unspent_nullifiers()
613            .map(|nullifier| nullifier.prefix())
614            .collect();
615
616        let mut new_nullifiers = self
617            .rpc_api
618            .sync_nullifiers(&nullifiers_tags, current_block_num, Some(state_sync_update.block_num))
619            .await?;
620
621        // Discard nullifiers that are newer than the current block (this might happen if the block
622        // changes between the sync_state and the check_nullifier calls)
623        new_nullifiers.retain(|update| update.block_num <= state_sync_update.block_num);
624
625        for nullifier_update in new_nullifiers {
626            let external_consumer_account = state_sync_update
627                .transaction_updates
628                .external_nullifier_account(&nullifier_update.nullifier);
629
630            state_sync_update.note_updates.apply_nullifiers_state_transitions(
631                &nullifier_update,
632                state_sync_update.transaction_updates.committed_transactions(),
633                external_consumer_account,
634            )?;
635
636            // Process nullifiers and track the updates of local tracked transactions that were
637            // discarded because the notes that they were processing were nullified by an
638            // another transaction.
639            state_sync_update
640                .transaction_updates
641                .apply_input_note_nullified(nullifier_update.nullifier);
642        }
643
644        Ok(())
645    }
646
647    /// Applies the changes received from the sync response to the transactions tracked by the
648    /// client and updates the `transaction_updates` accordingly.
649    ///
650    /// The transaction updates might include:
651    /// * New transactions that were committed in the block.
652    /// * Transactions that were discarded because they were stale or expired.
653    fn transaction_state_sync(
654        &self,
655        transaction_updates: &mut TransactionUpdateTracker,
656        new_block_header: &BlockHeader,
657        transaction_inclusions: &[TransactionInclusion],
658    ) {
659        for transaction_inclusion in transaction_inclusions {
660            transaction_updates.apply_transaction_inclusion(
661                transaction_inclusion,
662                u64::from(new_block_header.timestamp()),
663            ); //TODO: Change timestamps from u64 to u32
664        }
665
666        transaction_updates
667            .apply_sync_height_update(new_block_header.block_num(), self.tx_discard_delta);
668    }
669}
670
671// HELPERS
672// ================================================================================================
673
674/// Derives account commitment updates from transaction records.
675///
676/// For each unique account, takes the `final_state_commitment` from the transaction with the
677/// highest `block_num`. This replicates the old `SyncState` behavior where the node returned
678/// the latest account commitment per account in the synced range.
679fn derive_account_commitment_updates(
680    transaction_records: &[RpcTransactionRecord],
681) -> Vec<(AccountId, Word)> {
682    let mut latest_by_account: BTreeMap<AccountId, &RpcTransactionRecord> = BTreeMap::new();
683
684    for record in transaction_records {
685        let account_id = record.transaction_header.account_id();
686        latest_by_account
687            .entry(account_id)
688            .and_modify(|existing| {
689                if record.block_num > existing.block_num {
690                    *existing = record;
691                }
692            })
693            .or_insert(record);
694    }
695
696    latest_by_account
697        .into_iter()
698        .map(|(account_id, record)| {
699            (account_id, record.transaction_header.final_state_commitment())
700        })
701        .collect()
702}
703
704/// Returns nullifiers ordered by consuming transaction position, per account.
705///
706/// Groups RPC transaction records by (`account_id`, `block_num`), chains them using
707/// `initial_state_commitment` / `final_state_commitment`, and collects each transaction's
708/// input note nullifiers in execution order. Nullifiers from the same account are in execution
709/// order; ordering across different accounts is arbitrary.
710fn compute_ordered_nullifiers(transaction_records: &[RpcTransactionRecord]) -> Vec<Nullifier> {
711    // Group transactions by (account_id, block_num).
712    let mut groups: BTreeMap<(AccountId, BlockNumber), Vec<&RpcTransactionRecord>> =
713        BTreeMap::new();
714
715    for record in transaction_records {
716        let account_id = record.transaction_header.account_id();
717        groups.entry((account_id, record.block_num)).or_default().push(record);
718    }
719
720    let mut result = Vec::new();
721
722    for txs in groups.values() {
723        // Build a lookup from initial_state_commitment -> transaction record.
724        let mut init_to_tx: BTreeMap<Word, &RpcTransactionRecord> = txs
725            .iter()
726            .map(|tx| (tx.transaction_header.initial_state_commitment(), *tx))
727            .collect();
728
729        // Build a set of all final states to find the chain start.
730        let final_states: BTreeSet<Word> =
731            txs.iter().map(|tx| tx.transaction_header.final_state_commitment()).collect();
732
733        // Find the chain start: the tx whose initial_state_commitment is not any other tx's
734        // final_state_commitment.
735        let chain_start = txs
736            .iter()
737            .find(|tx| !final_states.contains(&tx.transaction_header.initial_state_commitment()));
738
739        let Some(start_tx) = chain_start else {
740            continue;
741        };
742
743        // Walk the chain from start, removing each step from the map.
744        let mut current =
745            init_to_tx.remove(&start_tx.transaction_header.initial_state_commitment());
746
747        while let Some(tx) = current {
748            for commitment in tx.transaction_header.input_notes().iter() {
749                result.push(commitment.nullifier());
750            }
751            current = init_to_tx.remove(&tx.transaction_header.final_state_commitment());
752        }
753    }
754
755    result
756}
757
758#[cfg(test)]
759mod tests {
760    use alloc::collections::BTreeSet;
761    use alloc::sync::Arc;
762
763    use async_trait::async_trait;
764    use miden_protocol::assembly::DefaultSourceManager;
765    use miden_protocol::crypto::merkle::mmr::{Forest, InOrderIndex, PartialMmr};
766    use miden_protocol::note::{NoteTag, NoteType};
767    use miden_protocol::{Felt, Word};
768    use miden_standards::code_builder::CodeBuilder;
769    use miden_testing::MockChainBuilder;
770
771    use super::*;
772    use crate::testing::mock::MockRpcApi;
773
774    /// Mock note screener that discards all notes, for minimal test setup.
775    struct MockScreener;
776
777    #[async_trait(?Send)]
778    impl OnNoteReceived for MockScreener {
779        async fn on_note_received(
780            &self,
781            _committed_note: CommittedNote,
782            _public_note: Option<InputNoteRecord>,
783        ) -> Result<NoteUpdateAction, ClientError> {
784            Ok(NoteUpdateAction::Discard)
785        }
786    }
787
788    fn empty() -> StateSyncInput {
789        StateSyncInput {
790            accounts: vec![],
791            note_tags: BTreeSet::new(),
792            input_notes: vec![],
793            output_notes: vec![],
794            uncommitted_transactions: vec![],
795        }
796    }
797
798    // COMPUTE NULLIFIER TX ORDER TESTS
799    // --------------------------------------------------------------------------------------------
800
801    mod compute_nullifiers_tests {
802        use alloc::vec;
803
804        use miden_protocol::asset::FungibleAsset;
805        use miden_protocol::block::BlockNumber;
806        use miden_protocol::note::Nullifier;
807        use miden_protocol::transaction::{InputNoteCommitment, InputNotes, TransactionHeader};
808        use miden_protocol::{Felt, ZERO};
809
810        use crate::rpc::domain::transaction::{
811            ACCOUNT_ID_NATIVE_ASSET_FAUCET,
812            TransactionRecord as RpcTransactionRecord,
813        };
814
815        fn word(n: u64) -> miden_protocol::Word {
816            [Felt::new(n), ZERO, ZERO, ZERO].into()
817        }
818
819        fn make_rpc_tx(
820            init_state: u64,
821            final_state: u64,
822            nullifier_vals: &[u64],
823            block_number: u32,
824        ) -> RpcTransactionRecord {
825            let account_id = miden_protocol::account::AccountId::try_from(
826                miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE,
827            )
828            .unwrap();
829
830            let input_notes = InputNotes::new_unchecked(
831                nullifier_vals
832                    .iter()
833                    .map(|v| InputNoteCommitment::from(Nullifier::from_raw(word(*v))))
834                    .collect(),
835            );
836
837            let fee =
838                FungibleAsset::new(ACCOUNT_ID_NATIVE_ASSET_FAUCET.try_into().expect("valid"), 0u64)
839                    .unwrap();
840
841            RpcTransactionRecord {
842                block_num: BlockNumber::from(block_number),
843                transaction_header: TransactionHeader::new(
844                    account_id,
845                    word(init_state),
846                    word(final_state),
847                    input_notes,
848                    vec![],
849                    fee,
850                ),
851                output_notes: vec![],
852            }
853        }
854
855        #[test]
856        fn chains_rpc_transactions_by_state_commitment() {
857            // Chain: tx_a (state 1->2) -> tx_b (state 2->3) -> tx_c (state 3->4)
858            // Passed in reverse order to verify chaining uses state, not insertion order.
859            let tx_a = make_rpc_tx(1, 2, &[10], 5);
860            let tx_b = make_rpc_tx(2, 3, &[20], 5);
861            let tx_c = make_rpc_tx(3, 4, &[30], 5);
862
863            let result = super::super::compute_ordered_nullifiers(&[tx_c, tx_a, tx_b]);
864
865            assert_eq!(result[0], Nullifier::from_raw(word(10)));
866            assert_eq!(result[1], Nullifier::from_raw(word(20)));
867            assert_eq!(result[2], Nullifier::from_raw(word(30)));
868        }
869
870        #[test]
871        fn groups_independently_by_account_and_block() {
872            // Account A, block 5: two chained txs.
873            let tx_a1 = make_rpc_tx(1, 2, &[10], 5);
874            let tx_a2 = make_rpc_tx(2, 3, &[20], 5);
875
876            // Account A, block 6: independent chain.
877            let tx_a3 = make_rpc_tx(3, 4, &[30], 6);
878
879            // Account B, block 5: independent chain.
880            let account_b = miden_protocol::account::AccountId::try_from(
881                miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
882            )
883            .unwrap();
884
885            let fee =
886                FungibleAsset::new(ACCOUNT_ID_NATIVE_ASSET_FAUCET.try_into().expect("valid"), 0u64)
887                    .unwrap();
888
889            let tx_b1 = RpcTransactionRecord {
890                block_num: BlockNumber::from(5u32),
891                transaction_header: TransactionHeader::new(
892                    account_b,
893                    word(100),
894                    word(200),
895                    InputNotes::new_unchecked(vec![InputNoteCommitment::from(
896                        Nullifier::from_raw(word(40)),
897                    )]),
898                    vec![],
899                    fee,
900                ),
901                output_notes: vec![],
902            };
903
904            let result = super::super::compute_ordered_nullifiers(&[tx_a2, tx_b1, tx_a3, tx_a1]);
905
906            // Nullifiers are ordered by chain position within each (account, block) group.
907            // The exact global indices depend on BTreeMap iteration order of the groups.
908            let pos = |val: u64| -> usize {
909                result.iter().position(|n| *n == Nullifier::from_raw(word(val))).unwrap()
910            };
911
912            // Within the same group, chain order is preserved.
913            assert!(pos(10) < pos(20)); // A, block 5: pos 0 < pos 1
914            // Nullifiers from different groups are all present.
915            assert!(result.contains(&Nullifier::from_raw(word(30)))); // A, block 6
916            assert!(result.contains(&Nullifier::from_raw(word(40)))); // B, block 5
917        }
918
919        #[test]
920        fn multiple_nullifiers_per_transaction_are_consecutive() {
921            // Single tx consuming 3 notes — all should appear consecutively.
922            let tx = make_rpc_tx(1, 2, &[10, 20, 30], 5);
923
924            let result = super::super::compute_ordered_nullifiers(&[tx]);
925
926            assert_eq!(result.len(), 3);
927            assert!(result.contains(&Nullifier::from_raw(word(10))));
928            assert!(result.contains(&Nullifier::from_raw(word(20))));
929            assert!(result.contains(&Nullifier::from_raw(word(30))));
930        }
931
932        #[test]
933        fn empty_input_returns_empty_vec() {
934            let result = super::super::compute_ordered_nullifiers(&[]);
935            assert!(result.is_empty());
936        }
937    }
938
939    // CONSUMED NOTE ORDERING INTEGRATION TESTS
940    // --------------------------------------------------------------------------------------------
941
942    /// Mock note screener that commits all notes matching tracked input notes.
943    /// This ensures committed notes get their inclusion proofs set during sync.
944    struct CommitAllScreener;
945
946    #[async_trait(?Send)]
947    impl OnNoteReceived for CommitAllScreener {
948        async fn on_note_received(
949            &self,
950            committed_note: CommittedNote,
951            _public_note: Option<InputNoteRecord>,
952        ) -> Result<NoteUpdateAction, ClientError> {
953            Ok(NoteUpdateAction::Commit(committed_note))
954        }
955    }
956
957    use miden_protocol::account::Account;
958    use miden_protocol::note::Note;
959
960    /// Builds a `MockChain` where 3 notes are consumed by chained transactions in the same block.
961    ///
962    /// Returns the chain, the account, and the 3 notes (in consumption order).
963    async fn build_chain_with_chained_consume_txs() -> (miden_testing::MockChain, Account, [Note; 3])
964    {
965        use miden_protocol::asset::{Asset, FungibleAsset};
966        use miden_protocol::note::NoteType;
967        use miden_protocol::testing::account_id::{
968            ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET,
969            ACCOUNT_ID_SENDER,
970        };
971        use miden_testing::{MockChainBuilder, TxContextInput};
972
973        let sender_id: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
974        let faucet_id: AccountId = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into().unwrap();
975
976        let mut builder = MockChainBuilder::new();
977        let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
978        let account_id = account.id();
979
980        let asset = Asset::Fungible(FungibleAsset::new(faucet_id, 100u64).unwrap());
981        let note1 = builder
982            .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
983            .unwrap();
984        let note2 = builder
985            .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
986            .unwrap();
987        let note3 = builder
988            .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
989            .unwrap();
990
991        let mut chain = builder.build().unwrap();
992        chain.prove_next_block().unwrap(); // block 1: makes genesis notes consumable
993
994        // Execute 3 chained consume transactions (state S0→S1→S2→S3).
995        let mut current_account = account.clone();
996        for note in [&note1, &note2, &note3] {
997            let tx = Box::pin(
998                chain
999                    .build_tx_context(
1000                        TxContextInput::Account(current_account.clone()),
1001                        &[],
1002                        core::slice::from_ref(note),
1003                    )
1004                    .unwrap()
1005                    .build()
1006                    .unwrap()
1007                    .execute(),
1008            )
1009            .await
1010            .unwrap();
1011            current_account.apply_delta(tx.account_delta()).unwrap();
1012            chain.add_pending_executed_transaction(&tx).unwrap();
1013        }
1014
1015        chain.prove_next_block().unwrap(); // block 2: all 3 txs in one block
1016        (chain, account, [note1, note2, note3])
1017    }
1018
1019    /// Verifies that `consumed_tx_order` is correctly set when multiple chained transactions
1020    /// for the same account consume notes in the same block.
1021    #[tokio::test]
1022    async fn sync_state_sets_consumed_tx_order_for_chained_transactions() {
1023        use miden_protocol::note::NoteMetadata;
1024
1025        let (chain, account, [note1, note2, note3]) = build_chain_with_chained_consume_txs().await;
1026
1027        let mock_rpc = MockRpcApi::new(chain);
1028        let state_sync =
1029            StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(CommitAllScreener), None);
1030
1031        let genesis_peaks = mock_rpc.get_mmr().peaks_at(Forest::new(1)).unwrap();
1032        let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
1033
1034        let input_notes: Vec<InputNoteRecord> = [&note1, &note2, &note3]
1035            .into_iter()
1036            .map(|n| InputNoteRecord::from(n.clone()))
1037            .collect();
1038
1039        let note_tags: BTreeSet<NoteTag> =
1040            input_notes.iter().filter_map(|n| n.metadata().map(NoteMetadata::tag)).collect();
1041
1042        let account_id = account.id();
1043        let sync_input = StateSyncInput {
1044            accounts: vec![account.into()],
1045            note_tags,
1046            input_notes,
1047            output_notes: vec![],
1048            uncommitted_transactions: vec![],
1049        };
1050
1051        let update = state_sync.sync_state(&mut partial_mmr, sync_input).await.unwrap();
1052
1053        let updated_notes: Vec<_> = update.note_updates.updated_input_notes().collect();
1054
1055        let find_order = |note_id: NoteId| -> Option<u32> {
1056            updated_notes
1057                .iter()
1058                .find(|n| n.id() == note_id)
1059                .and_then(|n| n.consumed_tx_order())
1060        };
1061
1062        assert_eq!(find_order(note1.id()), Some(0), "note1 should have tx_order 0");
1063        assert_eq!(find_order(note2.id()), Some(1), "note2 should have tx_order 1");
1064        assert_eq!(find_order(note3.id()), Some(2), "note3 should have tx_order 2");
1065
1066        // Since there are no uncommitted_transactions, these notes were consumed by a tracked
1067        // account via external transactions. Verify that consumer_account is populated.
1068        for note in &updated_notes {
1069            let record = note.inner();
1070            assert!(record.is_consumed(), "note should be in a consumed state");
1071            assert_eq!(
1072                record.consumer_account(),
1073                Some(account_id),
1074                "externally-consumed notes by a tracked account should have consumer_account set",
1075            );
1076        }
1077    }
1078
1079    #[tokio::test]
1080    async fn sync_state_across_multiple_iterations_with_same_mmr() {
1081        // Setup: create a mock chain and advance it so there are blocks to sync.
1082        let mock_rpc = MockRpcApi::default();
1083        mock_rpc.advance_blocks(3);
1084        let chain_tip_1 = mock_rpc.get_chain_tip_block_num();
1085
1086        let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
1087
1088        // Build the initial PartialMmr from genesis (only 1 leaf).
1089        let genesis_peaks = mock_rpc.get_mmr().peaks_at(Forest::new(1)).unwrap();
1090        let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
1091        assert_eq!(partial_mmr.forest().num_leaves(), 1);
1092
1093        // First sync
1094        let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
1095
1096        assert_eq!(update.block_num, chain_tip_1);
1097        let forest_1 = partial_mmr.forest();
1098        // The MMR should contain one leaf per block (genesis + the new blocks).
1099        assert_eq!(forest_1.num_leaves(), chain_tip_1.as_u32() as usize + 1);
1100
1101        // Second sync
1102        mock_rpc.advance_blocks(2);
1103        let chain_tip_2 = mock_rpc.get_chain_tip_block_num();
1104
1105        let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
1106
1107        assert_eq!(update.block_num, chain_tip_2);
1108        let forest_2 = partial_mmr.forest();
1109        assert!(forest_2 > forest_1);
1110        assert_eq!(forest_2.num_leaves(), chain_tip_2.as_u32() as usize + 1);
1111
1112        // Third sync (no new blocks)
1113        let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
1114
1115        assert_eq!(update.block_num, chain_tip_2);
1116        assert_eq!(partial_mmr.forest(), forest_2);
1117    }
1118
1119    /// Builds a mock chain with a faucet that mints `num_blocks` notes, one per block.
1120    /// Returns the chain and the set of note tags for filtering.
1121    async fn build_chain_with_mint_notes(
1122        num_blocks: u64,
1123    ) -> (miden_testing::MockChain, BTreeSet<NoteTag>) {
1124        let mut builder = MockChainBuilder::new();
1125        let faucet = builder
1126            .add_existing_basic_faucet(
1127                miden_testing::Auth::BasicAuth {
1128                    auth_scheme: miden_protocol::account::auth::AuthScheme::Falcon512Poseidon2,
1129                },
1130                "TST",
1131                10_000,
1132                None,
1133            )
1134            .unwrap();
1135        let _target = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1136        let mut chain = builder.build().unwrap();
1137
1138        let recipient: Word = [0u32, 1, 2, 3].into();
1139        let tag = NoteTag::default();
1140        let mut faucet_account = faucet.clone();
1141        let mut note_tags = BTreeSet::new();
1142
1143        for i in 0..num_blocks {
1144            let amount = Felt::new(100 + i);
1145            let source_manager = Arc::new(DefaultSourceManager::default());
1146            let tx_script_code = format!(
1147                "
1148                begin
1149                    padw padw push.0
1150                    push.{r0}.{r1}.{r2}.{r3}
1151                    push.{note_type}
1152                    push.{tag}
1153                    push.{amount}
1154                    call.::miden::standards::faucets::basic_fungible::mint_and_send
1155                    dropw dropw dropw dropw
1156                end
1157                ",
1158                r0 = recipient[0],
1159                r1 = recipient[1],
1160                r2 = recipient[2],
1161                r3 = recipient[3],
1162                note_type = NoteType::Private as u8,
1163                tag = u32::from(tag),
1164                amount = amount,
1165            );
1166            let tx_script = CodeBuilder::with_source_manager(source_manager.clone())
1167                .compile_tx_script(tx_script_code)
1168                .unwrap();
1169            let tx = Box::pin(
1170                chain
1171                    .build_tx_context(
1172                        miden_testing::TxContextInput::Account(faucet_account.clone()),
1173                        &[],
1174                        &[],
1175                    )
1176                    .unwrap()
1177                    .tx_script(tx_script)
1178                    .with_source_manager(source_manager)
1179                    .build()
1180                    .unwrap()
1181                    .execute(),
1182            )
1183            .await
1184            .unwrap();
1185
1186            for output_note in tx.output_notes().iter() {
1187                note_tags.insert(output_note.metadata().tag());
1188            }
1189
1190            faucet_account.apply_delta(tx.account_delta()).unwrap();
1191            chain.add_pending_executed_transaction(&tx).unwrap();
1192            chain.prove_next_block().unwrap();
1193        }
1194
1195        (chain, note_tags)
1196    }
1197
1198    /// Verifies that the sync correctly processes notes committed in multiple blocks
1199    /// (batched `SyncNotes` response) and tracks their blocks in the partial MMR.
1200    ///
1201    /// This test creates a faucet and mints notes in separate blocks (blocks 1, 2, 3),
1202    /// so `sync_notes` returns multiple `NoteSyncBlock`s. It then verifies:
1203    /// - The MMR is advanced to the chain tip
1204    /// - Blocks containing relevant notes are tracked in the partial MMR via `track()`
1205    /// - Note inclusion proofs are set correctly
1206    /// - Block headers for note blocks are stored
1207    #[tokio::test]
1208    async fn sync_state_tracks_note_blocks_in_mmr() {
1209        let (chain, note_tags) = build_chain_with_mint_notes(3).await;
1210        let mock_rpc = MockRpcApi::new(chain);
1211        let chain_tip = mock_rpc.get_chain_tip_block_num();
1212
1213        // Verify the mock returns notes across multiple blocks.
1214        let note_sync =
1215            mock_rpc.sync_notes(BlockNumber::from(0u32), None, &note_tags).await.unwrap();
1216        assert!(
1217            note_sync.blocks.len() >= 2,
1218            "expected notes in multiple blocks, got {}",
1219            note_sync.blocks.len()
1220        );
1221
1222        // Collect the block numbers that have notes.
1223        let note_block_nums: BTreeSet<BlockNumber> =
1224            note_sync.blocks.iter().map(|b| b.block_header.block_num()).collect();
1225
1226        // Test that fetch_sync_data returns note blocks with valid MMR paths that
1227        // can be used to track blocks in the partial MMR.
1228        let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
1229
1230        let genesis_peaks = mock_rpc.get_mmr().peaks_at(Forest::new(1)).unwrap();
1231        let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
1232
1233        let sync_data = state_sync
1234            .fetch_sync_data(BlockNumber::GENESIS, &[], &Arc::new(note_tags.clone()))
1235            .await
1236            .unwrap()
1237            .expect("should have progressed past genesis");
1238
1239        // Should have advanced to the chain tip.
1240        assert_eq!(sync_data.chain_tip_header.block_num(), chain_tip);
1241        assert!(!sync_data.note_blocks.is_empty(), "should have note blocks");
1242
1243        // Apply the MMR delta and add the chain tip block.
1244        let _auth_nodes: Vec<(InOrderIndex, Word)> =
1245            partial_mmr.apply(sync_data.mmr_delta).map_err(StoreError::MmrError).unwrap();
1246        partial_mmr.add(sync_data.chain_tip_header.commitment(), false);
1247
1248        assert_eq!(partial_mmr.forest().num_leaves(), chain_tip.as_u32() as usize + 1);
1249
1250        // Track each note block using the MMR path from the sync_notes response.
1251        for block in &sync_data.note_blocks {
1252            let bn = block.block_header.block_num();
1253            partial_mmr
1254                .track(bn.as_usize(), block.block_header.commitment(), &block.mmr_path)
1255                .map_err(StoreError::MmrError)
1256                .unwrap();
1257
1258            assert!(
1259                partial_mmr.is_tracked(bn.as_usize()),
1260                "block {bn} should be tracked after calling track()"
1261            );
1262        }
1263
1264        // Verify the tracked blocks match the note blocks.
1265        for &bn in &note_block_nums {
1266            assert!(
1267                partial_mmr.is_tracked(bn.as_usize()),
1268                "block {bn} with notes should be tracked in partial MMR"
1269            );
1270        }
1271    }
1272}