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