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