Skip to main content

miden_client/sync/
state_sync.rs

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