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