Skip to main content

miden_client/sync/
state_sync.rs

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