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