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