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