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;
5use core::cmp::Ordering;
6
7use async_trait::async_trait;
8use miden_protocol::Word;
9use miden_protocol::account::{Account, AccountHeader, AccountId, StorageSlotType};
10use miden_protocol::block::{BlockHeader, BlockNumber};
11use miden_protocol::crypto::merkle::mmr::{MmrDelta, PartialMmr};
12use miden_protocol::note::{NoteAttachments, NoteId, NoteTag, NoteType, Nullifier};
13use tracing::info;
14
15use super::state_sync_update::TransactionUpdateTracker;
16use super::{
17    AccountUpdates,
18    NoteObserver,
19    PartialBlockchainUpdates,
20    PublicAccountDelta,
21    PublicAccountUpdate,
22    StateSyncUpdate,
23};
24use crate::ClientError;
25use crate::note::{NoteConsumption, NoteUpdateTracker};
26use crate::rpc::NodeRpcClient;
27use crate::rpc::domain::account::{AccountDetails, GetAccountRequest, StorageMapFetch, VaultFetch};
28use crate::rpc::domain::note::{CommittedNote, NoteSyncBlock, SyncedNoteDetails};
29use crate::rpc::domain::sync::{ChainMmrInfo, SyncTarget};
30use crate::rpc::domain::transaction::TransactionRecord as RpcTransactionRecord;
31use crate::store::{InputNoteRecord, OutputNoteRecord, StoreError};
32use crate::transaction::TransactionRecord;
33
34// STATE UPDATE DATA
35// ================================================================================================
36
37/// How a node snapshot of a public account should be reconciled against the local state.
38enum PublicAccountSync {
39    /// Node is newer — apply its state to the store.
40    Apply(Box<PublicAccountUpdate>),
41    /// Same nonce but different state — the local transaction lost the race and must be discarded.
42    Superseded,
43    /// Node is behind the local (potentially optimistic) state — leave the local state untouched.
44    Ignore,
45}
46
47/// Data fetched from the node needed to sync the client to the chain tip.
48///
49/// Aggregates the responses of `sync_chain_mmr`, `sync_notes`, `get_notes_by_id`, and
50/// `sync_transactions`. This may contain more data than a particular client needs to store — it is
51/// filtered and transformed into a [`StateSyncUpdate`] before being applied.
52struct FetchedSyncData {
53    /// MMR delta covering the full range from `current_block` to `chain_tip`.
54    mmr_delta: MmrDelta,
55    /// Chain tip block header.
56    chain_tip_header: BlockHeader,
57    /// Blocks with matching notes that the client is interested in.
58    note_blocks: Vec<NoteSyncBlock>,
59    /// Content fetched for the synced notes (public note bodies and private-note attachments),
60    /// keyed by note ID.
61    synced_notes: BTreeMap<NoteId, SyncedNoteDetails>,
62    /// Transaction records for the synced range, as returned by `sync_transactions`.
63    transactions: Vec<RpcTransactionRecord>,
64}
65
66// SYNC REQUEST
67// ================================================================================================
68
69/// Bundles the client state needed to perform a sync operation.
70///
71/// The sync process uses these inputs to:
72/// - Request account commitment updates from the node for the provided accounts.
73/// - Filter which note inclusions the node returns based on the provided note tags.
74/// - Follow the lifecycle of every tracked note (input and output), transitioning them from pending
75///   to committed to consumed as the network state advances.
76/// - Track uncommitted transactions so they can be marked as committed when the node confirms them,
77///   or discarded when they become stale.
78///
79/// Use [`Client::build_sync_input()`](`crate::Client::build_sync_input()`) to build a default input
80/// from the client state, or construct this struct manually for custom sync scenarios.
81pub struct StateSyncInput {
82    /// Headers of the tracked accounts to follow during the sync.
83    pub accounts: Vec<AccountHeader>,
84    /// Note tags that the node uses to filter which note inclusions to return.
85    pub note_tags: BTreeSet<NoteTag>,
86    /// Input notes whose lifecycle should be followed during sync.
87    pub input_notes: Vec<InputNoteRecord>,
88    /// Output notes whose lifecycle should be followed during sync.
89    pub output_notes: Vec<OutputNoteRecord>,
90    /// Transactions to track for commitment or discard during sync.
91    pub uncommitted_transactions: Vec<TransactionRecord>,
92}
93
94// SYNC CALLBACKS
95// ================================================================================================
96
97/// The action to be taken when a note update is received as part of the sync response.
98#[allow(clippy::large_enum_variant)]
99pub enum NoteUpdateAction {
100    /// The note commit update is relevant and the specified note should be marked as committed in
101    /// the store, storing its inclusion proof.
102    Commit(CommittedNote),
103    /// The public note is relevant and should be inserted into the store.
104    Insert(InputNoteRecord),
105    /// The note update is not relevant and should be discarded.
106    Discard,
107}
108
109#[async_trait(?Send)]
110pub trait OnNoteReceived {
111    /// Callback that gets executed when a new note is received as part of the sync response.
112    ///
113    /// It receives:
114    ///
115    /// - The committed note received from the network.
116    /// - An optional note record that corresponds to the state of the note in the network (only if
117    ///   the note is public).
118    ///
119    /// It returns an enum indicating the action to be taken for the received note update. Whether
120    /// the note updated should be committed, new public note inserted, or ignored.
121    async fn on_note_received(
122        &self,
123        committed_note: CommittedNote,
124        public_note: Option<InputNoteRecord>,
125    ) -> Result<NoteUpdateAction, ClientError>;
126}
127// STATE SYNC
128// ================================================================================================
129
130/// The state sync component encompasses the client's sync logic. It is then used to request
131/// updates from the node and apply them to the relevant elements. The updates are then returned and
132/// can be applied to the store to persist the changes.
133#[derive(Clone)]
134pub struct StateSync {
135    /// The RPC client used to communicate with the node.
136    rpc_api: Arc<dyn NodeRpcClient>,
137    /// Responsible for checking the relevance of notes and executing the
138    /// [`OnNoteReceived`] callback when a new note inclusion is received.
139    note_screener: Arc<dyn OnNoteReceived>,
140    /// Per-note observers (see [`NoteObserver`]), invoked *before* the
141    /// screener verdict in `note_state_sync`. Empty by default.
142    note_observers: Vec<Arc<dyn NoteObserver>>,
143    /// Number of blocks after which pending transactions are considered stale and discarded.
144    /// If `None`, there is no limit and transactions will be kept indefinitely.
145    tx_discard_delta: Option<u32>,
146    /// If true, queries the node for consumption of tracked unspent-note nullifiers
147    /// each sync and discards local transactions whose inputs were nullified.
148    sync_nullifiers: bool,
149}
150
151impl StateSync {
152    /// Creates a new instance of the state sync component.
153    ///
154    /// The nullifiers sync is enabled by default. To disable it, see
155    /// [`Self::disable_nullifier_sync`].
156    ///
157    /// # Arguments
158    ///
159    /// * `rpc_api` - The RPC client used to communicate with the node.
160    /// * `note_screener` - The note screener used to check the relevance of notes.
161    /// * `tx_discard_delta` - Number of blocks after which pending transactions are discarded.
162    pub fn new(
163        rpc_api: Arc<dyn NodeRpcClient>,
164        note_screener: Arc<dyn OnNoteReceived>,
165        tx_discard_delta: Option<u32>,
166    ) -> Self {
167        Self {
168            rpc_api,
169            note_screener,
170            note_observers: Vec::new(),
171            tx_discard_delta,
172            sync_nullifiers: true,
173        }
174    }
175
176    /// Attaches a [`NoteObserver`] to this sync component. Observers run
177    /// in attachment order *before* the screener verdict; failures are
178    /// logged (tagged with [`NoteObserver::name`]) and never abort sync.
179    #[must_use]
180    pub fn with_note_observer(mut self, observer: Arc<dyn NoteObserver>) -> Self {
181        self.note_observers.push(observer);
182        self
183    }
184
185    /// Disables the nullifier sync.
186    ///
187    /// When disabled, the component will not query the node for new nullifiers after each sync
188    /// step. This is useful for clients that don't need to track note consumption, such as
189    /// faucets.
190    pub fn disable_nullifier_sync(&mut self) {
191        self.sync_nullifiers = false;
192    }
193
194    /// Enables the nullifier sync.
195    pub fn enable_nullifier_sync(&mut self) {
196        self.sync_nullifiers = true;
197    }
198
199    /// Runs each attached observer's `apply()` hook against `state_sync_update`.
200    /// Called by the orchestrator after [`Self::sync_state`] returns but
201    /// before the caller persists the sync update. Per-observer failures are
202    /// logged (tagged with the observer's [`NoteObserver::name`]) and never
203    /// abort the rest of the pass — symmetric with the per-note `observe()`
204    /// dispatcher.
205    pub(crate) async fn run_apply_hooks(
206        &self,
207        state_sync_update: &StateSyncUpdate,
208    ) -> Result<(), ClientError> {
209        for observer in &self.note_observers {
210            crate::errors::log_observer_failure(
211                observer.name(),
212                "NoteObserver::apply",
213                observer.apply(state_sync_update).await,
214            );
215        }
216        Ok(())
217    }
218
219    /// Syncs the state of the client with the chain tip of the node, returning the updates that
220    /// should be applied to the store.
221    ///
222    /// Use [`Client::build_sync_input()`](`crate::Client::build_sync_input()`) to build the default
223    /// input, or assemble it manually for custom sync. The `current_partial_mmr` is taken by
224    /// mutable reference so callers can keep it in memory across syncs.
225    ///
226    /// During the sync process, the following steps are performed:
227    /// 1. Fetch sync data from the node (MMR delta, note inclusions, transactions).
228    /// 2. Update account states (fetch updated public accounts, flag mismatched private ones).
229    /// 3. Advance the partial MMR to the chain tip.
230    /// 4. Screen note inclusions via the configured [`OnNoteReceived`] callback and track relevant
231    ///    blocks in the MMR.
232    /// 5. Process transaction inclusions (commit local txs, record external consumers, discard
233    ///    stale/expired txs, commit output notes).
234    /// 6. Detect consumed notes via nullifier sync (optional, see
235    ///    [`Self::disable_nullifier_sync`]).
236    pub async fn sync_state(
237        &self,
238        current_partial_mmr: &mut PartialMmr,
239        input: StateSyncInput,
240    ) -> Result<StateSyncUpdate, ClientError> {
241        let StateSyncInput {
242            accounts,
243            note_tags,
244            input_notes,
245            output_notes,
246            uncommitted_transactions,
247        } = input;
248        let block_num = u32::try_from(current_partial_mmr.forest().num_leaves().saturating_sub(1))
249            .map_err(|_| ClientError::InvalidPartialMmrForest)?
250            .into();
251
252        let note_tags = Arc::new(note_tags);
253        let account_ids: Vec<AccountId> = accounts.iter().map(AccountHeader::id).collect();
254
255        let mut state_sync_update = StateSyncUpdate {
256            block_num,
257            note_updates: NoteUpdateTracker::new(input_notes, output_notes),
258            transaction_updates: TransactionUpdateTracker::new(uncommitted_transactions),
259            ..Default::default()
260        };
261        let Some(sync_data) = self
262            .fetch_sync_data(state_sync_update.block_num, &account_ids, &note_tags)
263            .await?
264        else {
265            // No progress — already at the tip.
266            return Ok(state_sync_update);
267        };
268
269        state_sync_update.block_num = sync_data.chain_tip_header.block_num();
270
271        let new_commitments = derive_account_commitments(&sync_data.transactions);
272        let superseded_states = self
273            .account_state_sync(
274                &mut state_sync_update.account_updates,
275                &accounts,
276                &new_commitments,
277                block_num,
278            )
279            .await?;
280
281        // Discard the local transactions whose result lost a same-nonce race against the network.
282        for superseded_state in superseded_states {
283            state_sync_update
284                .transaction_updates
285                .apply_superseded_account_state(superseded_state);
286        }
287
288        // Apply local changes: update the MMR, screen notes, and apply state transitions.
289        self.apply_sync_result(sync_data, &mut state_sync_update, current_partial_mmr)
290            .await?;
291
292        if self.sync_nullifiers {
293            self.nullifiers_state_sync(&mut state_sync_update, block_num).await?;
294        }
295
296        Ok(state_sync_update)
297    }
298
299    /// Fetches the sync data from the node by calling the following endpoints:
300    /// 1. `sync_chain_mmr` — discovers the chain tip, gets the MMR delta and chain tip header.
301    /// 2. `sync_notes` — loops until the full range to the chain tip is covered (handles paginated
302    ///    responses).
303    /// 3. `get_notes_by_id` — fetches full metadata for notes with attachments.
304    /// 4. `sync_transactions` — gets transaction data for the full range.
305    ///
306    /// Returns `None` when the client is already at the chain tip (no progress).
307    async fn fetch_sync_data(
308        &self,
309        current_block_num: BlockNumber,
310        account_ids: &[AccountId],
311        note_tags: &Arc<BTreeSet<NoteTag>>,
312    ) -> Result<Option<FetchedSyncData>, ClientError> {
313        // Step 1: Fetch the MMR delta and chain tip header.
314        let chain_mmr_info = self
315            .rpc_api
316            .sync_chain_mmr(current_block_num, SyncTarget::CommittedChainTip)
317            .await?;
318        let chain_tip = chain_mmr_info.block_to;
319
320        // Validate the response covers the range we requested.
321        Self::validate_chain_mmr_response(&chain_mmr_info, current_block_num)?;
322
323        // No progress — already at the tip.
324        if chain_tip == current_block_num {
325            info!(block_num = %current_block_num, "Already at chain tip, nothing to sync.");
326            return Ok(None);
327        }
328
329        info!(
330            block_from = %current_block_num,
331            block_to = %chain_tip,
332            "Syncing state.",
333        );
334
335        // Step 2: sync notes and fetch full note bodies for public notes (and attachment content
336        // for private notes that carry attachments), paginating with the same chain tip so MMR
337        // paths are opened at a consistent forest. With no tracked tags there's nothing the node
338        // could match, so skip the RPC entirely.
339        let (note_blocks, synced_notes) = if note_tags.is_empty() {
340            (Vec::new(), BTreeMap::new())
341        } else {
342            self.rpc_api
343                .sync_notes_with_details(current_block_num + 1, chain_tip, note_tags.as_ref())
344                .await?
345        };
346
347        // Validate every returned note block falls in (current_block_num, chain_tip].
348        Self::validate_note_blocks_range(&note_blocks, current_block_num, chain_tip)?;
349
350        let note_count: usize = note_blocks.iter().map(|b| b.notes.len()).sum();
351        info!(
352            blocks_with_notes = note_blocks.len(),
353            notes = note_count,
354            synced_notes = synced_notes.len(),
355            "Fetched note sync data.",
356        );
357
358        // Step 3: sync transactions for tracked accounts over the full range. With no tracked
359        // accounts there's nothing the node could match, so skip the RPC entirely.
360        let transaction_records = if account_ids.is_empty() {
361            Vec::new()
362        } else {
363            self.rpc_api
364                .sync_transactions(current_block_num + 1, chain_tip, account_ids.to_vec())
365                .await?
366        };
367
368        Ok(Some(FetchedSyncData {
369            mmr_delta: chain_mmr_info.mmr_delta,
370            chain_tip_header: chain_mmr_info.block_header,
371            note_blocks,
372            synced_notes,
373            transactions: transaction_records,
374        }))
375    }
376
377    // HELPERS
378    // --------------------------------------------------------------------------------------------
379
380    /// Applies sync results to the local state update.
381    ///
382    /// Applies fetched sync data to the local state:
383    /// 1. Advances the partial MMR (delta + chain tip leaf).
384    /// 2. Screens note blocks and tracks relevant ones in the MMR.
385    /// 3. Applies transaction and nullifier updates.
386    async fn apply_sync_result(
387        &self,
388        sync_data: FetchedSyncData,
389        state_sync_update: &mut StateSyncUpdate,
390        current_partial_mmr: &mut PartialMmr,
391    ) -> Result<(), ClientError> {
392        let FetchedSyncData {
393            mmr_delta,
394            chain_tip_header,
395            note_blocks,
396            synced_notes,
397            transactions,
398        } = sync_data;
399
400        // Operate on a clone so any validation failure leaves `current_partial_mmr` untouched.
401        // The clone is committed back at the end of the function once all checks pass.
402        let mut working_mmr = current_partial_mmr.clone();
403
404        Self::advance_mmr(
405            mmr_delta,
406            &chain_tip_header,
407            &mut working_mmr,
408            &mut state_sync_update.partial_blockchain_updates,
409        )?;
410
411        self.screen_note_blocks(note_blocks, synced_notes, state_sync_update, &mut working_mmr)
412            .await?;
413
414        self.apply_transactions_and_nullifiers(
415            &chain_tip_header,
416            &transactions,
417            state_sync_update,
418        )?;
419
420        // Commit the working MMR back to the caller once all checks pass.
421        *current_partial_mmr = working_mmr;
422
423        Ok(())
424    }
425
426    /// Validates that a `sync_chain_mmr` response covers the requested range.
427    fn validate_chain_mmr_response(
428        chain_mmr_info: &ChainMmrInfo,
429        current_block_num: BlockNumber,
430    ) -> Result<(), ClientError> {
431        if chain_mmr_info.block_header.block_num() != chain_mmr_info.block_to {
432            return Err(ClientError::ChainValidationError(format!(
433                "sync_chain_mmr block_header.block_num ({}) does not match block_to ({})",
434                chain_mmr_info.block_header.block_num(),
435                chain_mmr_info.block_to
436            )));
437        }
438        if chain_mmr_info.block_from != current_block_num {
439            return Err(ClientError::ChainValidationError(format!(
440                "sync_chain_mmr block_from mismatch: expected {current_block_num}, got {}",
441                chain_mmr_info.block_from
442            )));
443        }
444        if chain_mmr_info.block_to < current_block_num {
445            return Err(ClientError::ChainValidationError(format!(
446                "sync_chain_mmr block_to ({}) is behind current block {current_block_num}",
447                chain_mmr_info.block_to
448            )));
449        }
450        Ok(())
451    }
452
453    /// Validates that every block returned by `sync_notes` falls in the requested range
454    /// `(current_block_num, chain_tip]`.
455    fn validate_note_blocks_range(
456        note_blocks: &[NoteSyncBlock],
457        current_block_num: BlockNumber,
458        chain_tip: BlockNumber,
459    ) -> Result<(), ClientError> {
460        for block in note_blocks {
461            let block_num = block.block_header.block_num();
462            if block_num <= current_block_num || block_num > chain_tip {
463                return Err(ClientError::ChainValidationError(format!(
464                    "sync_notes returned block {block_num} outside requested range ({current_block_num}, {chain_tip}]"
465                )));
466            }
467        }
468        Ok(())
469    }
470
471    /// Applies the MMR delta and inserts the chain-tip leaf into the partial blockchain
472    /// updates. The delta excludes the chain-tip leaf because of the one-block lag in block
473    /// header MMR commitments, so the tip leaf has to be added separately.
474    ///
475    /// Before adding the chain-tip leaf, the post-delta peaks are checked against the chain
476    /// tip header's chain commitment to ensure the delta advanced the MMR to the expected state.
477    fn advance_mmr(
478        mmr_delta: MmrDelta,
479        chain_tip_header: &BlockHeader,
480        current_partial_mmr: &mut PartialMmr,
481        partial_blockchain_updates: &mut PartialBlockchainUpdates,
482    ) -> Result<(), ClientError> {
483        let mut new_authentication_nodes =
484            current_partial_mmr.apply(mmr_delta).map_err(StoreError::MmrError)?;
485        let new_peaks = current_partial_mmr.peaks();
486
487        // Verify that post-delta peaks match the block header's chain commitment.
488        // chain_commitment is the hash of MMR peaks for blocks 0..block_num-1,
489        // which is exactly the state after applying the delta.
490        let peaks_commitment = new_peaks.hash_peaks();
491        if peaks_commitment != chain_tip_header.chain_commitment() {
492            return Err(ClientError::ChainValidationError(format!(
493                "MMR peaks commitment is {} and does not match block header chain commitment {}",
494                peaks_commitment.to_hex(),
495                chain_tip_header.chain_commitment().to_hex()
496            )));
497        }
498
499        partial_blockchain_updates.new_peaks = new_peaks;
500
501        // Note: we add the chain tip leaf to our MMR, but we cannot prove that it is effectively
502        // the chain tip. In the current context of centralized trusted node, we assume it
503        // is valid. Eventually, we will be able to validate that the resulting MMR root is
504        // "canonical".
505        new_authentication_nodes.append(
506            &mut current_partial_mmr
507                .add(chain_tip_header.commitment(), false)
508                .map_err(StoreError::MmrError)?,
509        );
510
511        partial_blockchain_updates.insert(
512            chain_tip_header.clone(),
513            false,
514            new_authentication_nodes,
515        );
516
517        Ok(())
518    }
519
520    /// Screens each note block for relevance and, for blocks containing client-relevant notes,
521    /// tracks them in the partial MMR using the authentication path from the `sync_notes`
522    /// response.
523    async fn screen_note_blocks(
524        &self,
525        note_blocks: Vec<NoteSyncBlock>,
526        synced_notes: BTreeMap<NoteId, SyncedNoteDetails>,
527        state_sync_update: &mut StateSyncUpdate,
528        current_partial_mmr: &mut PartialMmr,
529    ) -> Result<(), ClientError> {
530        // Attachment content for private notes, keyed by note ID. Joined to each committed note
531        // by ID so the stored record reconstructs the correct note ID.
532        let private_attachments: BTreeMap<NoteId, NoteAttachments> = synced_notes
533            .iter()
534            .filter_map(|(id, synced)| match synced {
535                SyncedNoteDetails::Private(Some(attachments)) => Some((*id, attachments.clone())),
536                _ => None,
537            })
538            .collect();
539        let public_note_records = Self::build_public_note_records(synced_notes, &note_blocks);
540
541        for block in note_blocks {
542            let found_relevant_note = self
543                .note_state_sync(
544                    &mut state_sync_update.note_updates,
545                    block.notes,
546                    &block.block_header,
547                    &public_note_records,
548                    &private_attachments,
549                )
550                .await?;
551
552            if found_relevant_note {
553                let block_pos = block.block_header.block_num().as_usize();
554
555                let nodes_before: BTreeMap<_, _> =
556                    current_partial_mmr.nodes().map(|(k, v)| (*k, *v)).collect();
557
558                if !current_partial_mmr.is_tracked(block_pos) {
559                    current_partial_mmr
560                        .track(block_pos, block.block_header.commitment(), &block.mmr_path)
561                        .map_err(StoreError::MmrError)?;
562                }
563
564                // Always collect new authentication nodes — even when the block was
565                // already tracked from the MMR delta, the delta's nodes may not include
566                // the full authentication path needed to reconstruct the PartialMmr
567                // from storage later.
568                let track_auth_nodes: Vec<_> = current_partial_mmr
569                    .nodes()
570                    .filter(|(k, _)| !nodes_before.contains_key(k))
571                    .map(|(k, v)| (*k, *v))
572                    .collect();
573
574                state_sync_update.partial_blockchain_updates.insert(
575                    block.block_header,
576                    true,
577                    track_auth_nodes,
578                );
579            }
580        }
581
582        Ok(())
583    }
584
585    /// Extends the note tracker with newly-observed nullifiers, applies transaction
586    /// inclusions, and walks each transaction to apply output-note inclusion proofs and mark
587    /// same-batch-erased output notes as consumed.
588    fn apply_transactions_and_nullifiers(
589        &self,
590        chain_tip_header: &BlockHeader,
591        transactions: &[RpcTransactionRecord],
592        state_sync_update: &mut StateSyncUpdate,
593    ) -> Result<(), ClientError> {
594        state_sync_update
595            .note_updates
596            .extend_nullifiers(compute_ordered_nullifiers(transactions));
597
598        for record in transactions {
599            state_sync_update
600                .transaction_updates
601                .apply_transaction_inclusion(record, u64::from(chain_tip_header.timestamp())); //TODO: Change timestamps from u64 to u32
602        }
603        state_sync_update
604            .transaction_updates
605            .apply_sync_height_update(chain_tip_header.block_num(), self.tx_discard_delta);
606
607        for transaction in transactions {
608            // Transition tracked output notes to Committed using inclusion proofs from the
609            // transaction sync response. This covers output notes regardless of whether their
610            // tags were tracked in the note sync.
611            state_sync_update
612                .note_updates
613                .apply_output_note_inclusion_proofs(&transaction.output_notes)?;
614
615            // Detect output notes erased by same-batch note erasure.
616            Self::mark_erased_notes_as_consumed(state_sync_update, transaction);
617        }
618
619        Ok(())
620    }
621
622    /// Marks output notes that were erased by same-batch note erasure as consumed.
623    ///
624    /// When a note is created and consumed in the same batch, note erasure removes it from
625    /// the block body. The node reports these as erased output notes in the transaction
626    /// record (note ID only, no inclusion proof). We mark them as consumed.
627    fn mark_erased_notes_as_consumed(
628        state_sync_update: &mut StateSyncUpdate,
629        transaction: &RpcTransactionRecord,
630    ) {
631        for note_header in &transaction.erased_output_notes {
632            // Best-effort: ignore errors for notes not tracked by this client.
633            let _ = state_sync_update
634                .note_updates
635                .mark_erased_note_as_consumed(note_header, transaction.block_num);
636        }
637    }
638
639    /// Compares the state of tracked accounts with the updates received from the node. The method
640    /// Updates the `account_updates` with the details of the accounts that need to be updated.
641    ///
642    /// The account updates might include:
643    /// * Public accounts that have been updated in the node (full or delta-based).
644    /// * Network accounts that have been updated in the node and are being tracked by the client.
645    /// * Private accounts that have been marked as mismatched because the current commitment
646    ///   doesn't match the one received from the node. The client will need to handle these cases
647    ///   as they could be a stale account state or a reason to lock the account.
648    ///
649    /// Returns the local states that were superseded by a same-nonce network transaction; the
650    /// caller must discard the transactions that produced them.
651    async fn account_state_sync(
652        &self,
653        account_updates: &mut AccountUpdates,
654        accounts: &[AccountHeader],
655        account_commitment_updates: &[(AccountId, Word)],
656        block_from: BlockNumber,
657    ) -> Result<Vec<Word>, ClientError> {
658        // "Public" here includes both Public and Network accounts, since both have
659        // their state stored on-chain and follow the same sync path.
660        let (public_accounts, private_accounts): (Vec<_>, Vec<_>) =
661            accounts.iter().partition(|header| !header.id().is_private());
662
663        let superseded_states = self
664            .sync_public_accounts(
665                account_updates,
666                account_commitment_updates,
667                &public_accounts,
668                block_from,
669            )
670            .await?;
671
672        let mismatched_private_accounts = account_commitment_updates
673            .iter()
674            .filter(|(account_id, digest)| {
675                private_accounts
676                    .iter()
677                    .any(|header| header.id() == *account_id && &header.to_commitment() != digest)
678            })
679            .copied()
680            .collect::<Vec<_>>();
681
682        account_updates.extend(AccountUpdates::new(Vec::new(), mismatched_private_accounts));
683
684        Ok(superseded_states)
685    }
686
687    /// Queries the node for updated public accounts and populates `account_updates`.
688    ///
689    /// For each public account whose commitment changed, an updated snapshot is fetched with a
690    /// single `get_account` call that requests every storage map and the vault.
691    ///
692    /// Accounts whose vault or maps are too large to fit in a single response fall back to the
693    /// incremental [`PublicAccountUpdate::Delta`] path, which fetches vault and storage map
694    /// updates over the synced block range.
695    async fn sync_public_accounts(
696        &self,
697        account_updates: &mut AccountUpdates,
698        commitment_updates: &[(AccountId, Word)],
699        current_public_accounts: &[&AccountHeader],
700        block_from: BlockNumber,
701    ) -> Result<Vec<Word>, ClientError> {
702        let local_headers: BTreeMap<AccountId, &AccountHeader> =
703            current_public_accounts.iter().map(|header| (header.id(), *header)).collect();
704        // Local states that lost a same-nonce race; their transactions must be discarded.
705        let mut superseded_states = Vec::new();
706        for (id, commitment) in commitment_updates {
707            let Some(local_header) = local_headers.get(id).copied() else {
708                continue;
709            };
710
711            if local_header.to_commitment() == *commitment {
712                continue;
713            }
714
715            match self.sync_public_account(*id, local_header, block_from).await? {
716                PublicAccountSync::Apply(public_update) => {
717                    account_updates.extend(AccountUpdates::new(vec![*public_update], Vec::new()));
718                },
719                PublicAccountSync::Superseded => {
720                    superseded_states.push(local_header.to_commitment());
721                },
722                PublicAccountSync::Ignore => {},
723            }
724        }
725
726        Ok(superseded_states)
727    }
728
729    // SYNC PUBLIC ACCOUNTS HELPERS
730    // --------------------------------------------------------------------------------------------
731
732    /// Fetches an updated snapshot for a single public account and decides how to reconcile it
733    /// against the local state.
734    ///
735    /// Must only be called when the local commitment for the account is known to differ from the
736    /// network's, so an equal nonce always means a genuine fork.
737    ///
738    /// # Panics
739    ///
740    /// Panics if the node response omits account details, since that would mean the account is
741    /// not public.
742    async fn sync_public_account(
743        &self,
744        account_id: AccountId,
745        local_header: &AccountHeader,
746        block_from: BlockNumber,
747    ) -> Result<PublicAccountSync, ClientError> {
748        // A single request fetches the full snapshot: every storage map's entries plus the vault,
749        // with the storage layout discovered server-side.
750        let (proof_block_num, proof) = self
751            .rpc_api
752            .get_account(
753                account_id,
754                GetAccountRequest::new()
755                    .with_storage(StorageMapFetch::All)
756                    .with_vault(VaultFetch::Always),
757            )
758            .await
759            .map_err(ClientError::RpcError)?;
760
761        let details = proof.into_details().expect("node returned no details for a public account");
762        match details
763            .header
764            .nonce()
765            .as_canonical_u64()
766            .cmp(&local_header.nonce().as_canonical_u64())
767        {
768            // Node is behind us: our own transaction was committed yet (will expire naturally
769            // eventually).
770            Ordering::Less => return Ok(PublicAccountSync::Ignore),
771            // Same height but different state: our transaction definitively lost, drop it.
772            Ordering::Equal => return Ok(PublicAccountSync::Superseded),
773            // Node moved past us: adopt its state, built below.
774            Ordering::Greater => {},
775        }
776
777        let vault_oversized = details.vault_details.too_many_assets;
778        let any_map_oversized =
779            details.storage_details.map_details.iter().any(|m| m.too_many_entries);
780
781        // TODO: we can handle vault and storage-map oversize independently. Today any oversize
782        // routes the whole account through the incremental delta path, which always fetches
783        // both `sync_storage_maps` and `sync_account_vault`, even if not needed.
784        let public_update = if vault_oversized || any_map_oversized {
785            // Some part of the account is oversized — use incremental endpoints.
786            self.build_delta_update(account_id, &details, block_from, proof_block_num)
787                .await?
788        } else {
789            // The single response carries the full vault and every map's entries.
790            let account = Account::try_from(&details).map_err(ClientError::RpcError)?;
791            PublicAccountUpdate::Full(account)
792        };
793
794        Ok(PublicAccountSync::Apply(Box::new(public_update)))
795    }
796
797    /// Builds a [`PublicAccountUpdate::Delta`] by fetching incremental storage map and vault
798    /// updates over the synced range.
799    async fn build_delta_update(
800        &self,
801        account_id: AccountId,
802        details: &AccountDetails,
803        block_from: BlockNumber,
804        block_to: BlockNumber,
805    ) -> Result<PublicAccountUpdate, ClientError> {
806        let value_slot_updates: Vec<(_, Word)> = details
807            .storage_details
808            .header
809            .slots()
810            .filter(|slot| slot.slot_type() == StorageSlotType::Value)
811            .map(|slot| (slot.name().clone(), slot.value()))
812            .collect();
813
814        // The lower bound is inclusive at the node, so request from `block_from + 1` to skip
815        // the block whose state we already have.
816        let map_info = self
817            .rpc_api
818            .sync_storage_maps(block_from + 1, block_to, account_id)
819            .await
820            .map_err(ClientError::RpcError)?;
821        let vault_info = self
822            .rpc_api
823            .sync_account_vault(block_from + 1, block_to, account_id)
824            .await
825            .map_err(ClientError::RpcError)?;
826
827        Ok(PublicAccountUpdate::Delta(PublicAccountDelta::new(
828            details.header.clone(),
829            block_from,
830            block_to,
831            value_slot_updates,
832            map_info.updates,
833            vault_info.updates,
834        )))
835    }
836
837    /// Applies the changes received from the sync response to the notes and transactions tracked
838    /// by the client and updates the `note_updates` accordingly.
839    ///
840    /// This method uses the callbacks provided to the [`StateSync`] component to check if the
841    /// updates received are relevant to the client.
842    ///
843    /// The note updates might include:
844    /// * New notes that we received from the node and might be relevant to the client.
845    /// * Tracked expected notes that were committed in the block.
846    /// * Tracked notes that were being processed by a transaction that got committed.
847    /// * Tracked notes that were nullified by an external transaction.
848    ///
849    /// The `public_notes` parameter provides cached public note details for the current sync
850    /// iteration so the node is only queried once per batch. The `private_attachments` parameter
851    /// carries attachment content resolved for private notes, keyed by note ID; it is joined to
852    /// each committed note by ID so the stored record reconstructs the correct note ID.
853    async fn note_state_sync(
854        &self,
855        note_updates: &mut NoteUpdateTracker,
856        note_inclusions: BTreeMap<NoteId, CommittedNote>,
857        block_header: &BlockHeader,
858        public_notes: &BTreeMap<NoteId, InputNoteRecord>,
859        private_attachments: &BTreeMap<NoteId, NoteAttachments>,
860    ) -> Result<bool, ClientError> {
861        // `found_relevant_note` tracks whether we want to persist the block header in the end
862        let mut found_relevant_note = false;
863
864        for (_, committed_note) in note_inclusions {
865            let public_note = (committed_note.note_type() != NoteType::Private)
866                .then(|| public_notes.get(committed_note.note_id()))
867                .flatten()
868                .cloned();
869
870            // Observers run BEFORE the screener: they are a side-effect
871            // channel independent of the Commit/Insert/Discard decision,
872            // and a failing screener must not rob them of the note. Clone
873            // is skipped when no observers are attached (the common case).
874            if !self.note_observers.is_empty() {
875                // Resolve attachment content for the note from the sync window: public note
876                // bodies carry their attachments on the cached `InputNoteRecord`; private-note
877                // attachments arrive in their own side-table. Both are keyed by note ID.
878                let note_attachments = if committed_note.note_type() == NoteType::Private {
879                    private_attachments.get(committed_note.note_id())
880                } else {
881                    public_note.as_ref().map(InputNoteRecord::attachments)
882                };
883                for obs in &self.note_observers {
884                    match obs.observe(&committed_note, note_attachments).await {
885                        Ok(true) => found_relevant_note = true,
886                        Ok(false) => {},
887                        Err(err) => {
888                            tracing::warn!(
889                                observer = obs.name(),
890                                error = ?err,
891                                "note observer failed; sync continues",
892                            );
893                        },
894                    }
895                }
896            }
897
898            match self.note_screener.on_note_received(committed_note, public_note).await? {
899                NoteUpdateAction::Commit(committed_note) => {
900                    // Only mark the downloaded block header as relevant if we are talking about
901                    // an input note (output notes get marked as committed but we don't need the
902                    // block for anything there)
903                    let attachments = private_attachments.get(committed_note.note_id());
904                    found_relevant_note |= note_updates.apply_committed_note_state_transitions(
905                        &committed_note,
906                        block_header,
907                        attachments,
908                    )?;
909                },
910                NoteUpdateAction::Insert(public_note) => {
911                    found_relevant_note = true;
912
913                    note_updates.apply_new_public_note(public_note, block_header)?;
914                },
915                NoteUpdateAction::Discard => {},
916            }
917        }
918
919        Ok(found_relevant_note)
920    }
921
922    /// Collects the nullifier tags for the notes that were updated in the sync response and uses
923    /// the `sync_nullifiers` endpoint to check if there are new nullifiers for these
924    /// notes. It then processes the nullifiers to apply the state transitions on the note updates.
925    ///
926    /// The `state_sync_update` parameter will be updated to track the new discarded transactions.
927    async fn nullifiers_state_sync(
928        &self,
929        state_sync_update: &mut StateSyncUpdate,
930        current_block_num: BlockNumber,
931    ) -> Result<(), ClientError> {
932        // To receive information about added nullifiers, we reduce them to the higher 16 bits
933        // Note that besides filtering by nullifier prefixes, the node also filters by block number
934        // (it only returns nullifiers from current_block_num + 1 until state_sync_update.block_num)
935
936        // Check for new nullifiers for input notes that were updated
937        let nullifiers_tags: Vec<u16> = state_sync_update
938            .note_updates
939            .unspent_nullifiers()
940            .map(|nullifier| nullifier.prefix())
941            .collect();
942
943        let mut new_nullifiers = self
944            .rpc_api
945            .sync_nullifiers(&nullifiers_tags, current_block_num + 1, state_sync_update.block_num)
946            .await?;
947
948        // Discard nullifiers that are newer than the current block (this might happen if the block
949        // changes between the sync_state and the check_nullifier calls)
950        new_nullifiers.retain(|update| update.block_num <= state_sync_update.block_num);
951
952        // Match each nullifier update with the externally-tracked consumer account.
953        let consumptions: Vec<NoteConsumption> = new_nullifiers
954            .into_iter()
955            .map(|update| NoteConsumption {
956                external_consumer: state_sync_update
957                    .transaction_updates
958                    .external_nullifier_account(&update.nullifier),
959                nullifier: update.nullifier,
960                block_num: update.block_num,
961            })
962            .collect();
963
964        for consumption in consumptions {
965            state_sync_update.note_updates.apply_note_consumption(
966                &consumption,
967                state_sync_update.transaction_updates.committed_transactions(),
968            )?;
969
970            // Process nullifiers and track the updates of local tracked transactions that were
971            // discarded because the notes that they were processing were nullified by an
972            // another transaction.
973            state_sync_update
974                .transaction_updates
975                .apply_input_note_nullified(consumption.nullifier);
976        }
977
978        Ok(())
979    }
980
981    /// Pairs each public note body with the matching inclusion proof from `note_blocks`. Private
982    /// notes and public notes without a matching inclusion proof are dropped.
983    fn build_public_note_records(
984        synced_notes: BTreeMap<NoteId, SyncedNoteDetails>,
985        note_blocks: &[NoteSyncBlock],
986    ) -> BTreeMap<NoteId, InputNoteRecord> {
987        let mut records = BTreeMap::new();
988        for (note_id, synced) in synced_notes {
989            let SyncedNoteDetails::Public(note) = synced else {
990                continue;
991            };
992            let inclusion_proof = note_blocks
993                .iter()
994                .find_map(|b| b.notes.get(&note_id))
995                .map(|committed| committed.inclusion_proof().clone());
996
997            if let Some(inclusion_proof) = inclusion_proof {
998                let state = crate::store::input_note_states::UnverifiedNoteState {
999                    metadata: *note.metadata(),
1000                    inclusion_proof,
1001                }
1002                .into();
1003                let attachments = note.attachments().clone();
1004                let record = InputNoteRecord::new(note.into(), attachments, None, state);
1005                let id = record.id().expect("CommittedNoteState carries metadata, so id() is Some");
1006                records.insert(id, record);
1007            }
1008        }
1009        records
1010    }
1011}
1012
1013// HELPERS
1014// ================================================================================================
1015
1016/// Groups transaction records by `(account_id, block_num)`.
1017fn group_txs_by_account_block(
1018    transaction_records: &[RpcTransactionRecord],
1019) -> BTreeMap<(AccountId, BlockNumber), Vec<&RpcTransactionRecord>> {
1020    let mut groups: BTreeMap<(AccountId, BlockNumber), Vec<&RpcTransactionRecord>> =
1021        BTreeMap::new();
1022    for record in transaction_records {
1023        let account_id = record.transaction_header.account_id();
1024        groups.entry((account_id, record.block_num)).or_default().push(record);
1025    }
1026    groups
1027}
1028
1029/// Walks a group of transaction records in execution order.
1030///
1031/// Same-block transactions for the same account form an execution chain: each tx's
1032/// `final_state_commitment` is the next tx's `initial_state_commitment`. This finds the chain
1033/// start and walks forward, yielding each tx in execution order.
1034fn walk_execution_chain<'a>(
1035    txs: &'a [&'a RpcTransactionRecord],
1036) -> impl Iterator<Item = &'a RpcTransactionRecord> + 'a {
1037    let (self_loops, chained): (Vec<&RpcTransactionRecord>, Vec<&RpcTransactionRecord>) =
1038        txs.iter().copied().partition(|tx| {
1039            tx.transaction_header.initial_state_commitment()
1040                == tx.transaction_header.final_state_commitment()
1041        });
1042
1043    let final_states: BTreeSet<Word> = chained
1044        .iter()
1045        .map(|tx| tx.transaction_header.final_state_commitment())
1046        .collect();
1047
1048    let mut init_to_tx: BTreeMap<Word, &RpcTransactionRecord> = chained
1049        .iter()
1050        .map(|tx| (tx.transaction_header.initial_state_commitment(), *tx))
1051        .collect();
1052
1053    let start = chained
1054        .iter()
1055        .find(|tx| !final_states.contains(&tx.transaction_header.initial_state_commitment()))
1056        .copied();
1057
1058    assert!(start.is_some() || chained.is_empty(), "cannot walk cyclic execution chain");
1059
1060    let mut current =
1061        start.and_then(|tx| init_to_tx.remove(&tx.transaction_header.initial_state_commitment()));
1062    let mut self_loops_iter = self_loops.into_iter();
1063
1064    core::iter::from_fn(move || {
1065        if let Some(tx) = current {
1066            current = init_to_tx.remove(&tx.transaction_header.final_state_commitment());
1067            return Some(tx);
1068        }
1069        self_loops_iter.next()
1070    })
1071}
1072
1073/// Derives account commitment updates from transaction records.
1074///
1075/// For each unique account, returns the `final_state_commitment` from the final transaction with
1076/// the highest `block_num`.
1077fn derive_account_commitments(
1078    transaction_records: &[RpcTransactionRecord],
1079) -> Vec<(AccountId, Word)> {
1080    let mut latest_by_account: BTreeMap<AccountId, (BlockNumber, Word)> = BTreeMap::new();
1081
1082    for ((account_id, block_num), txs) in &group_txs_by_account_block(transaction_records) {
1083        let terminal_state = walk_execution_chain(txs)
1084            .last()
1085            .expect("account must have a final state")
1086            .transaction_header
1087            .final_state_commitment();
1088
1089        latest_by_account
1090            .entry(*account_id)
1091            .and_modify(|(existing_block, existing_state)| {
1092                if *block_num > *existing_block {
1093                    *existing_block = *block_num;
1094                    *existing_state = terminal_state;
1095                }
1096            })
1097            .or_insert((*block_num, terminal_state));
1098    }
1099
1100    latest_by_account
1101        .into_iter()
1102        .map(|(account_id, (_, state))| (account_id, state))
1103        .collect()
1104}
1105
1106/// Returns nullifiers ordered by consuming transaction position, per account.
1107///
1108/// Groups RPC transaction records by (`account_id`, `block_num`), chains them using
1109/// `initial_state_commitment` / `final_state_commitment`, and collects each transaction's
1110/// input note nullifiers in execution order. Nullifiers from the same account are in execution
1111/// order; ordering across different accounts is arbitrary.
1112fn compute_ordered_nullifiers(transaction_records: &[RpcTransactionRecord]) -> Vec<Nullifier> {
1113    let mut result = Vec::new();
1114
1115    for txs in group_txs_by_account_block(transaction_records).values() {
1116        for tx in walk_execution_chain(txs) {
1117            for commitment in tx.transaction_header.input_notes().iter() {
1118                result.push(commitment.nullifier());
1119            }
1120        }
1121    }
1122
1123    result
1124}
1125
1126#[cfg(all(test, feature = "testing"))]
1127mod tests {
1128    use alloc::collections::BTreeSet;
1129    use alloc::sync::Arc;
1130
1131    use async_trait::async_trait;
1132    use miden_protocol::account::Account;
1133    use miden_protocol::assembly::DefaultSourceManager;
1134    use miden_protocol::asset::{Asset, FungibleAsset};
1135    use miden_protocol::block::BlockNumber;
1136    use miden_protocol::crypto::merkle::MerklePath;
1137    use miden_protocol::crypto::merkle::mmr::{Forest, InOrderIndex, PartialMmr};
1138    use miden_protocol::note::{
1139        Note,
1140        NoteAssets,
1141        NoteAttachment,
1142        NoteAttachments,
1143        NoteDetails,
1144        NoteHeader,
1145        NoteMetadata,
1146        NoteRecipient,
1147        NoteStorage,
1148        NoteTag,
1149        NoteType,
1150        PartialNoteMetadata,
1151    };
1152    use miden_protocol::testing::account_id::{
1153        ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET,
1154        ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
1155        ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE,
1156        ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
1157        ACCOUNT_ID_SENDER,
1158    };
1159    use miden_protocol::transaction::{InputNotes, TransactionArgs, TransactionHeader};
1160    use miden_protocol::vm::AdviceMap;
1161    use miden_protocol::{EMPTY_WORD, Felt, Word, ZERO};
1162    use miden_standards::code_builder::CodeBuilder;
1163    use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint};
1164    use miden_testing::{MockChainBuilder, TxContextInput};
1165
1166    use super::*;
1167    use crate::rpc::domain::transaction::ACCOUNT_ID_NATIVE_ASSET_FAUCET;
1168    use crate::store::{OutputNoteRecord, OutputNoteState};
1169    use crate::test_utils::mock::MockRpcApi;
1170
1171    /// Mock note screener that discards all notes, for minimal test setup.
1172    struct MockScreener;
1173
1174    #[async_trait(?Send)]
1175    impl OnNoteReceived for MockScreener {
1176        async fn on_note_received(
1177            &self,
1178            _committed_note: CommittedNote,
1179            _public_note: Option<InputNoteRecord>,
1180        ) -> Result<NoteUpdateAction, ClientError> {
1181            Ok(NoteUpdateAction::Discard)
1182        }
1183    }
1184
1185    fn empty() -> StateSyncInput {
1186        StateSyncInput {
1187            accounts: vec![],
1188            note_tags: BTreeSet::new(),
1189            input_notes: vec![],
1190            output_notes: vec![],
1191            uncommitted_transactions: vec![],
1192        }
1193    }
1194
1195    fn word(n: u64) -> miden_protocol::Word {
1196        [
1197            Felt::new(n).expect("test value should fit into the base field"),
1198            ZERO,
1199            ZERO,
1200            ZERO,
1201        ]
1202        .into()
1203    }
1204
1205    #[tokio::test]
1206    async fn sync_public_accounts_ignores_older_node_snapshot() {
1207        let mut builder = MockChainBuilder::new();
1208        let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1209        let rpc_api = MockRpcApi::new(builder.build().unwrap());
1210        let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1211
1212        // Local state is at a higher nonce than the node's snapshot (our own tx isn't committed
1213        // there yet), so the node snapshot must be ignored.
1214        let local_header =
1215            AccountHeader::new(account.id(), Felt::from(2u32), EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
1216        let current_public_accounts = vec![&local_header];
1217        let commitment_updates = vec![(account.id(), account.to_commitment())];
1218        let mut account_updates = AccountUpdates::default();
1219
1220        let superseded = state_sync
1221            .sync_public_accounts(
1222                &mut account_updates,
1223                &commitment_updates,
1224                &current_public_accounts,
1225                BlockNumber::GENESIS,
1226            )
1227            .await
1228            .unwrap();
1229
1230        assert!(
1231            account_updates.updated_public_accounts().is_empty(),
1232            "public account sync should ignore node snapshots that are older than local"
1233        );
1234        assert!(
1235            superseded.is_empty(),
1236            "an older node snapshot must not supersede the local state"
1237        );
1238    }
1239
1240    #[tokio::test]
1241    async fn sync_public_accounts_marks_same_nonce_mismatch_as_superseded() {
1242        let mut builder = MockChainBuilder::new();
1243        let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1244        let rpc_api = MockRpcApi::new(builder.build().unwrap());
1245        let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1246
1247        // Local state is at the same nonce as the node's but with a different commitment: a fork
1248        // where the local transaction lost the race and must be discarded.
1249        let local_header =
1250            AccountHeader::new(account.id(), account.nonce(), EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
1251        let current_public_accounts = vec![&local_header];
1252        let commitment_updates = vec![(account.id(), account.to_commitment())];
1253        let mut account_updates = AccountUpdates::default();
1254
1255        let superseded = state_sync
1256            .sync_public_accounts(
1257                &mut account_updates,
1258                &commitment_updates,
1259                &current_public_accounts,
1260                BlockNumber::GENESIS,
1261            )
1262            .await
1263            .unwrap();
1264
1265        assert!(
1266            account_updates.updated_public_accounts().is_empty(),
1267            "a same-nonce fork must not overwrite the account while its tx is still pending"
1268        );
1269        assert_eq!(
1270            superseded,
1271            vec![local_header.to_commitment()],
1272            "the superseded local state should be reported so its transaction is discarded"
1273        );
1274    }
1275
1276    // COMPUTE NULLIFIER TX ORDER TESTS
1277    // --------------------------------------------------------------------------------------------
1278
1279    mod compute_nullifiers_tests {
1280        use alloc::vec;
1281
1282        use miden_protocol::asset::FungibleAsset;
1283        use miden_protocol::block::BlockNumber;
1284        use miden_protocol::note::Nullifier;
1285        use miden_protocol::transaction::{InputNoteCommitment, InputNotes, TransactionHeader};
1286
1287        use super::word;
1288        use crate::rpc::domain::transaction::{
1289            ACCOUNT_ID_NATIVE_ASSET_FAUCET,
1290            TransactionRecord as RpcTransactionRecord,
1291        };
1292
1293        fn make_rpc_tx(
1294            init_state: u64,
1295            final_state: u64,
1296            nullifier_vals: &[u64],
1297            block_number: u32,
1298        ) -> RpcTransactionRecord {
1299            let account_id = miden_protocol::account::AccountId::try_from(
1300                miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE,
1301            )
1302            .unwrap();
1303
1304            let input_notes = InputNotes::new_unchecked(
1305                nullifier_vals
1306                    .iter()
1307                    .map(|v| InputNoteCommitment::from(Nullifier::from_raw(word(*v))))
1308                    .collect(),
1309            );
1310
1311            let fee =
1312                FungibleAsset::new(ACCOUNT_ID_NATIVE_ASSET_FAUCET.try_into().expect("valid"), 0u64)
1313                    .unwrap();
1314
1315            RpcTransactionRecord {
1316                block_num: BlockNumber::from(block_number),
1317                transaction_header: TransactionHeader::new(
1318                    account_id,
1319                    word(init_state),
1320                    word(final_state),
1321                    input_notes,
1322                    vec![],
1323                    fee,
1324                ),
1325                output_notes: vec![],
1326                erased_output_notes: vec![],
1327            }
1328        }
1329
1330        #[test]
1331        fn chains_rpc_transactions_by_state_commitment() {
1332            // Chain: tx_a (state 1->2) -> tx_b (state 2->3) -> tx_c (state 3->4)
1333            // Passed in reverse order to verify chaining uses state, not insertion order.
1334            let tx_a = make_rpc_tx(1, 2, &[10], 5);
1335            let tx_b = make_rpc_tx(2, 3, &[20], 5);
1336            let tx_c = make_rpc_tx(3, 4, &[30], 5);
1337
1338            let result = super::super::compute_ordered_nullifiers(&[tx_c, tx_a, tx_b]);
1339
1340            assert_eq!(result[0], Nullifier::from_raw(word(10)));
1341            assert_eq!(result[1], Nullifier::from_raw(word(20)));
1342            assert_eq!(result[2], Nullifier::from_raw(word(30)));
1343        }
1344
1345        #[test]
1346        fn groups_independently_by_account_and_block() {
1347            // Account A, block 5: two chained txs.
1348            let tx_a1 = make_rpc_tx(1, 2, &[10], 5);
1349            let tx_a2 = make_rpc_tx(2, 3, &[20], 5);
1350
1351            // Account A, block 6: independent chain.
1352            let tx_a3 = make_rpc_tx(3, 4, &[30], 6);
1353
1354            // Account B, block 5: independent chain.
1355            let account_b = miden_protocol::account::AccountId::try_from(
1356                miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
1357            )
1358            .unwrap();
1359
1360            let fee =
1361                FungibleAsset::new(ACCOUNT_ID_NATIVE_ASSET_FAUCET.try_into().expect("valid"), 0u64)
1362                    .unwrap();
1363
1364            let tx_b1 = RpcTransactionRecord {
1365                block_num: BlockNumber::from(5u32),
1366                transaction_header: TransactionHeader::new(
1367                    account_b,
1368                    word(100),
1369                    word(200),
1370                    InputNotes::new_unchecked(vec![InputNoteCommitment::from(
1371                        Nullifier::from_raw(word(40)),
1372                    )]),
1373                    vec![],
1374                    fee,
1375                ),
1376                output_notes: vec![],
1377                erased_output_notes: vec![],
1378            };
1379
1380            let result = super::super::compute_ordered_nullifiers(&[tx_a2, tx_b1, tx_a3, tx_a1]);
1381
1382            // Nullifiers are ordered by chain position within each (account, block) group.
1383            // The exact global indices depend on BTreeMap iteration order of the groups.
1384            let pos = |val: u64| -> usize {
1385                result.iter().position(|n| *n == Nullifier::from_raw(word(val))).unwrap()
1386            };
1387
1388            // Within the same group, chain order is preserved.
1389            assert!(pos(10) < pos(20)); // A, block 5: pos 0 < pos 1
1390            // Nullifiers from different groups are all present.
1391            assert!(result.contains(&Nullifier::from_raw(word(30)))); // A, block 6
1392            assert!(result.contains(&Nullifier::from_raw(word(40)))); // B, block 5
1393        }
1394
1395        #[test]
1396        fn multiple_nullifiers_per_transaction_are_consecutive() {
1397            // Single tx consuming 3 notes — all should appear consecutively.
1398            let tx = make_rpc_tx(1, 2, &[10, 20, 30], 5);
1399
1400            let result = super::super::compute_ordered_nullifiers(&[tx]);
1401
1402            assert_eq!(result.len(), 3);
1403            assert!(result.contains(&Nullifier::from_raw(word(10))));
1404            assert!(result.contains(&Nullifier::from_raw(word(20))));
1405            assert!(result.contains(&Nullifier::from_raw(word(30))));
1406        }
1407
1408        #[test]
1409        fn empty_input_returns_empty_vec() {
1410            let result = super::super::compute_ordered_nullifiers(&[]);
1411            assert!(result.is_empty());
1412        }
1413    }
1414
1415    // DERIVE ACCOUNT COMMITMENTS TESTS
1416    // --------------------------------------------------------------------------------------------
1417
1418    /// `derive_account_commitments` must walk the execution chain to get the final
1419    /// commitment when several transactions for the same account land in the same block.
1420    ///
1421    /// Test scenario:
1422    /// - Account A, block 5: chain 1 - 2 - 3 (older group; must be dominated by block 6).
1423    /// - Account A, block 6: chain 3 - 4 - 5 (final state = 5).
1424    /// - Account B, block 6: single tx 10 - 20 (final state = 20).
1425    #[test]
1426    fn derive_account_commitments_walks_chains_per_account() {
1427        let fee =
1428            FungibleAsset::new(ACCOUNT_ID_NATIVE_ASSET_FAUCET.try_into().expect("valid"), 0u64)
1429                .unwrap();
1430        let make_tx = |account: AccountId, init_state: u64, final_state: u64, block_num: u32| {
1431            RpcTransactionRecord {
1432                block_num: BlockNumber::from(block_num),
1433                transaction_header: TransactionHeader::new(
1434                    account,
1435                    word(init_state),
1436                    word(final_state),
1437                    InputNotes::new_unchecked(vec![]),
1438                    vec![],
1439                    fee,
1440                ),
1441                output_notes: vec![],
1442                erased_output_notes: vec![],
1443            }
1444        };
1445
1446        let account_a: AccountId =
1447            ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE.try_into().unwrap();
1448        let account_b: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap();
1449
1450        let tx_a_b5_1 = make_tx(account_a, 1, 2, 5);
1451        let tx_a_b5_2 = make_tx(account_a, 2, 3, 5);
1452        let tx_a_b6_1 = make_tx(account_a, 3, 4, 6);
1453        let tx_a_b6_2 = make_tx(account_a, 4, 5, 6);
1454        let tx_b_b6 = make_tx(account_b, 10, 20, 6);
1455
1456        // Insert transactions not ordered by execution order.
1457        let result = super::derive_account_commitments(&[
1458            tx_a_b6_1, tx_b_b6, tx_a_b5_2, tx_a_b6_2, tx_a_b5_1,
1459        ]);
1460
1461        assert_eq!(result.len(), 2, "one entry per account");
1462        assert!(
1463            result.contains(&(account_a, word(5))),
1464            "account A: must walk block 6's chain, not return block 5 or an intermediate",
1465        );
1466        assert!(
1467            result.contains(&(account_b, word(20))),
1468            "account B: must be resolved independently of account A",
1469        );
1470    }
1471
1472    // CONSUMED NOTE ORDERING INTEGRATION TESTS
1473    // --------------------------------------------------------------------------------------------
1474
1475    /// Mock note screener that commits all notes matching tracked input notes.
1476    /// This ensures committed notes get their inclusion proofs set during sync.
1477    struct CommitAllScreener;
1478
1479    #[async_trait(?Send)]
1480    impl OnNoteReceived for CommitAllScreener {
1481        async fn on_note_received(
1482            &self,
1483            committed_note: CommittedNote,
1484            _public_note: Option<InputNoteRecord>,
1485        ) -> Result<NoteUpdateAction, ClientError> {
1486            Ok(NoteUpdateAction::Commit(committed_note))
1487        }
1488    }
1489
1490    /// Builds a `MockChain` where 3 notes are consumed by chained transactions in the same block.
1491    ///
1492    /// Returns the chain, the account, and the 3 notes (in consumption order).
1493    async fn build_chain_with_chained_consume_txs() -> (miden_testing::MockChain, Account, [Note; 3])
1494    {
1495        let sender_id: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
1496        let faucet_id: AccountId = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into().unwrap();
1497
1498        let mut builder = MockChainBuilder::new();
1499        let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1500        let account_id = account.id();
1501
1502        let asset = Asset::Fungible(FungibleAsset::new(faucet_id, 100u64).unwrap());
1503        let note1 = builder
1504            .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
1505            .unwrap();
1506        let note2 = builder
1507            .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
1508            .unwrap();
1509        let note3 = builder
1510            .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
1511            .unwrap();
1512
1513        let mut chain = builder.build().unwrap();
1514        chain.prove_next_block().unwrap(); // block 1: makes genesis notes consumable
1515
1516        // Execute 3 chained consume transactions (state S0→S1→S2→S3).
1517        let mut current_account = account.clone();
1518        for note in [&note1, &note2, &note3] {
1519            let tx = Box::pin(
1520                chain
1521                    .build_tx_context(
1522                        TxContextInput::Account(current_account.clone()),
1523                        &[],
1524                        core::slice::from_ref(note),
1525                    )
1526                    .unwrap()
1527                    .build()
1528                    .unwrap()
1529                    .execute(),
1530            )
1531            .await
1532            .unwrap();
1533            current_account.apply_delta(tx.account_delta()).unwrap();
1534            chain.add_pending_executed_transaction(&tx).unwrap();
1535        }
1536
1537        chain.prove_next_block().unwrap(); // block 2: all 3 txs in one block
1538        (chain, account, [note1, note2, note3])
1539    }
1540
1541    /// Verifies that `consumed_tx_order` is correctly set when multiple chained transactions
1542    /// for the same account consume notes in the same block.
1543    #[tokio::test]
1544    async fn sync_state_sets_consumed_tx_order_for_chained_transactions() {
1545        use miden_protocol::note::NoteMetadata;
1546
1547        let (chain, account, [note1, note2, note3]) = build_chain_with_chained_consume_txs().await;
1548
1549        let mock_rpc = MockRpcApi::new(chain);
1550        let state_sync =
1551            StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(CommitAllScreener), None);
1552
1553        let genesis_peaks =
1554            mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
1555        let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
1556
1557        let input_notes: Vec<InputNoteRecord> = [&note1, &note2, &note3]
1558            .into_iter()
1559            .map(|n| InputNoteRecord::from(n.clone()))
1560            .collect();
1561
1562        let note_tags: BTreeSet<NoteTag> =
1563            input_notes.iter().filter_map(|n| n.metadata().map(NoteMetadata::tag)).collect();
1564
1565        let account_id = account.id();
1566        let sync_input = StateSyncInput {
1567            accounts: vec![AccountHeader::from(account)],
1568            note_tags,
1569            input_notes,
1570            output_notes: vec![],
1571            uncommitted_transactions: vec![],
1572        };
1573
1574        let update = state_sync.sync_state(&mut partial_mmr, sync_input).await.unwrap();
1575
1576        let updated_notes: Vec<_> = update.note_updates.updated_input_notes().collect();
1577
1578        let find_order = |details_commitment| -> Option<u32> {
1579            updated_notes
1580                .iter()
1581                .find(|n| n.inner().details_commitment() == details_commitment)
1582                .and_then(|n| n.consumed_tx_order())
1583        };
1584
1585        assert_eq!(find_order(note1.details_commitment()), Some(0), "note1 should have tx_order 0");
1586        assert_eq!(find_order(note2.details_commitment()), Some(1), "note2 should have tx_order 1");
1587        assert_eq!(find_order(note3.details_commitment()), Some(2), "note3 should have tx_order 2");
1588
1589        // Since there are no uncommitted_transactions, these notes were consumed by a tracked
1590        // account via external transactions. Verify that consumer_account is populated.
1591        for note in &updated_notes {
1592            let record = note.inner();
1593            assert!(record.is_consumed(), "note should be in a consumed state");
1594            assert_eq!(
1595                record.consumer_account(),
1596                Some(account_id),
1597                "externally-consumed notes by a tracked account should have consumer_account set",
1598            );
1599        }
1600    }
1601
1602    #[tokio::test]
1603    async fn sync_state_across_multiple_iterations_with_same_mmr() {
1604        // Setup: create a mock chain and advance it so there are blocks to sync.
1605        let mock_rpc = MockRpcApi::default();
1606        mock_rpc.advance_blocks(3);
1607        let chain_tip_1 = mock_rpc.get_chain_tip_block_num();
1608
1609        let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
1610
1611        // Build the initial PartialMmr from genesis (only 1 leaf).
1612        let genesis_peaks =
1613            mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
1614        let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
1615        assert_eq!(partial_mmr.forest().num_leaves(), 1);
1616
1617        // First sync
1618        let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
1619
1620        assert_eq!(update.block_num, chain_tip_1);
1621        let forest_1 = partial_mmr.forest();
1622        // The MMR should contain one leaf per block (genesis + the new blocks).
1623        assert_eq!(forest_1.num_leaves(), chain_tip_1.as_u32() as usize + 1);
1624
1625        // Second sync
1626        mock_rpc.advance_blocks(2);
1627        let chain_tip_2 = mock_rpc.get_chain_tip_block_num();
1628
1629        let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
1630
1631        assert_eq!(update.block_num, chain_tip_2);
1632        let forest_2 = partial_mmr.forest();
1633        assert!(forest_2 > forest_1);
1634        assert_eq!(forest_2.num_leaves(), chain_tip_2.as_u32() as usize + 1);
1635
1636        // Third sync (no new blocks)
1637        let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
1638
1639        assert_eq!(update.block_num, chain_tip_2);
1640        assert_eq!(partial_mmr.forest(), forest_2);
1641    }
1642
1643    /// Builds a mock chain with a faucet that mints `num_blocks` notes, one per block.
1644    /// Returns the chain and the set of note tags for filtering.
1645    async fn build_chain_with_mint_notes(
1646        num_blocks: u64,
1647    ) -> (miden_testing::MockChain, BTreeSet<NoteTag>) {
1648        let mut builder = MockChainBuilder::new();
1649        let faucet = builder
1650            .add_existing_basic_faucet(
1651                miden_testing::Auth::BasicAuth {
1652                    auth_scheme: miden_protocol::account::auth::AuthScheme::Falcon512Poseidon2,
1653                },
1654                "TST",
1655                10_000,
1656                None,
1657            )
1658            .unwrap();
1659        let _target = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1660        let mut chain = builder.build().unwrap();
1661
1662        // Build a real recipient so its digest has a registered preimage in the advice map;
1663        // `mint_and_send` → `output_note::create` emits `NOTE_BEFORE_CREATED_EVENT`, whose host
1664        // handler decomposes the recipient digest through the advice map and fails with
1665        // `MalformedRecipientData` if the preimage isn't present.
1666        let note_script = CodeBuilder::new()
1667            .compile_note_script("@note_script\npub proc main\n    nop\nend")
1668            .unwrap();
1669        let note_recipient = NoteRecipient::new(
1670            Word::from([1u32, 2, 3, 4]),
1671            note_script,
1672            NoteStorage::new(vec![]).unwrap(),
1673        );
1674        let recipient = note_recipient.digest();
1675        // `add_output_note_recipient` populates the advice map with the recipient's preimage
1676        // chain (RECIPIENT → [SERIAL_SCRIPT_HASH, STORAGE_COMMITMENT], etc.).
1677        let note_details = NoteDetails::new(NoteAssets::new(vec![]).unwrap(), note_recipient);
1678        let mut recipient_args = TransactionArgs::new(AdviceMap::default());
1679        recipient_args.add_output_note_recipient(&note_details);
1680        let recipient_advice = recipient_args.advice_inputs().clone();
1681
1682        let tag = NoteTag::default();
1683        let mut faucet_account = faucet.clone();
1684        let mut note_tags = BTreeSet::new();
1685
1686        for i in 0..num_blocks {
1687            let amount = 100 + i;
1688            let source_manager = Arc::new(DefaultSourceManager::default());
1689            // Derive the asset key/value in MASM via `create_fungible_asset` (mirroring the
1690            // protocol's own faucet tests) so the callback flag matches what `mint_and_send`
1691            // derives internally. `add_existing_basic_faucet` registers transfer policies, so
1692            // the faucet has callbacks enabled (`push.1`). The new `mint_and_send` signature is
1693            // `[ASSET_KEY, ASSET_VALUE, tag, note_type, RECIPIENT, pad(2)]`.
1694            let tx_script_code = format!(
1695                "
1696                begin
1697                    push.{recipient}
1698                    push.{note_type}
1699                    push.{tag}
1700                    push.{amount}
1701                    push.{faucet_id_prefix}
1702                    push.{faucet_id_suffix}
1703                    push.1
1704                    exec.::miden::protocol::asset::create_fungible_asset
1705                    call.::miden::standards::faucets::fungible::mint_and_send
1706                    dropw dropw dropw dropw
1707                end
1708                ",
1709                recipient = recipient,
1710                note_type = NoteType::Private as u8,
1711                tag = u32::from(tag),
1712                amount = amount,
1713                faucet_id_prefix = faucet_account.id().prefix().as_felt(),
1714                faucet_id_suffix = faucet_account.id().suffix(),
1715            );
1716            let tx_script = CodeBuilder::with_source_manager(source_manager.clone())
1717                .compile_tx_script(tx_script_code)
1718                .unwrap();
1719            let tx = Box::pin(
1720                chain
1721                    .build_tx_context(
1722                        miden_testing::TxContextInput::Account(faucet_account.clone()),
1723                        &[],
1724                        &[],
1725                    )
1726                    .unwrap()
1727                    .extend_advice_inputs(recipient_advice.clone())
1728                    .tx_script(tx_script)
1729                    .with_source_manager(source_manager)
1730                    .build()
1731                    .unwrap()
1732                    .execute(),
1733            )
1734            .await
1735            .unwrap();
1736
1737            for output_note in tx.output_notes().iter() {
1738                note_tags.insert(output_note.metadata().tag());
1739            }
1740
1741            faucet_account.apply_delta(tx.account_delta()).unwrap();
1742            chain.add_pending_executed_transaction(&tx).unwrap();
1743            chain.prove_next_block().unwrap();
1744        }
1745
1746        (chain, note_tags)
1747    }
1748
1749    /// Verifies that the sync correctly processes notes committed in multiple blocks
1750    /// (batched `SyncNotes` response) and tracks their blocks in the partial MMR.
1751    ///
1752    /// This test creates a faucet and mints notes in separate blocks (blocks 1, 2, 3),
1753    /// so `sync_notes` returns multiple `NoteSyncBlock`s. It then verifies:
1754    /// - The MMR is advanced to the chain tip
1755    /// - Blocks containing relevant notes are tracked in the partial MMR via `track()`
1756    /// - Note inclusion proofs are set correctly
1757    /// - Block headers for note blocks are stored
1758    #[tokio::test]
1759    async fn sync_state_tracks_note_blocks_in_mmr() {
1760        let (chain, note_tags) = build_chain_with_mint_notes(3).await;
1761        let mock_rpc = MockRpcApi::new(chain);
1762        let chain_tip = mock_rpc.get_chain_tip_block_num();
1763
1764        // Verify the mock returns notes across multiple blocks.
1765        let note_blocks = mock_rpc
1766            .sync_notes(BlockNumber::from(0u32), chain_tip, &note_tags)
1767            .await
1768            .unwrap();
1769        assert!(
1770            note_blocks.len() >= 2,
1771            "expected notes in multiple blocks, got {}",
1772            note_blocks.len()
1773        );
1774
1775        // Collect the block numbers that have notes.
1776        let note_block_nums: BTreeSet<BlockNumber> =
1777            note_blocks.iter().map(|b| b.block_header.block_num()).collect();
1778
1779        // Test that fetch_sync_data returns note blocks with valid MMR paths that
1780        // can be used to track blocks in the partial MMR.
1781        let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
1782
1783        let genesis_peaks =
1784            mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
1785        let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
1786
1787        let sync_data = state_sync
1788            .fetch_sync_data(BlockNumber::GENESIS, &[], &Arc::new(note_tags.clone()))
1789            .await
1790            .unwrap()
1791            .expect("should have progressed past genesis");
1792
1793        // Should have advanced to the chain tip.
1794        assert_eq!(sync_data.chain_tip_header.block_num(), chain_tip);
1795        assert!(!sync_data.note_blocks.is_empty(), "should have note blocks");
1796
1797        // Apply the MMR delta and add the chain tip block.
1798        let _auth_nodes: Vec<(InOrderIndex, Word)> =
1799            partial_mmr.apply(sync_data.mmr_delta).map_err(StoreError::MmrError).unwrap();
1800        partial_mmr
1801            .add(sync_data.chain_tip_header.commitment(), false)
1802            .expect("chain tip should append to the partial MMR");
1803
1804        assert_eq!(partial_mmr.forest().num_leaves(), chain_tip.as_u32() as usize + 1);
1805
1806        // Track each note block using the MMR path from the sync_notes response.
1807        for block in &sync_data.note_blocks {
1808            let bn = block.block_header.block_num();
1809            partial_mmr
1810                .track(bn.as_usize(), block.block_header.commitment(), &block.mmr_path)
1811                .map_err(StoreError::MmrError)
1812                .unwrap();
1813
1814            assert!(
1815                partial_mmr.is_tracked(bn.as_usize()),
1816                "block {bn} should be tracked after calling track()"
1817            );
1818        }
1819
1820        // Verify the tracked blocks match the note blocks.
1821        for &bn in &note_block_nums {
1822            assert!(
1823                partial_mmr.is_tracked(bn.as_usize()),
1824                "block {bn} with notes should be tracked in partial MMR"
1825            );
1826        }
1827    }
1828
1829    #[tokio::test]
1830    async fn sync_notes_with_details_fetches_inclusive_upper_bound_page() {
1831        let (chain, note_tags) = build_chain_with_mint_notes(10).await;
1832        let mock_rpc = MockRpcApi::new(chain);
1833
1834        let (blocks, _synced_notes) = mock_rpc
1835            .sync_notes_with_details(4_u32.into(), 10_u32.into(), &note_tags)
1836            .await
1837            .expect("sync notes should succeed");
1838
1839        assert_eq!(blocks.last().unwrap().block_header.block_num(), BlockNumber::from(10u32));
1840        assert!(
1841            blocks
1842                .iter()
1843                .any(|block| block.block_header.block_num() == BlockNumber::from(9u32))
1844        );
1845    }
1846
1847    /// Tests that erased notes are marked as consumed when a committed transaction
1848    /// reports output notes that were erased by same-batch note erasure.
1849    ///
1850    /// This simulates same-batch note erasure: the transaction was committed, its header
1851    /// says it produced a note, but the note was erased and doesn't exist on the node.
1852    #[tokio::test]
1853    async fn erased_notes_are_marked_as_consumed() {
1854        // Create a public output note. It won't be in the mock chain (simulating erasure).
1855        let sender_id: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
1856        let partial_metadata = PartialNoteMetadata::new(sender_id, NoteType::Public);
1857        let metadata = NoteMetadata::new(partial_metadata, &NoteAttachments::empty());
1858        let script = CodeBuilder::new()
1859            .compile_note_script("@note_script\npub proc main\n    nop\nend")
1860            .unwrap();
1861        let recipient = NoteRecipient::new(
1862            Word::from([1u32, 2, 3, 4]),
1863            script,
1864            NoteStorage::new(vec![]).unwrap(),
1865        );
1866        let output_note = OutputNoteRecord::new(
1867            recipient.digest(),
1868            NoteAssets::new(vec![]).unwrap(),
1869            metadata,
1870            OutputNoteState::ExpectedFull { recipient },
1871            BlockNumber::from(1u32),
1872            NoteAttachments::default(),
1873        );
1874        let note_id = output_note.id();
1875        let note_header = NoteHeader::new(output_note.details_commitment(), metadata);
1876
1877        // Build a NoteUpdateTracker with the output note.
1878        let mut note_updates = NoteUpdateTracker::new(vec![], vec![output_note]);
1879
1880        // Mark the note as erased (created and consumed in the same batch).
1881        let block_num = BlockNumber::from(3u32);
1882        note_updates
1883            .mark_erased_note_as_consumed(&note_header, block_num)
1884            .expect("marking erased note should succeed");
1885
1886        let updated = note_updates
1887            .updated_output_notes()
1888            .find(|n| n.id() == note_id)
1889            .expect("output note should be in the update");
1890
1891        assert!(
1892            updated.inner().is_consumed(),
1893            "output note should be consumed after erasure detection, but state is: {}",
1894            updated.inner().state()
1895        );
1896    }
1897
1898    /// Tests that erased notes targeting a tracked network account are marked as consumed
1899    /// by that account through the full sync flow.
1900    ///
1901    /// Same-batch erasure scenario: a sender's transaction creates an output note
1902    /// targeting a network account that consumes it in the same batch, so the note never
1903    /// appears in the block body and the mock RPC surfaces it as erased in the
1904    /// transaction sync response.
1905    ///
1906    /// When the client tracks the network account, the expected end state is that an
1907    /// input note record is created for the erased note in a consumed state with the
1908    /// network account as the consumer.
1909    ///
1910    /// Ignored because the consumer extraction from an erased note's attachments is no
1911    /// longer wired through `mark_erased_note_as_consumed` — the RPC sync stream delivers
1912    /// only a bare `NoteHeader`, so the consumer is left unknown. Re-enable once attachments
1913    /// are delivered alongside erased notes (or the test is reworked against the new model).
1914    #[allow(clippy::too_many_lines)]
1915    #[ignore = "consumer derivation removed; see comment above"]
1916    #[tokio::test]
1917    async fn erased_notes_are_marked_as_consumed_by_network_account() {
1918        // Build a chain with a sender that executes one tx so `sync_transactions` returns
1919        // a record. The mock attaches the registered erased note header to that record.
1920        let mut builder = MockChainBuilder::new();
1921        let p2id_sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
1922        let faucet_id: AccountId = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into().unwrap();
1923        let sender_account =
1924            builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1925        let sender_id = sender_account.id();
1926
1927        let asset = Asset::Fungible(FungibleAsset::new(faucet_id, 100u64).unwrap());
1928        let note = builder
1929            .add_p2id_note(p2id_sender, sender_id, &[asset], NoteType::Public)
1930            .unwrap();
1931
1932        let mut chain = builder.build().unwrap();
1933        chain.prove_next_block().unwrap();
1934
1935        let tx = Box::pin(
1936            chain
1937                .build_tx_context(
1938                    TxContextInput::Account(sender_account.clone()),
1939                    &[],
1940                    core::slice::from_ref(&note),
1941                )
1942                .unwrap()
1943                .build()
1944                .unwrap()
1945                .execute(),
1946        )
1947        .await
1948        .unwrap();
1949        chain.add_pending_executed_transaction(&tx).unwrap();
1950        chain.prove_next_block().unwrap();
1951
1952        // Construct the erased note that will be marked as consumed by the network account.
1953        let network_account_id: AccountId =
1954            ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into().unwrap();
1955        let target =
1956            NetworkAccountTarget::new(network_account_id, NoteExecutionHint::Always).unwrap();
1957        let attachment: NoteAttachment = target.into();
1958        let attachments = NoteAttachments::new(vec![attachment]).unwrap();
1959        let partial_metadata = PartialNoteMetadata::new(sender_id, NoteType::Public);
1960        let metadata = NoteMetadata::new(partial_metadata, &attachments);
1961        let script = CodeBuilder::new()
1962            .compile_note_script("@note_script\npub proc main\n    nop\nend")
1963            .unwrap();
1964        let recipient = NoteRecipient::new(
1965            Word::from([7u32, 8, 9, 10]),
1966            script,
1967            NoteStorage::new(vec![]).unwrap(),
1968        );
1969        let recipient_digest = recipient.digest();
1970        let assets = NoteAssets::new(vec![]).unwrap();
1971
1972        // Output note record tracked by the sender prior to sync. The flow that builds the
1973        // input record from the erased header relies on this output entry being present.
1974        let output_note = OutputNoteRecord::new(
1975            recipient_digest,
1976            assets.clone(),
1977            metadata,
1978            OutputNoteState::ExpectedFull { recipient },
1979            BlockNumber::from(1u32),
1980            NoteAttachments::default(),
1981        );
1982        let erased_note_id = output_note.id();
1983        let erased_note_header = NoteHeader::new(output_note.details_commitment(), metadata);
1984
1985        let mock_rpc = MockRpcApi::new(chain);
1986        mock_rpc.mark_note_as_erased(erased_note_header);
1987
1988        // Track both the sender (so its tx is returned) and the network account (so the
1989        // gating in `mark_erased_note_as_consumed` allows creating the input record).
1990        let network_header =
1991            AccountHeader::new(network_account_id, ZERO, EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
1992
1993        let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
1994
1995        let genesis_peaks =
1996            mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
1997        let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
1998
1999        let sync_input = StateSyncInput {
2000            accounts: vec![AccountHeader::from(sender_account), network_header],
2001            note_tags: BTreeSet::new(),
2002            input_notes: vec![],
2003            output_notes: vec![output_note],
2004            uncommitted_transactions: vec![],
2005        };
2006
2007        let update = state_sync.sync_state(&mut partial_mmr, sync_input).await.unwrap();
2008
2009        // The output note record should transition to consumed.
2010        let updated_output = update
2011            .note_updates
2012            .updated_output_notes()
2013            .find(|n| n.id() == erased_note_id)
2014            .expect("output note should be in the update");
2015        assert!(
2016            updated_output.inner().is_consumed(),
2017            "output note should be consumed, got: {}",
2018            updated_output.inner().state()
2019        );
2020
2021        // A new input note record should be created with the network account as consumer.
2022        let input_note_update = update
2023            .note_updates
2024            .updated_input_notes()
2025            .find(|n| n.id() == Some(erased_note_id))
2026            .expect("input note should be created from the erased output note");
2027
2028        let inner = input_note_update.inner();
2029        assert!(
2030            inner.is_consumed(),
2031            "input note should be in a consumed state, got: {}",
2032            inner.state()
2033        );
2034        assert_eq!(
2035            inner.consumer_account(),
2036            Some(network_account_id),
2037            "consumer should be the tracked network account"
2038        );
2039    }
2040
2041    /// Verifies the validations performed on `sync_chain_mmr` responses: a genuine mock-chain
2042    /// response passes, while each tampered variant is rejected with a `ChainValidationError`.
2043    #[tokio::test]
2044    async fn validate_chain_mmr_response_rejects_tampered_responses() {
2045        let mock_rpc = MockRpcApi::default();
2046        mock_rpc.advance_blocks(3);
2047        let chain_tip = mock_rpc.get_chain_tip_block_num();
2048        let current = BlockNumber::GENESIS;
2049
2050        let header_of =
2051            |block_num: u32| mock_rpc.mock_chain.read().block_header(block_num as usize);
2052        let chain_mmr_response = || async {
2053            mock_rpc.sync_chain_mmr(current, SyncTarget::CommittedChainTip).await.unwrap()
2054        };
2055
2056        // Sanity check: the untampered response passes validation.
2057        let response = chain_mmr_response().await;
2058        StateSync::validate_chain_mmr_response(&response, current).unwrap();
2059
2060        // The returned block header doesn't correspond to `block_to`.
2061        let mut response = chain_mmr_response().await;
2062        response.block_header = header_of(chain_tip.as_u32() - 1);
2063        let result = StateSync::validate_chain_mmr_response(&response, current);
2064        assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2065
2066        // `block_from` doesn't match the block the sync was requested from.
2067        let mut response = chain_mmr_response().await;
2068        response.block_from = current + 1;
2069        let result = StateSync::validate_chain_mmr_response(&response, current);
2070        assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2071
2072        // `block_to` (and its header) regress behind the client's current block.
2073        let mut response = chain_mmr_response().await;
2074        response.block_from = chain_tip;
2075        response.block_to = BlockNumber::GENESIS;
2076        response.block_header = header_of(0);
2077        let result = StateSync::validate_chain_mmr_response(&response, chain_tip);
2078        assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2079    }
2080
2081    /// Verifies that `sync_notes` blocks outside the requested range `(current, chain_tip]`
2082    /// are rejected with a `ChainValidationError`.
2083    #[test]
2084    fn validate_note_blocks_range_rejects_out_of_range_blocks() {
2085        let mock_rpc = MockRpcApi::default();
2086        mock_rpc.advance_blocks(3);
2087        let chain_tip = mock_rpc.get_chain_tip_block_num();
2088        let current = BlockNumber::GENESIS;
2089
2090        // Sanity check: an empty block list passes validation.
2091        StateSync::validate_note_blocks_range(&[], current, chain_tip).unwrap();
2092
2093        // A note block outside the requested range: genesis is always outside it.
2094        let genesis_note_block = NoteSyncBlock {
2095            block_header: mock_rpc.mock_chain.read().block_header(0),
2096            mmr_path: MerklePath::new(Vec::new()),
2097            notes: BTreeMap::new(),
2098        };
2099        let result =
2100            StateSync::validate_note_blocks_range(&[genesis_note_block], current, chain_tip);
2101        assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2102    }
2103
2104    /// Verifies that `advance_mmr` rejects an MMR delta whose post-apply peaks don't match the
2105    /// chain tip header's chain commitment.
2106    #[test]
2107    fn advance_mmr_rejects_delta_inconsistent_with_chain_commitment() {
2108        let mock_rpc = MockRpcApi::default();
2109        mock_rpc.advance_blocks(3);
2110        let chain_tip = mock_rpc.get_chain_tip_block_num();
2111
2112        let chain_tip_header = mock_rpc.mock_chain.read().block_header(chain_tip.as_usize());
2113        let genesis_partial_mmr = || {
2114            let peaks = mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2115            PartialMmr::from_peaks(peaks)
2116        };
2117
2118        // An MMR delta consistent with the chain tip header advances the MMR...
2119        let full_delta = mock_rpc
2120            .get_mmr()
2121            .get_delta(Forest::new(1).unwrap(), Forest::new(chain_tip.as_usize()).unwrap())
2122            .unwrap();
2123        StateSync::advance_mmr(
2124            full_delta,
2125            &chain_tip_header,
2126            &mut genesis_partial_mmr(),
2127            &mut PartialBlockchainUpdates::default(),
2128        )
2129        .unwrap();
2130
2131        // ...but one that stops short of the chain tip fails the chain commitment check.
2132        let truncated_delta = mock_rpc
2133            .get_mmr()
2134            .get_delta(Forest::new(1).unwrap(), Forest::new(chain_tip.as_usize() - 1).unwrap())
2135            .unwrap();
2136        let result = StateSync::advance_mmr(
2137            truncated_delta,
2138            &chain_tip_header,
2139            &mut genesis_partial_mmr(),
2140            &mut PartialBlockchainUpdates::default(),
2141        );
2142        assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2143    }
2144}