1use alloc::boxed::Box;
2use alloc::collections::{BTreeMap, BTreeSet};
3use alloc::sync::Arc;
4use alloc::vec::Vec;
5use core::cmp::Ordering;
6
7use async_trait::async_trait;
8use futures::{StreamExt, TryStreamExt};
9use miden_protocol::Word;
10use miden_protocol::account::{Account, AccountHeader, AccountId, StorageSlotType};
11use miden_protocol::block::account_tree::AccountIdKey;
12use miden_protocol::block::{BlockHeader, BlockNumber};
13use miden_protocol::crypto::merkle::MerklePath;
14use miden_protocol::crypto::merkle::mmr::{InOrderIndex, MmrDelta, PartialMmr};
15use miden_protocol::note::{NoteId, NoteTag, Nullifier};
16use tracing::info;
17
18use super::state_sync_update::{TransactionUpdateTracker, build_account_patch};
19use super::{
20 AccountUpdates,
21 NoteObserver,
22 PartialBlockchainUpdates,
23 PublicAccountUpdate,
24 StateSyncUpdate,
25};
26use crate::ClientError;
27use crate::note::{NoteConsumption, NoteUpdateTracker};
28use crate::rpc::domain::account::{
29 AccountDetails,
30 AccountProof,
31 AccountStorageMapDetails,
32 GetAccountRequest,
33 StorageMapFetch,
34 VaultFetch,
35};
36use crate::rpc::domain::note::{CommittedNote, FetchedNote, ResolvedSyncNotesBlock, SyncedNote};
37use crate::rpc::domain::sync::{ChainMmrInfo, SyncTarget};
38use crate::rpc::domain::transaction::TransactionRecord as RpcTransactionRecord;
39use crate::rpc::{AccountStateAt, NodeRpcClient, NoteContentFetch, RpcError};
40use crate::store::input_note_states::UnverifiedNoteState;
41use crate::store::{InputNoteRecord, OutputNoteRecord, StoreError};
42use crate::transaction::TransactionRecord;
43
44const MAX_CONCURRENT_ACCOUNT_FETCHES: usize = 4;
46
47enum PublicAccountSync {
52 Apply(Box<PublicAccountUpdate>),
54 Superseded,
56 Ignore,
58}
59
60struct FetchedSyncData {
66 mmr_delta: MmrDelta,
68 chain_tip_header: BlockHeader,
70 note_blocks: Vec<ResolvedSyncNotesBlock>,
73 transactions: Vec<RpcTransactionRecord>,
75}
76
77struct RecoverableConsumedNote {
82 nullifier: Nullifier,
83 consumer: AccountId,
84 block_num: BlockNumber,
85}
86
87struct RelevantNoteBlock {
92 block_header: BlockHeader,
93 mmr_path: MerklePath,
94 observer_requires_block: bool,
95}
96
97#[derive(Default)]
99struct NoteBlockRelevance {
100 has_client_note: bool,
101 observer_requires_block: bool,
102}
103
104impl NoteBlockRelevance {
105 fn is_relevant(&self) -> bool {
106 self.has_client_note || self.observer_requires_block
107 }
108}
109
110pub struct StateSyncInput {
126 pub accounts: Vec<AccountHeader>,
128 pub note_tags: BTreeSet<NoteTag>,
130 pub input_notes: Vec<InputNoteRecord>,
132 pub output_notes: Vec<OutputNoteRecord>,
138 pub uncommitted_transactions: Vec<TransactionRecord>,
140}
141
142#[allow(clippy::large_enum_variant)]
147pub enum NoteUpdateAction {
148 Commit(CommittedNote),
151 Insert(InputNoteRecord),
153 Discard,
155}
156
157#[async_trait(?Send)]
158pub trait OnNoteReceived {
159 async fn on_note_received(
170 &self,
171 committed_note: CommittedNote,
172 public_note: Option<InputNoteRecord>,
173 ) -> Result<NoteUpdateAction, ClientError>;
174}
175#[derive(Clone)]
182pub struct StateSync {
183 rpc_api: Arc<dyn NodeRpcClient>,
185 note_screener: Arc<dyn OnNoteReceived>,
188 note_observers: Vec<Arc<dyn NoteObserver>>,
191 tx_discard_delta: Option<u32>,
194 sync_nullifiers: bool,
197}
198
199impl StateSync {
200 pub fn new(
211 rpc_api: Arc<dyn NodeRpcClient>,
212 note_screener: Arc<dyn OnNoteReceived>,
213 tx_discard_delta: Option<u32>,
214 ) -> Self {
215 Self {
216 rpc_api,
217 note_screener,
218 note_observers: Vec::new(),
219 tx_discard_delta,
220 sync_nullifiers: true,
221 }
222 }
223
224 #[must_use]
228 pub fn with_note_observer(mut self, observer: Arc<dyn NoteObserver>) -> Self {
229 self.note_observers.push(observer);
230 self
231 }
232
233 pub fn disable_nullifier_sync(&mut self) {
239 self.sync_nullifiers = false;
240 }
241
242 pub fn enable_nullifier_sync(&mut self) {
244 self.sync_nullifiers = true;
245 }
246
247 pub(crate) async fn run_apply_hooks(
254 &self,
255 state_sync_update: &StateSyncUpdate,
256 ) -> Result<(), ClientError> {
257 for observer in &self.note_observers {
258 crate::errors::log_observer_failure(
259 observer.name(),
260 "NoteObserver::apply",
261 observer.apply(state_sync_update).await,
262 );
263 }
264 Ok(())
265 }
266
267 pub async fn sync_state(
285 &self,
286 current_partial_mmr: &mut PartialMmr,
287 input: StateSyncInput,
288 ) -> Result<StateSyncUpdate, ClientError> {
289 let StateSyncInput {
290 accounts,
291 note_tags,
292 input_notes,
293 output_notes,
294 uncommitted_transactions,
295 } = input;
296 let block_num = u32::try_from(current_partial_mmr.forest().num_leaves().saturating_sub(1))
297 .map_err(|_| ClientError::InvalidPartialMmrForest)?
298 .into();
299
300 let note_tags = Arc::new(note_tags);
301 let account_ids: Vec<AccountId> = accounts.iter().map(AccountHeader::id).collect();
302
303 let mut note_updates = NoteUpdateTracker::new(input_notes, output_notes);
304 let mut transaction_updates = TransactionUpdateTracker::new(uncommitted_transactions);
305 let mut partial_blockchain_updates = PartialBlockchainUpdates::default();
306 let mut account_updates = AccountUpdates::default();
307
308 let Some(sync_data) = self.fetch_sync_data(block_num, &account_ids, ¬e_tags).await?
309 else {
310 return Ok(StateSyncUpdate::from_parts(
312 block_num,
313 partial_blockchain_updates,
314 note_updates,
315 transaction_updates,
316 account_updates,
317 ));
318 };
319
320 let FetchedSyncData {
321 mmr_delta,
322 chain_tip_header,
323 note_blocks,
324 transactions,
325 } = sync_data;
326 let chain_tip = chain_tip_header.block_num();
327
328 let new_commitments = derive_account_commitments(&transactions);
329 let superseded_states = self
330 .account_state_sync(
331 &mut account_updates,
332 &accounts,
333 &new_commitments,
334 block_num,
335 &chain_tip_header,
336 )
337 .await?;
338
339 for superseded_state in superseded_states {
341 transaction_updates.apply_superseded_account_state(superseded_state);
342 }
343
344 let mut working_mmr = current_partial_mmr.clone();
346
347 Self::advance_mmr(
348 mmr_delta,
349 &chain_tip_header,
350 &mut working_mmr,
351 &mut partial_blockchain_updates,
352 )?;
353 let relevant_note_blocks = self.screen_note_blocks(note_blocks, &mut note_updates).await?;
354 self.apply_transactions_and_nullifiers(
355 &chain_tip_header,
356 &transactions,
357 &mut note_updates,
358 &mut transaction_updates,
359 )?;
360
361 if self.sync_nullifiers {
362 self.nullifiers_state_sync(
363 &mut note_updates,
364 &mut transaction_updates,
365 chain_tip,
366 block_num,
367 )
368 .await?;
369 }
370
371 self.recover_consumed_public_notes(&mut note_updates, &transactions).await?;
372
373 let blocks_with_unspent_notes: BTreeSet<BlockNumber> =
374 note_updates.unspent_input_note_block_numbers().collect();
375
376 Self::validate_and_track_note_blocks(
377 relevant_note_blocks,
378 &blocks_with_unspent_notes,
379 &mut working_mmr,
380 &mut partial_blockchain_updates,
381 )?;
382
383 *current_partial_mmr = working_mmr;
384
385 Ok(StateSyncUpdate::from_parts(
386 chain_tip,
387 partial_blockchain_updates,
388 note_updates,
389 transaction_updates,
390 account_updates,
391 ))
392 }
393
394 async fn recover_consumed_public_notes(
399 &self,
400 note_updates: &mut NoteUpdateTracker,
401 transactions: &[RpcTransactionRecord],
402 ) -> Result<(), ClientError> {
403 let mut recoverable_consumed_notes: BTreeMap<NoteId, RecoverableConsumedNote> =
404 BTreeMap::new();
405 for tx in transactions {
406 for (nullifier, note_id) in tx.trusted_consumed_note_refs() {
407 recoverable_consumed_notes.insert(
408 note_id,
409 RecoverableConsumedNote {
410 nullifier,
411 consumer: tx.transaction_header.account_id(),
412 block_num: tx.block_num,
413 },
414 );
415 }
416 }
417 recoverable_consumed_notes.retain(|note_id, _| !note_updates.tracks_note(*note_id));
420
421 let note_ids: Vec<NoteId> = recoverable_consumed_notes.keys().copied().collect();
422 if note_ids.is_empty() {
423 return Ok(());
424 }
425
426 for fetched in self.rpc_api.get_notes_by_id(¬e_ids).await? {
427 match fetched {
428 FetchedNote::Public(note, _) => {
429 let Some(reference) = recoverable_consumed_notes.get(¬e.id()) else {
430 continue;
431 };
432 if note.nullifier() != reference.nullifier {
435 return Err(RpcError::InvalidResponse(format!(
436 "node returned note {} whose nullifier doesn't match the consumed reference",
437 note.id()
438 ))
439 .into());
440 }
441 note_updates.insert_consumed_public_note(
442 note,
443 reference.consumer,
444 reference.block_num,
445 )?;
446 },
447 FetchedNote::Private(note_id, ..) => {
448 return Err(RpcError::InvalidResponse(format!(
449 "node returned private note {note_id} for a public consumed-note reference"
450 ))
451 .into());
452 },
453 }
454 }
455
456 Ok(())
457 }
458
459 async fn fetch_sync_data(
469 &self,
470 current_block_num: BlockNumber,
471 account_ids: &[AccountId],
472 note_tags: &Arc<BTreeSet<NoteTag>>,
473 ) -> Result<Option<FetchedSyncData>, ClientError> {
474 let chain_mmr_info = self
476 .rpc_api
477 .sync_chain_mmr(current_block_num, SyncTarget::CommittedChainTip)
478 .await?;
479 let chain_tip = chain_mmr_info.block_to;
480
481 Self::validate_chain_mmr_response(&chain_mmr_info, current_block_num)?;
483
484 if chain_tip == current_block_num {
486 info!(block_num = %current_block_num, "Already at chain tip, nothing to sync.");
487 return Ok(None);
488 }
489
490 info!(
491 block_from = %current_block_num,
492 block_to = %chain_tip,
493 "Syncing state.",
494 );
495
496 let (note_blocks, transaction_records) = futures::try_join!(
499 self.fetch_note_blocks(current_block_num + 1, chain_tip, note_tags),
500 self.fetch_transactions(current_block_num + 1, chain_tip, account_ids),
501 )?;
502
503 Self::validate_note_blocks_range(¬e_blocks, current_block_num, chain_tip)?;
505
506 let note_count: usize = note_blocks.iter().map(|b| b.notes.len()).sum();
507 info!(
508 blocks_with_notes = note_blocks.len(),
509 notes = note_count,
510 "Fetched note sync data.",
511 );
512
513 Self::validate_transaction_records_range(
514 &transaction_records,
515 current_block_num,
516 chain_tip,
517 )?;
518
519 Ok(Some(FetchedSyncData {
520 mmr_delta: chain_mmr_info.mmr_delta,
521 chain_tip_header: chain_mmr_info.block_header,
522 note_blocks,
523 transactions: transaction_records,
524 }))
525 }
526
527 async fn fetch_note_blocks(
533 &self,
534 block_from: BlockNumber,
535 block_to: BlockNumber,
536 note_tags: &BTreeSet<NoteTag>,
537 ) -> Result<Vec<ResolvedSyncNotesBlock>, ClientError> {
538 if note_tags.is_empty() {
539 return Ok(Vec::new());
540 }
541
542 self.rpc_api
543 .sync_notes_with_content(
544 block_from,
545 block_to,
546 note_tags,
547 NoteContentFetch::PublicDetailsAndAttachments,
548 )
549 .await
550 .map_err(ClientError::RpcError)
551 }
552
553 async fn fetch_transactions(
555 &self,
556 block_from: BlockNumber,
557 block_to: BlockNumber,
558 account_ids: &[AccountId],
559 ) -> Result<Vec<RpcTransactionRecord>, ClientError> {
560 if account_ids.is_empty() {
561 return Ok(Vec::new());
562 }
563
564 self.rpc_api
565 .sync_transactions(block_from, block_to, account_ids.to_vec())
566 .await
567 .map_err(ClientError::RpcError)
568 }
569
570 fn validate_chain_mmr_response(
572 chain_mmr_info: &ChainMmrInfo,
573 current_block_num: BlockNumber,
574 ) -> Result<(), ClientError> {
575 if chain_mmr_info.block_header.block_num() != chain_mmr_info.block_to {
576 return Err(ClientError::ChainValidationError(format!(
577 "sync_chain_mmr block_header.block_num ({}) does not match block_to ({})",
578 chain_mmr_info.block_header.block_num(),
579 chain_mmr_info.block_to
580 )));
581 }
582 if chain_mmr_info.block_from != current_block_num {
583 return Err(ClientError::ChainValidationError(format!(
584 "sync_chain_mmr block_from mismatch: expected {current_block_num}, got {}",
585 chain_mmr_info.block_from
586 )));
587 }
588 if chain_mmr_info.block_to < current_block_num {
589 return Err(ClientError::ChainValidationError(format!(
590 "sync_chain_mmr block_to ({}) is behind current block {current_block_num}",
591 chain_mmr_info.block_to
592 )));
593 }
594 Ok(())
595 }
596
597 fn validate_note_blocks_range(
600 note_blocks: &[ResolvedSyncNotesBlock],
601 current_block_num: BlockNumber,
602 chain_tip: BlockNumber,
603 ) -> Result<(), ClientError> {
604 for block in note_blocks {
605 let block_num = block.block_header.block_num();
606 if block_num <= current_block_num || block_num > chain_tip {
607 return Err(ClientError::ChainValidationError(format!(
608 "sync_notes returned block {block_num} outside requested range ({current_block_num}, {chain_tip}]"
609 )));
610 }
611 }
612 Ok(())
613 }
614
615 fn validate_transaction_records_range(
618 records: &[RpcTransactionRecord],
619 current_block_num: BlockNumber,
620 chain_tip: BlockNumber,
621 ) -> Result<(), ClientError> {
622 for record in records {
623 let block_num = record.block_num;
624 if block_num <= current_block_num || block_num > chain_tip {
625 return Err(ClientError::ChainValidationError(format!(
626 "sync_transactions returned block {block_num} outside requested range ({current_block_num}, {chain_tip}]"
627 )));
628 }
629 }
630 Ok(())
631 }
632
633 fn advance_mmr(
640 mmr_delta: MmrDelta,
641 chain_tip_header: &BlockHeader,
642 current_partial_mmr: &mut PartialMmr,
643 partial_blockchain_updates: &mut PartialBlockchainUpdates,
644 ) -> Result<(), ClientError> {
645 let mut new_authentication_nodes =
646 current_partial_mmr.apply(mmr_delta).map_err(StoreError::MmrError)?;
647 let new_peaks = current_partial_mmr.peaks();
648
649 let peaks_commitment = new_peaks.hash_peaks();
653 if peaks_commitment != chain_tip_header.chain_commitment() {
654 return Err(ClientError::ChainValidationError(format!(
655 "MMR peaks commitment is {} and does not match block header chain commitment {}",
656 peaks_commitment.to_hex(),
657 chain_tip_header.chain_commitment().to_hex()
658 )));
659 }
660
661 partial_blockchain_updates.new_peaks = new_peaks;
662
663 new_authentication_nodes.append(
668 &mut current_partial_mmr
669 .add(chain_tip_header.commitment(), false)
670 .map_err(StoreError::MmrError)?,
671 );
672
673 partial_blockchain_updates.insert(chain_tip_header.clone(), false);
674 partial_blockchain_updates.extend_authentication_nodes(new_authentication_nodes);
675
676 Ok(())
677 }
678
679 async fn screen_note_blocks(
687 &self,
688 note_blocks: Vec<ResolvedSyncNotesBlock>,
689 note_updates: &mut NoteUpdateTracker,
690 ) -> Result<Vec<RelevantNoteBlock>, ClientError> {
691 let mut relevant_blocks = Vec::new();
692
693 for block in note_blocks {
694 let relevance =
695 self.note_state_sync(note_updates, block.notes, &block.block_header).await?;
696
697 if relevance.is_relevant() {
698 relevant_blocks.push(RelevantNoteBlock {
699 block_header: block.block_header,
700 mmr_path: block.mmr_path,
701 observer_requires_block: relevance.observer_requires_block,
702 });
703 }
704 }
705
706 Ok(relevant_blocks)
707 }
708
709 fn validate_and_track_note_blocks(
719 relevant_blocks: Vec<RelevantNoteBlock>,
720 blocks_with_unspent_notes: &BTreeSet<BlockNumber>,
721 partial_mmr: &mut PartialMmr,
722 partial_blockchain_updates: &mut PartialBlockchainUpdates,
723 ) -> Result<(), ClientError> {
724 let nodes_before: BTreeSet<InOrderIndex> = partial_mmr.nodes().map(|(k, _)| *k).collect();
725
726 for RelevantNoteBlock {
727 block_header,
728 mmr_path,
729 observer_requires_block,
730 } in relevant_blocks
731 {
732 let block_pos = block_header.block_num().as_usize();
733 let was_tracked = partial_mmr.is_tracked(block_pos);
734
735 partial_mmr
738 .track(block_pos, block_header.commitment(), &mmr_path)
739 .map_err(StoreError::MmrError)?;
740
741 if observer_requires_block
742 || blocks_with_unspent_notes.contains(&block_header.block_num())
743 {
744 partial_blockchain_updates.insert(block_header, true);
745 } else if !was_tracked {
746 partial_mmr.untrack(block_pos);
747 }
748 }
749
750 partial_blockchain_updates.extend_authentication_nodes(
752 partial_mmr
753 .nodes()
754 .filter(|(index, _)| !nodes_before.contains(index))
755 .map(|(index, value)| (*index, *value)),
756 );
757
758 Ok(())
759 }
760
761 fn apply_transactions_and_nullifiers(
765 &self,
766 chain_tip_header: &BlockHeader,
767 transactions: &[RpcTransactionRecord],
768 note_updates: &mut NoteUpdateTracker,
769 transaction_updates: &mut TransactionUpdateTracker,
770 ) -> Result<(), ClientError> {
771 note_updates.extend_nullifiers(compute_ordered_nullifiers(transactions));
772
773 for record in transactions {
774 transaction_updates
775 .apply_transaction_inclusion(record, u64::from(chain_tip_header.timestamp())); }
777 transaction_updates
778 .apply_sync_height_update(chain_tip_header.block_num(), self.tx_discard_delta);
779
780 for transaction in transactions {
781 note_updates.apply_output_note_inclusion_proofs(&transaction.output_notes)?;
785
786 Self::mark_erased_notes_as_consumed(note_updates, transaction);
788 }
789
790 Ok(())
791 }
792
793 fn mark_erased_notes_as_consumed(
799 note_updates: &mut NoteUpdateTracker,
800 transaction: &RpcTransactionRecord,
801 ) {
802 for note_header in &transaction.erased_output_notes {
803 let _ = note_updates.mark_erased_note_as_consumed(note_header, transaction.block_num);
805 }
806 }
807
808 async fn account_state_sync(
821 &self,
822 account_updates: &mut AccountUpdates,
823 accounts: &[AccountHeader],
824 account_commitment_updates: &[(AccountId, Word)],
825 block_from: BlockNumber,
826 chain_tip_header: &BlockHeader,
827 ) -> Result<Vec<Word>, ClientError> {
828 let (public_accounts, private_accounts): (Vec<_>, Vec<_>) =
831 accounts.iter().partition(|header| !header.id().is_private());
832
833 let superseded_states = self
834 .sync_public_accounts(
835 account_updates,
836 account_commitment_updates,
837 &public_accounts,
838 block_from,
839 chain_tip_header,
840 )
841 .await?;
842
843 let diverging_private_accounts: Vec<&AccountHeader> = private_accounts
846 .into_iter()
847 .filter(|header| {
848 let local_commitment = header.to_commitment();
849 account_commitment_updates
850 .iter()
851 .any(|(id, digest)| *id == header.id() && *digest != local_commitment)
852 })
853 .collect();
854
855 let proven_commitments: Vec<Option<Word>> =
856 futures::stream::iter(diverging_private_accounts.iter().map(|header| {
857 self.verify_private_account_mismatch(
858 header.id(),
859 header.to_commitment(),
860 chain_tip_header,
861 )
862 }))
863 .buffered(MAX_CONCURRENT_ACCOUNT_FETCHES)
864 .try_collect()
865 .await?;
866
867 let mismatched_private_accounts: Vec<(AccountId, Word)> = diverging_private_accounts
868 .iter()
869 .zip(proven_commitments)
870 .filter_map(|(header, proven_commitment)| {
871 proven_commitment.map(|commitment| (header.id(), commitment))
872 })
873 .collect();
874
875 account_updates.extend(AccountUpdates::new(Vec::new(), mismatched_private_accounts));
876
877 Ok(superseded_states)
878 }
879
880 async fn verify_private_account_mismatch(
891 &self,
892 account_id: AccountId,
893 local_commitment: Word,
894 chain_tip_header: &BlockHeader,
895 ) -> Result<Option<Word>, ClientError> {
896 let chain_tip = chain_tip_header.block_num();
897 let (proof_block_num, proof) = self
898 .rpc_api
899 .get_account(account_id, GetAccountRequest::new().at(AccountStateAt::Block(chain_tip)))
900 .await?;
901
902 if proof_block_num != chain_tip {
903 return Err(ClientError::ChainValidationError(format!(
904 "get_account returned a proof at block {proof_block_num}, expected chain tip {chain_tip}"
905 )));
906 }
907
908 let (witness, _) = proof.into_parts();
909 let witness_id = witness.id();
910 let proven_commitment = witness.state_commitment();
911 if witness.into_proof().compute_root() != chain_tip_header.account_root() {
914 return Err(ClientError::ChainValidationError(format!(
915 "account witness for {account_id} does not verify against the chain tip account root"
916 )));
917 }
918
919 if witness_id != account_id
922 || proven_commitment == Word::empty()
923 || proven_commitment == local_commitment
924 {
925 return Ok(None);
926 }
927
928 Ok(Some(proven_commitment))
929 }
930
931 async fn sync_public_accounts(
940 &self,
941 account_updates: &mut AccountUpdates,
942 commitment_updates: &[(AccountId, Word)],
943 current_public_accounts: &[&AccountHeader],
944 block_from: BlockNumber,
945 chain_tip_header: &BlockHeader,
946 ) -> Result<Vec<Word>, ClientError> {
947 let local_headers: BTreeMap<AccountId, &AccountHeader> =
948 current_public_accounts.iter().map(|header| (header.id(), *header)).collect();
949
950 let diverging_accounts: Vec<(AccountId, &AccountHeader)> = commitment_updates
953 .iter()
954 .filter_map(|(id, commitment)| {
955 let local_header = local_headers.get(id).copied()?;
956 (local_header.to_commitment() != *commitment).then_some((*id, local_header))
957 })
958 .collect();
959
960 let synced_accounts: Vec<PublicAccountSync> =
963 futures::stream::iter(diverging_accounts.iter().map(|(id, local_header)| {
964 self.sync_public_account(*id, local_header, block_from, chain_tip_header)
965 }))
966 .buffered(MAX_CONCURRENT_ACCOUNT_FETCHES)
967 .try_collect()
968 .await?;
969
970 let mut superseded_states = Vec::new();
972 for ((_, local_header), synced_account) in diverging_accounts.iter().zip(synced_accounts) {
973 match synced_account {
974 PublicAccountSync::Apply(public_update) => {
975 account_updates.extend(AccountUpdates::new(vec![*public_update], Vec::new()));
976 },
977 PublicAccountSync::Superseded => {
978 superseded_states.push(local_header.to_commitment());
979 },
980 PublicAccountSync::Ignore => {},
981 }
982 }
983
984 Ok(superseded_states)
985 }
986
987 async fn sync_public_account(
1001 &self,
1002 account_id: AccountId,
1003 local_header: &AccountHeader,
1004 block_from: BlockNumber,
1005 chain_tip_header: &BlockHeader,
1006 ) -> Result<PublicAccountSync, ClientError> {
1007 let target_block_num = chain_tip_header.block_num();
1008
1009 let (proof_block_num, proof) = self
1012 .rpc_api
1013 .get_account(
1014 account_id,
1015 GetAccountRequest::new()
1016 .at(AccountStateAt::Block(target_block_num))
1017 .with_storage(StorageMapFetch::All)
1018 .with_vault(VaultFetch::Always),
1019 )
1020 .await
1021 .map_err(ClientError::RpcError)?;
1022
1023 let details =
1024 Self::validate_account_proof(proof, proof_block_num, account_id, chain_tip_header)?;
1025
1026 match details
1027 .header
1028 .nonce()
1029 .as_canonical_u64()
1030 .cmp(&local_header.nonce().as_canonical_u64())
1031 {
1032 Ordering::Less => return Ok(PublicAccountSync::Ignore),
1035 Ordering::Equal => return Ok(PublicAccountSync::Superseded),
1037 Ordering::Greater => {},
1039 }
1040
1041 let vault_oversized = details.vault_details.too_many_assets;
1042 let any_map_oversized = details
1043 .storage_details
1044 .map_details
1045 .iter()
1046 .any(AccountStorageMapDetails::is_limit_exceeded);
1047
1048 let public_update = if vault_oversized || any_map_oversized {
1052 self.build_patch_update(account_id, &details, block_from, proof_block_num)
1054 .await?
1055 } else {
1056 let account = Account::try_from(&details).map_err(ClientError::RpcError)?;
1058 PublicAccountUpdate::Full(account)
1059 };
1060
1061 Ok(PublicAccountSync::Apply(Box::new(public_update)))
1062 }
1063
1064 fn validate_account_proof(
1080 proof: AccountProof,
1081 proof_block_num: BlockNumber,
1082 account_id: AccountId,
1083 chain_tip_header: &BlockHeader,
1084 ) -> Result<AccountDetails, ClientError> {
1085 let target_block_num = chain_tip_header.block_num();
1086
1087 if proof_block_num != target_block_num {
1088 return Err(ClientError::ChainValidationError(format!(
1089 "get_account returned block {proof_block_num} but {target_block_num} was requested"
1090 )));
1091 }
1092
1093 let (witness, details) = proof.into_parts();
1094
1095 if witness.id() != account_id {
1097 return Err(ClientError::ChainValidationError(format!(
1098 "get_account returned account {} but {account_id} was requested",
1099 witness.id()
1100 )));
1101 }
1102
1103 let account_key = AccountIdKey::from(account_id).as_word();
1104 let state_commitment = witness.state_commitment();
1105 witness
1106 .into_proof()
1107 .verify_presence(&account_key, &state_commitment, &chain_tip_header.account_root())
1108 .map_err(|err| {
1109 ClientError::ChainValidationError(format!(
1110 "get_account witness for account {account_id} does not open under block \
1111 {target_block_num} account root: {err}"
1112 ))
1113 })?;
1114
1115 Ok(details.expect("node returned no details for a public account"))
1116 }
1117
1118 async fn build_patch_update(
1121 &self,
1122 account_id: AccountId,
1123 details: &AccountDetails,
1124 block_from: BlockNumber,
1125 block_to: BlockNumber,
1126 ) -> Result<PublicAccountUpdate, ClientError> {
1127 let value_slot_updates: Vec<(_, Word)> = details
1128 .storage_details
1129 .header
1130 .slots()
1131 .filter(|slot| slot.slot_type() == StorageSlotType::Value)
1132 .map(|slot| (slot.name().clone(), slot.value()))
1133 .collect();
1134
1135 let map_info = self
1138 .rpc_api
1139 .sync_storage_maps(block_from + 1, block_to, account_id)
1140 .await
1141 .map_err(ClientError::RpcError)?;
1142 let vault_info = self
1143 .rpc_api
1144 .sync_account_vault(block_from + 1, block_to, account_id)
1145 .await
1146 .map_err(ClientError::RpcError)?;
1147
1148 let patch = build_account_patch(
1149 &details.header,
1150 value_slot_updates,
1151 map_info.map_entries,
1152 vault_info.vault_patch,
1153 details.code.clone(),
1154 )
1155 .map_err(StoreError::AccountPatchError)?;
1156
1157 Ok(PublicAccountUpdate::Patch {
1158 new_header: details.header.clone(),
1159 patch,
1160 })
1161 }
1162
1163 async fn note_state_sync(
1181 &self,
1182 note_updates: &mut NoteUpdateTracker,
1183 notes: BTreeMap<NoteId, SyncedNote>,
1184 block_header: &BlockHeader,
1185 ) -> Result<NoteBlockRelevance, ClientError> {
1186 let mut relevance = NoteBlockRelevance::default();
1187
1188 for (_, SyncedNote { committed, details, attachments }) in notes {
1189 let public_note = details.map(|details| {
1192 let state = UnverifiedNoteState {
1193 metadata: *committed.metadata(),
1194 inclusion_proof: committed.inclusion_proof().clone(),
1195 }
1196 .into();
1197 InputNoteRecord::new(details, attachments.clone(), None, state)
1198 });
1199
1200 if !self.note_observers.is_empty() {
1204 for obs in &self.note_observers {
1205 match obs.observe(&committed, &attachments).await {
1206 Ok(true) => relevance.observer_requires_block = true,
1207 Ok(false) => {},
1208 Err(err) => {
1209 tracing::warn!(
1210 observer = obs.name(),
1211 error = ?err,
1212 "note observer failed; sync continues",
1213 );
1214 },
1215 }
1216 }
1217 }
1218
1219 match self.note_screener.on_note_received(committed, public_note).await? {
1220 NoteUpdateAction::Commit(committed_note) => {
1221 relevance.has_client_note |= note_updates
1225 .apply_committed_note_state_transitions(
1226 &committed_note,
1227 block_header,
1228 &attachments,
1229 )?;
1230 },
1231 NoteUpdateAction::Insert(public_note) => {
1232 relevance.has_client_note = true;
1233
1234 note_updates.apply_new_public_note(public_note, block_header)?;
1235 },
1236 NoteUpdateAction::Discard => {},
1237 }
1238 }
1239
1240 Ok(relevance)
1241 }
1242
1243 async fn nullifiers_state_sync(
1250 &self,
1251 note_updates: &mut NoteUpdateTracker,
1252 transaction_updates: &mut TransactionUpdateTracker,
1253 chain_tip: BlockNumber,
1254 current_block_num: BlockNumber,
1255 ) -> Result<(), ClientError> {
1256 let nullifiers_tags: Vec<u16> =
1262 note_updates.unspent_nullifiers().map(|nullifier| nullifier.prefix()).collect();
1263
1264 let mut new_nullifiers = self
1265 .rpc_api
1266 .sync_nullifiers(&nullifiers_tags, current_block_num + 1, chain_tip)
1267 .await?;
1268
1269 new_nullifiers.retain(|update| update.block_num <= chain_tip);
1272
1273 let consumptions: Vec<NoteConsumption> = new_nullifiers
1275 .into_iter()
1276 .map(|update| NoteConsumption {
1277 external_consumer: transaction_updates
1278 .external_nullifier_account(&update.nullifier),
1279 nullifier: update.nullifier,
1280 block_num: update.block_num,
1281 })
1282 .collect();
1283
1284 for consumption in consumptions {
1285 note_updates.apply_note_consumption(
1286 &consumption,
1287 transaction_updates.committed_transactions(),
1288 )?;
1289
1290 transaction_updates.apply_input_note_nullified(consumption.nullifier);
1294 }
1295
1296 Ok(())
1297 }
1298}
1299
1300fn group_txs_by_account_block(
1305 transaction_records: &[RpcTransactionRecord],
1306) -> BTreeMap<(AccountId, BlockNumber), Vec<&RpcTransactionRecord>> {
1307 let mut groups: BTreeMap<(AccountId, BlockNumber), Vec<&RpcTransactionRecord>> =
1308 BTreeMap::new();
1309 for record in transaction_records {
1310 let account_id = record.transaction_header.account_id();
1311 groups.entry((account_id, record.block_num)).or_default().push(record);
1312 }
1313 groups
1314}
1315
1316fn walk_execution_chain<'a>(
1322 txs: &'a [&'a RpcTransactionRecord],
1323) -> impl Iterator<Item = &'a RpcTransactionRecord> + 'a {
1324 let (self_loops, chained): (Vec<&RpcTransactionRecord>, Vec<&RpcTransactionRecord>) =
1325 txs.iter().copied().partition(|tx| {
1326 tx.transaction_header.initial_state_commitment()
1327 == tx.transaction_header.final_state_commitment()
1328 });
1329
1330 let final_states: BTreeSet<Word> = chained
1331 .iter()
1332 .map(|tx| tx.transaction_header.final_state_commitment())
1333 .collect();
1334
1335 let mut init_to_tx: BTreeMap<Word, &RpcTransactionRecord> = chained
1336 .iter()
1337 .map(|tx| (tx.transaction_header.initial_state_commitment(), *tx))
1338 .collect();
1339
1340 let start = chained
1341 .iter()
1342 .find(|tx| !final_states.contains(&tx.transaction_header.initial_state_commitment()))
1343 .copied();
1344
1345 assert!(start.is_some() || chained.is_empty(), "cannot walk cyclic execution chain");
1346
1347 let mut current =
1348 start.and_then(|tx| init_to_tx.remove(&tx.transaction_header.initial_state_commitment()));
1349 let mut self_loops_iter = self_loops.into_iter();
1350
1351 core::iter::from_fn(move || {
1352 if let Some(tx) = current {
1353 current = init_to_tx.remove(&tx.transaction_header.final_state_commitment());
1354 return Some(tx);
1355 }
1356 self_loops_iter.next()
1357 })
1358}
1359
1360fn derive_account_commitments(
1365 transaction_records: &[RpcTransactionRecord],
1366) -> Vec<(AccountId, Word)> {
1367 let mut latest_by_account: BTreeMap<AccountId, (BlockNumber, Word)> = BTreeMap::new();
1368
1369 for ((account_id, block_num), txs) in &group_txs_by_account_block(transaction_records) {
1370 let terminal_state = walk_execution_chain(txs)
1371 .last()
1372 .expect("account must have a final state")
1373 .transaction_header
1374 .final_state_commitment();
1375
1376 latest_by_account
1377 .entry(*account_id)
1378 .and_modify(|(existing_block, existing_state)| {
1379 if *block_num > *existing_block {
1380 *existing_block = *block_num;
1381 *existing_state = terminal_state;
1382 }
1383 })
1384 .or_insert((*block_num, terminal_state));
1385 }
1386
1387 latest_by_account
1388 .into_iter()
1389 .map(|(account_id, (_, state))| (account_id, state))
1390 .collect()
1391}
1392
1393fn compute_ordered_nullifiers(transaction_records: &[RpcTransactionRecord]) -> Vec<Nullifier> {
1400 let mut result = Vec::new();
1401
1402 for txs in group_txs_by_account_block(transaction_records).values() {
1403 for tx in walk_execution_chain(txs) {
1404 for commitment in tx.transaction_header.input_notes().iter() {
1405 result.push(commitment.nullifier());
1406 }
1407 }
1408 }
1409
1410 result
1411}
1412
1413#[cfg(all(test, feature = "testing"))]
1414mod tests {
1415 use alloc::collections::BTreeSet;
1416 use alloc::sync::Arc;
1417
1418 use async_trait::async_trait;
1419 use miden_protocol::account::Account;
1420 use miden_protocol::assembly::DefaultSourceManager;
1421 use miden_protocol::asset::{Asset, FungibleAsset};
1422 use miden_protocol::block::BlockNumber;
1423 use miden_protocol::crypto::merkle::MerklePath;
1424 use miden_protocol::crypto::merkle::mmr::{Forest, InOrderIndex, PartialMmr};
1425 use miden_protocol::note::{
1426 Note,
1427 NoteAssets,
1428 NoteAttachment,
1429 NoteAttachments,
1430 NoteDetails,
1431 NoteHeader,
1432 NoteMetadata,
1433 NoteRecipient,
1434 NoteStorage,
1435 NoteTag,
1436 NoteType,
1437 PartialNoteMetadata,
1438 };
1439 use miden_protocol::testing::account_id::{
1440 ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET,
1441 ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
1442 ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE,
1443 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
1444 ACCOUNT_ID_SENDER,
1445 };
1446 use miden_protocol::transaction::{InputNotes, TransactionArgs, TransactionHeader};
1447 use miden_protocol::vm::AdviceMap;
1448 use miden_protocol::{EMPTY_WORD, Felt, Word, ZERO};
1449 use miden_standards::code_builder::CodeBuilder;
1450 use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint};
1451 use miden_testing::{MockChainBuilder, MockTransactionInput};
1452
1453 use super::*;
1454 use crate::store::{OutputNoteRecord, OutputNoteState};
1455 use crate::test_utils::mock::MockRpcApi;
1456
1457 struct MockScreener;
1459
1460 #[async_trait(?Send)]
1461 impl OnNoteReceived for MockScreener {
1462 async fn on_note_received(
1463 &self,
1464 _committed_note: CommittedNote,
1465 _public_note: Option<InputNoteRecord>,
1466 ) -> Result<NoteUpdateAction, ClientError> {
1467 Ok(NoteUpdateAction::Discard)
1468 }
1469 }
1470
1471 struct AlwaysRelevantObserver;
1473
1474 #[async_trait(?Send)]
1475 impl NoteObserver for AlwaysRelevantObserver {
1476 fn name(&self) -> &'static str {
1477 "always-relevant"
1478 }
1479
1480 async fn observe(
1481 &self,
1482 _committed_note: &CommittedNote,
1483 _attachments: &NoteAttachments,
1484 ) -> Result<bool, ClientError> {
1485 Ok(true)
1486 }
1487 }
1488
1489 fn empty() -> StateSyncInput {
1490 StateSyncInput {
1491 accounts: vec![],
1492 note_tags: BTreeSet::new(),
1493 input_notes: vec![],
1494 output_notes: vec![],
1495 uncommitted_transactions: vec![],
1496 }
1497 }
1498
1499 fn word(n: u64) -> miden_protocol::Word {
1500 [
1501 Felt::new(n).expect("test value should fit into the base field"),
1502 ZERO,
1503 ZERO,
1504 ZERO,
1505 ]
1506 .into()
1507 }
1508
1509 fn header_with_account_root(header: &BlockHeader, account_root: Word) -> BlockHeader {
1510 BlockHeader::new(
1511 header.version(),
1512 header.prev_block_commitment(),
1513 header.block_num(),
1514 header.chain_commitment(),
1515 account_root,
1516 header.nullifier_root(),
1517 header.note_root(),
1518 header.tx_commitment(),
1519 header.tx_kernel_commitment(),
1520 header.validator_keys().clone(),
1521 header.fee_parameters().clone(),
1522 header.timestamp(),
1523 )
1524 }
1525
1526 #[tokio::test]
1527 async fn sync_public_accounts_ignores_older_node_snapshot() {
1528 let mut builder = MockChainBuilder::new();
1529 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1530 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1531 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1532 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1533
1534 let local_header =
1537 AccountHeader::new(account.id(), Felt::from(2u32), EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
1538 let current_public_accounts = vec![&local_header];
1539 let commitment_updates = vec![(account.id(), account.to_commitment())];
1540 let mut account_updates = AccountUpdates::default();
1541
1542 let superseded = state_sync
1543 .sync_public_accounts(
1544 &mut account_updates,
1545 &commitment_updates,
1546 ¤t_public_accounts,
1547 BlockNumber::GENESIS,
1548 &chain_tip_header,
1549 )
1550 .await
1551 .unwrap();
1552
1553 assert!(
1554 account_updates.updated_public_accounts().is_empty(),
1555 "public account sync should ignore node snapshots that are older than local"
1556 );
1557 assert!(
1558 superseded.is_empty(),
1559 "an older node snapshot must not supersede the local state"
1560 );
1561 }
1562
1563 #[tokio::test]
1564 async fn sync_public_accounts_marks_same_nonce_mismatch_as_superseded() {
1565 let mut builder = MockChainBuilder::new();
1566 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1567 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1568 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1569 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1570
1571 let local_header =
1574 AccountHeader::new(account.id(), account.nonce(), EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
1575 let current_public_accounts = vec![&local_header];
1576 let commitment_updates = vec![(account.id(), account.to_commitment())];
1577 let mut account_updates = AccountUpdates::default();
1578
1579 let superseded = state_sync
1580 .sync_public_accounts(
1581 &mut account_updates,
1582 &commitment_updates,
1583 ¤t_public_accounts,
1584 BlockNumber::GENESIS,
1585 &chain_tip_header,
1586 )
1587 .await
1588 .unwrap();
1589
1590 assert!(
1591 account_updates.updated_public_accounts().is_empty(),
1592 "a same-nonce fork must not overwrite the account while its tx is still pending"
1593 );
1594 assert_eq!(
1595 superseded,
1596 vec![local_header.to_commitment()],
1597 "the superseded local state should be reported so its transaction is discarded"
1598 );
1599 }
1600
1601 #[test]
1607 fn validate_transaction_records_range_rejects_out_of_range_blocks() {
1608 let account_id: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap();
1609 let current = BlockNumber::from(5u32);
1610 let chain_tip = BlockNumber::from(10u32);
1611
1612 StateSync::validate_transaction_records_range(
1613 &[make_tx_record(account_id, 7)],
1614 current,
1615 chain_tip,
1616 )
1617 .unwrap();
1618
1619 let result = StateSync::validate_transaction_records_range(
1620 &[make_tx_record(account_id, 11)],
1621 current,
1622 chain_tip,
1623 );
1624 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1625
1626 let result = StateSync::validate_transaction_records_range(
1627 &[make_tx_record(account_id, 5)],
1628 current,
1629 chain_tip,
1630 );
1631 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1632 }
1633
1634 #[tokio::test]
1637 async fn verify_private_account_mismatch_ignores_forged_commitment() {
1638 let mut builder = MockChainBuilder::new();
1639 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1640 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1641 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1642 let on_chain_commitment = account.to_commitment();
1643 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1644
1645 let result = state_sync
1646 .verify_private_account_mismatch(account.id(), on_chain_commitment, &chain_tip_header)
1647 .await
1648 .unwrap();
1649
1650 assert!(
1651 result.is_none(),
1652 "an unproven commitment must not lock an account whose on-chain state matches local"
1653 );
1654 }
1655
1656 #[tokio::test]
1659 async fn verify_private_account_mismatch_reports_proven_divergence() {
1660 let mut builder = MockChainBuilder::new();
1661 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1662 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1663 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1664 let on_chain_commitment = account.to_commitment();
1665 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1666 let stale_local_commitment = word(0xdead_beef);
1667
1668 let result = state_sync
1669 .verify_private_account_mismatch(
1670 account.id(),
1671 stale_local_commitment,
1672 &chain_tip_header,
1673 )
1674 .await
1675 .unwrap();
1676
1677 assert_eq!(
1678 result,
1679 Some(on_chain_commitment),
1680 "a proven divergence should return the proven commitment to lock with"
1681 );
1682 }
1683
1684 #[tokio::test]
1687 async fn verify_private_account_mismatch_rejects_unverifiable_proof() {
1688 let mut builder = MockChainBuilder::new();
1689 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1690 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1691 let real_header = rpc_api.mock_chain.read().latest_block_header();
1692 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1693
1694 let tampered_header = BlockHeader::new(
1697 real_header.version(),
1698 real_header.prev_block_commitment(),
1699 real_header.block_num(),
1700 real_header.chain_commitment(),
1701 word(0xbad0_bad0),
1702 real_header.nullifier_root(),
1703 real_header.note_root(),
1704 real_header.tx_commitment(),
1705 real_header.tx_kernel_commitment(),
1706 real_header.validator_keys().clone(),
1707 real_header.fee_parameters().clone(),
1708 real_header.timestamp(),
1709 );
1710
1711 let result = state_sync
1712 .verify_private_account_mismatch(
1713 account.id(),
1714 account.to_commitment(),
1715 &tampered_header,
1716 )
1717 .await;
1718 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1719 }
1720
1721 #[tokio::test]
1724 async fn sync_public_accounts_pins_account_fetch_to_sync_target() {
1725 let mut builder = MockChainBuilder::new();
1726 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1727 let mut chain = builder.build().unwrap();
1728
1729 let sync_target_header = chain.latest_block_header();
1731 let tx = Box::pin(
1732 chain
1733 .build_transaction(MockTransactionInput::AccountId(account.id()))
1734 .build()
1735 .unwrap()
1736 .execute(),
1737 )
1738 .await
1739 .unwrap();
1740 let local_header = tx.final_account().clone();
1741 assert_ne!(local_header.to_commitment(), account.to_commitment());
1742 chain.add_pending_executed_transaction(&tx).unwrap();
1743
1744 let rpc_api = MockRpcApi::new(chain);
1745 rpc_api.prove_block();
1747 assert_eq!(
1748 rpc_api
1749 .mock_chain
1750 .read()
1751 .committed_account(account.id())
1752 .unwrap()
1753 .to_commitment(),
1754 local_header.to_commitment()
1755 );
1756 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1757
1758 let current_public_accounts = vec![&local_header];
1759 let commitment_updates = vec![(account.id(), account.to_commitment())];
1760 let mut account_updates = AccountUpdates::default();
1761
1762 let superseded = state_sync
1763 .sync_public_accounts(
1764 &mut account_updates,
1765 &commitment_updates,
1766 ¤t_public_accounts,
1767 BlockNumber::GENESIS,
1768 &sync_target_header,
1769 )
1770 .await
1771 .unwrap();
1772
1773 assert!(superseded.is_empty(), "the transaction must not be superseded");
1774 assert!(
1775 account_updates.updated_public_accounts().is_empty(),
1776 "the target state must not overwrite the local account"
1777 );
1778 }
1779
1780 async fn get_account_proof(
1782 rpc_api: &MockRpcApi,
1783 account_id: AccountId,
1784 ) -> (BlockNumber, AccountProof) {
1785 rpc_api
1786 .get_account(
1787 account_id,
1788 GetAccountRequest::new()
1789 .with_storage(StorageMapFetch::All)
1790 .with_vault(VaultFetch::Always),
1791 )
1792 .await
1793 .unwrap()
1794 }
1795
1796 #[tokio::test]
1798 async fn validate_account_proof_rejects_mismatched_account() {
1799 let mut builder = MockChainBuilder::new();
1800 let account_a = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1801 let account_b = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1802 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1803 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1804
1805 let (proof_block_num, proof) = get_account_proof(&rpc_api, account_b.id()).await;
1807 let result = StateSync::validate_account_proof(
1808 proof,
1809 proof_block_num,
1810 account_a.id(),
1811 &chain_tip_header,
1812 );
1813
1814 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1815 }
1816
1817 #[tokio::test]
1819 async fn validate_account_proof_rejects_wrong_account_root() {
1820 let mut builder = MockChainBuilder::new();
1821 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1822 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1823 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1824 let wrong_header = header_with_account_root(&chain_tip_header, word(999));
1825
1826 let (proof_block_num, proof) = get_account_proof(&rpc_api, account.id()).await;
1828 let result =
1829 StateSync::validate_account_proof(proof, proof_block_num, account.id(), &wrong_header);
1830
1831 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1832 }
1833
1834 #[tokio::test]
1836 async fn validate_account_proof_rejects_wrong_block() {
1837 let mut builder = MockChainBuilder::new();
1838 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1839 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1840 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1841
1842 let (proof_block_num, proof) = get_account_proof(&rpc_api, account.id()).await;
1844 let result = StateSync::validate_account_proof(
1845 proof,
1846 proof_block_num + 1,
1847 account.id(),
1848 &chain_tip_header,
1849 );
1850
1851 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1852 }
1853
1854 mod compute_nullifiers_tests {
1858 use alloc::vec;
1859
1860 use miden_protocol::block::BlockNumber;
1861 use miden_protocol::note::Nullifier;
1862 use miden_protocol::transaction::{InputNoteCommitment, InputNotes, TransactionHeader};
1863
1864 use super::word;
1865 use crate::rpc::domain::transaction::TransactionRecord as RpcTransactionRecord;
1866
1867 fn make_rpc_tx(
1868 init_state: u64,
1869 final_state: u64,
1870 nullifier_vals: &[u64],
1871 block_number: u32,
1872 ) -> RpcTransactionRecord {
1873 let account_id = miden_protocol::account::AccountId::try_from(
1874 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE,
1875 )
1876 .unwrap();
1877
1878 let input_notes = InputNotes::new_unchecked(
1879 nullifier_vals
1880 .iter()
1881 .map(|v| InputNoteCommitment::from(Nullifier::from_raw(word(*v))))
1882 .collect(),
1883 );
1884
1885 RpcTransactionRecord {
1886 block_num: BlockNumber::from(block_number),
1887 transaction_header: TransactionHeader::new(
1888 account_id,
1889 word(init_state),
1890 word(final_state),
1891 input_notes,
1892 vec![],
1893 ),
1894 output_notes: vec![],
1895 erased_output_notes: vec![],
1896 consumed_note_refs: vec![],
1897 }
1898 }
1899
1900 #[test]
1901 fn chains_rpc_transactions_by_state_commitment() {
1902 let tx_a = make_rpc_tx(1, 2, &[10], 5);
1905 let tx_b = make_rpc_tx(2, 3, &[20], 5);
1906 let tx_c = make_rpc_tx(3, 4, &[30], 5);
1907
1908 let result = super::super::compute_ordered_nullifiers(&[tx_c, tx_a, tx_b]);
1909
1910 assert_eq!(result[0], Nullifier::from_raw(word(10)));
1911 assert_eq!(result[1], Nullifier::from_raw(word(20)));
1912 assert_eq!(result[2], Nullifier::from_raw(word(30)));
1913 }
1914
1915 #[test]
1916 fn groups_independently_by_account_and_block() {
1917 let tx_a1 = make_rpc_tx(1, 2, &[10], 5);
1919 let tx_a2 = make_rpc_tx(2, 3, &[20], 5);
1920
1921 let tx_a3 = make_rpc_tx(3, 4, &[30], 6);
1923
1924 let account_b = miden_protocol::account::AccountId::try_from(
1926 miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
1927 )
1928 .unwrap();
1929
1930 let tx_b1 = RpcTransactionRecord {
1931 block_num: BlockNumber::from(5u32),
1932 transaction_header: TransactionHeader::new(
1933 account_b,
1934 word(100),
1935 word(200),
1936 InputNotes::new_unchecked(vec![InputNoteCommitment::from(
1937 Nullifier::from_raw(word(40)),
1938 )]),
1939 vec![],
1940 ),
1941 output_notes: vec![],
1942 erased_output_notes: vec![],
1943 consumed_note_refs: vec![],
1944 };
1945
1946 let result = super::super::compute_ordered_nullifiers(&[tx_a2, tx_b1, tx_a3, tx_a1]);
1947
1948 let pos = |val: u64| -> usize {
1951 result.iter().position(|n| *n == Nullifier::from_raw(word(val))).unwrap()
1952 };
1953
1954 assert!(pos(10) < pos(20)); assert!(result.contains(&Nullifier::from_raw(word(30)))); assert!(result.contains(&Nullifier::from_raw(word(40)))); }
1960
1961 #[test]
1962 fn multiple_nullifiers_per_transaction_are_consecutive() {
1963 let tx = make_rpc_tx(1, 2, &[10, 20, 30], 5);
1965
1966 let result = super::super::compute_ordered_nullifiers(&[tx]);
1967
1968 assert_eq!(result.len(), 3);
1969 assert!(result.contains(&Nullifier::from_raw(word(10))));
1970 assert!(result.contains(&Nullifier::from_raw(word(20))));
1971 assert!(result.contains(&Nullifier::from_raw(word(30))));
1972 }
1973
1974 #[test]
1975 fn empty_input_returns_empty_vec() {
1976 let result = super::super::compute_ordered_nullifiers(&[]);
1977 assert!(result.is_empty());
1978 }
1979 }
1980
1981 #[test]
1992 fn derive_account_commitments_walks_chains_per_account() {
1993 let make_tx = |account: AccountId, init_state: u64, final_state: u64, block_num: u32| {
1994 RpcTransactionRecord {
1995 block_num: BlockNumber::from(block_num),
1996 transaction_header: TransactionHeader::new(
1997 account,
1998 word(init_state),
1999 word(final_state),
2000 InputNotes::new_unchecked(vec![]),
2001 vec![],
2002 ),
2003 output_notes: vec![],
2004 erased_output_notes: vec![],
2005 consumed_note_refs: vec![],
2006 }
2007 };
2008
2009 let account_a: AccountId =
2010 ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE.try_into().unwrap();
2011 let account_b: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap();
2012
2013 let tx_a_b5_1 = make_tx(account_a, 1, 2, 5);
2014 let tx_a_b5_2 = make_tx(account_a, 2, 3, 5);
2015 let tx_a_b6_1 = make_tx(account_a, 3, 4, 6);
2016 let tx_a_b6_2 = make_tx(account_a, 4, 5, 6);
2017 let tx_b_b6 = make_tx(account_b, 10, 20, 6);
2018
2019 let result = super::derive_account_commitments(&[
2021 tx_a_b6_1, tx_b_b6, tx_a_b5_2, tx_a_b6_2, tx_a_b5_1,
2022 ]);
2023
2024 assert_eq!(result.len(), 2, "one entry per account");
2025 assert!(
2026 result.contains(&(account_a, word(5))),
2027 "account A: must walk block 6's chain, not return block 5 or an intermediate",
2028 );
2029 assert!(
2030 result.contains(&(account_b, word(20))),
2031 "account B: must be resolved independently of account A",
2032 );
2033 }
2034
2035 struct CommitAllScreener;
2041
2042 #[async_trait(?Send)]
2043 impl OnNoteReceived for CommitAllScreener {
2044 async fn on_note_received(
2045 &self,
2046 committed_note: CommittedNote,
2047 _public_note: Option<InputNoteRecord>,
2048 ) -> Result<NoteUpdateAction, ClientError> {
2049 Ok(NoteUpdateAction::Commit(committed_note))
2050 }
2051 }
2052
2053 async fn build_chain_with_chained_consume_txs() -> (miden_testing::MockChain, Account, [Note; 3])
2057 {
2058 let sender_id: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
2059 let faucet_id: AccountId = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into().unwrap();
2060
2061 let mut builder = MockChainBuilder::new();
2062 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2063 let account_id = account.id();
2064
2065 let asset = Asset::Fungible(FungibleAsset::new(faucet_id, 100u64).unwrap());
2066 let note1 = builder
2067 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
2068 .unwrap();
2069 let note2 = builder
2070 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
2071 .unwrap();
2072 let note3 = builder
2073 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
2074 .unwrap();
2075
2076 let mut chain = builder.build().unwrap();
2077 chain.prove_next_block().unwrap(); let mut current_account = account.clone();
2081 for note in [¬e1, ¬e2, ¬e3] {
2082 let tx = Box::pin(
2083 chain
2084 .build_transaction(MockTransactionInput::Account(current_account.clone()))
2085 .unauthenticated_input_note(note.clone())
2086 .build()
2087 .unwrap()
2088 .execute(),
2089 )
2090 .await
2091 .unwrap();
2092 current_account.apply_patch(tx.account_patch()).unwrap();
2093 chain.add_pending_executed_transaction(&tx).unwrap();
2094 }
2095
2096 chain.prove_next_block().unwrap(); (chain, account, [note1, note2, note3])
2098 }
2099
2100 #[tokio::test]
2103 async fn sync_state_sets_consumed_tx_order_for_chained_transactions() {
2104 use miden_protocol::note::NoteMetadata;
2105
2106 let (chain, account, [note1, note2, note3]) = build_chain_with_chained_consume_txs().await;
2107
2108 let mock_rpc = MockRpcApi::new(chain);
2109 let state_sync =
2110 StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(CommitAllScreener), None);
2111
2112 let genesis_peaks =
2113 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2114 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2115
2116 let input_notes: Vec<InputNoteRecord> = [¬e1, ¬e2, ¬e3]
2117 .into_iter()
2118 .map(|n| InputNoteRecord::from(n.clone()))
2119 .collect();
2120
2121 let note_tags: BTreeSet<NoteTag> =
2122 input_notes.iter().filter_map(|n| n.metadata().map(NoteMetadata::tag)).collect();
2123
2124 let account_id = account.id();
2125 let sync_input = StateSyncInput {
2126 accounts: vec![AccountHeader::from(account)],
2127 note_tags,
2128 input_notes,
2129 output_notes: vec![],
2130 uncommitted_transactions: vec![],
2131 };
2132
2133 let update = state_sync.sync_state(&mut partial_mmr, sync_input).await.unwrap();
2134
2135 let updated_notes: Vec<_> = update.note_updates().updated_input_notes().collect();
2136
2137 let find_order = |details_commitment| -> Option<u32> {
2138 updated_notes
2139 .iter()
2140 .find(|n| n.inner().details_commitment() == details_commitment)
2141 .and_then(|n| n.consumed_tx_order())
2142 };
2143
2144 assert_eq!(find_order(note1.details_commitment()), Some(0), "note1 should have tx_order 0");
2145 assert_eq!(find_order(note2.details_commitment()), Some(1), "note2 should have tx_order 1");
2146 assert_eq!(find_order(note3.details_commitment()), Some(2), "note3 should have tx_order 2");
2147
2148 for note in &updated_notes {
2151 let record = note.inner();
2152 assert!(record.is_consumed(), "note should be in a consumed state");
2153 assert_eq!(
2154 record.consumer_account(),
2155 Some(account_id),
2156 "externally-consumed notes by a tracked account should have consumer_account set",
2157 );
2158 }
2159 }
2160
2161 #[tokio::test]
2162 async fn sync_state_across_multiple_iterations_with_same_mmr() {
2163 let mock_rpc = MockRpcApi::default();
2165 mock_rpc.advance_blocks(3);
2166 let chain_tip_1 = mock_rpc.get_chain_tip_block_num();
2167
2168 let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
2169
2170 let genesis_peaks =
2172 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2173 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2174 assert_eq!(partial_mmr.forest().num_leaves(), 1);
2175
2176 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
2178
2179 assert_eq!(update.block_num(), chain_tip_1);
2180 let forest_1 = partial_mmr.forest();
2181 assert_eq!(forest_1.num_leaves(), chain_tip_1.as_u32() as usize + 1);
2183
2184 mock_rpc.advance_blocks(2);
2186 let chain_tip_2 = mock_rpc.get_chain_tip_block_num();
2187
2188 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
2189
2190 assert_eq!(update.block_num(), chain_tip_2);
2191 let forest_2 = partial_mmr.forest();
2192 assert!(forest_2 > forest_1);
2193 assert_eq!(forest_2.num_leaves(), chain_tip_2.as_u32() as usize + 1);
2194
2195 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
2197
2198 assert_eq!(update.block_num(), chain_tip_2);
2199 assert_eq!(partial_mmr.forest(), forest_2);
2200 }
2201
2202 async fn build_chain_with_mint_notes(
2205 num_blocks: u64,
2206 ) -> (miden_testing::MockChain, BTreeSet<NoteTag>) {
2207 let mut builder = MockChainBuilder::new();
2208 let faucet = builder
2209 .add_existing_basic_faucet(
2210 miden_testing::Auth::BasicAuth {
2211 auth_scheme: miden_protocol::account::auth::AuthScheme::Falcon512Poseidon2,
2212 },
2213 "TST",
2214 10_000,
2215 None,
2216 )
2217 .unwrap();
2218 let _target = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2219 let mut chain = builder.build().unwrap();
2220
2221 let note_script = CodeBuilder::new()
2226 .compile_note_script("@note_script\npub proc main\n nop\nend")
2227 .unwrap();
2228 let note_recipient = NoteRecipient::new(
2229 Word::from([1u32, 2, 3, 4]),
2230 note_script,
2231 NoteStorage::new(vec![]).unwrap(),
2232 );
2233 let recipient = note_recipient.digest();
2234 let note_details = NoteDetails::new(NoteAssets::new(vec![]).unwrap(), note_recipient);
2237 let mut recipient_args = TransactionArgs::new(AdviceMap::default());
2238 recipient_args.add_output_note_recipient(¬e_details);
2239 let recipient_advice = recipient_args.advice_inputs().clone();
2240
2241 let tag = NoteTag::default();
2242 let mut faucet_account = faucet.clone();
2243 let mut note_tags = BTreeSet::new();
2244
2245 for i in 0..num_blocks {
2246 let amount = 100 + i;
2247 let source_manager = Arc::new(DefaultSourceManager::default());
2248 let mint_asset = FungibleAsset::new(faucet_account.id(), amount).unwrap();
2252 let asset_id_word = mint_asset.id().to_word();
2253 let asset_value_word = mint_asset.to_value_word();
2254 let tx_script_code = format!(
2255 "
2256 @transaction_script
2257 pub proc main
2258 push.{recipient}
2259 push.{note_type}
2260 push.{tag}
2261 push.{asset_value}
2262 push.{asset_id}
2263 call.::miden::standards::faucets::fungible::mint_and_send
2264 dropw dropw dropw dropw
2265 end
2266 ",
2267 recipient = recipient,
2268 note_type = NoteType::Private as u8,
2269 tag = u32::from(tag),
2270 asset_value = asset_value_word,
2271 asset_id = asset_id_word,
2272 );
2273 let tx_script = CodeBuilder::with_source_manager(source_manager.clone())
2274 .compile_tx_script(tx_script_code)
2275 .unwrap();
2276 let tx = Box::pin(
2277 chain
2278 .build_transaction(miden_testing::MockTransactionInput::Account(
2279 faucet_account.clone(),
2280 ))
2281 .extend_advice_inputs(recipient_advice.clone())
2282 .tx_script(tx_script)
2283 .with_source_manager(source_manager)
2284 .build()
2285 .unwrap()
2286 .execute(),
2287 )
2288 .await
2289 .unwrap();
2290
2291 for output_note in tx.output_notes().iter() {
2292 note_tags.insert(output_note.metadata().tag());
2293 }
2294
2295 faucet_account.apply_patch(tx.account_patch()).unwrap();
2296 chain.add_pending_executed_transaction(&tx).unwrap();
2297 chain.prove_next_block().unwrap();
2298 }
2299
2300 (chain, note_tags)
2301 }
2302
2303 #[tokio::test]
2306 async fn observer_relevance_persists_discarded_note_block() {
2307 let (chain, note_tags) = build_chain_with_mint_notes(2).await;
2308 let mock_rpc = MockRpcApi::new(chain);
2309 let chain_tip = mock_rpc.get_chain_tip_block_num();
2310
2311 let genesis_peaks =
2312 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2313 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2314
2315 let state_sync = StateSync::new(Arc::new(mock_rpc), Arc::new(MockScreener), None)
2316 .with_note_observer(Arc::new(AlwaysRelevantObserver));
2317 let mut input = empty();
2318 input.note_tags = note_tags;
2319
2320 let update = state_sync.sync_state(&mut partial_mmr, input).await.unwrap();
2321 let observed_non_tip_block = BlockNumber::from(1u32);
2322
2323 assert!(
2324 update.partial_blockchain_updates().block_headers_to_store(chain_tip).any(
2325 |(header, is_relevant)| {
2326 header.block_num() == observed_non_tip_block && *is_relevant
2327 }
2328 ),
2329 "an observer-relevant block must be staged as relevant"
2330 );
2331 assert!(
2332 partial_mmr.is_tracked(observed_non_tip_block.as_usize()),
2333 "an observer-relevant block must remain tracked in the partial MMR"
2334 );
2335 }
2336
2337 #[tokio::test]
2347 async fn sync_state_tracks_note_blocks_in_mmr() {
2348 let (chain, note_tags) = build_chain_with_mint_notes(3).await;
2349 let mock_rpc = MockRpcApi::new(chain);
2350 let chain_tip = mock_rpc.get_chain_tip_block_num();
2351
2352 let note_blocks = mock_rpc
2354 .sync_notes(BlockNumber::from(0u32), chain_tip, ¬e_tags)
2355 .await
2356 .unwrap();
2357 assert!(
2358 note_blocks.len() >= 2,
2359 "expected notes in multiple blocks, got {}",
2360 note_blocks.len()
2361 );
2362
2363 let note_block_nums: BTreeSet<BlockNumber> =
2365 note_blocks.iter().map(|b| b.block_header.block_num()).collect();
2366
2367 let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
2370
2371 let genesis_peaks =
2372 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2373 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2374
2375 let sync_data = state_sync
2376 .fetch_sync_data(BlockNumber::GENESIS, &[], &Arc::new(note_tags.clone()))
2377 .await
2378 .unwrap()
2379 .expect("should have progressed past genesis");
2380
2381 assert_eq!(sync_data.chain_tip_header.block_num(), chain_tip);
2383 assert!(!sync_data.note_blocks.is_empty(), "should have note blocks");
2384
2385 let _auth_nodes: Vec<(InOrderIndex, Word)> =
2387 partial_mmr.apply(sync_data.mmr_delta).map_err(StoreError::MmrError).unwrap();
2388 partial_mmr
2389 .add(sync_data.chain_tip_header.commitment(), false)
2390 .expect("chain tip should append to the partial MMR");
2391
2392 assert_eq!(partial_mmr.forest().num_leaves(), chain_tip.as_u32() as usize + 1);
2393
2394 for block in &sync_data.note_blocks {
2396 let bn = block.block_header.block_num();
2397 partial_mmr
2398 .track(bn.as_usize(), block.block_header.commitment(), &block.mmr_path)
2399 .map_err(StoreError::MmrError)
2400 .unwrap();
2401
2402 assert!(
2403 partial_mmr.is_tracked(bn.as_usize()),
2404 "block {bn} should be tracked after calling track()"
2405 );
2406 }
2407
2408 for &bn in ¬e_block_nums {
2410 assert!(
2411 partial_mmr.is_tracked(bn.as_usize()),
2412 "block {bn} with notes should be tracked in partial MMR"
2413 );
2414 }
2415 }
2416
2417 #[tokio::test]
2418 async fn sync_notes_with_content_fetches_inclusive_upper_bound_page() {
2419 let (chain, note_tags) = build_chain_with_mint_notes(10).await;
2420 let mock_rpc = MockRpcApi::new(chain);
2421
2422 let blocks = mock_rpc
2423 .sync_notes_with_content(
2424 4_u32.into(),
2425 10_u32.into(),
2426 ¬e_tags,
2427 NoteContentFetch::PublicDetailsAndAttachments,
2428 )
2429 .await
2430 .expect("sync notes should succeed");
2431
2432 assert_eq!(blocks.last().unwrap().block_header.block_num(), BlockNumber::from(10u32));
2433 assert!(
2434 blocks
2435 .iter()
2436 .any(|block| block.block_header.block_num() == BlockNumber::from(9u32))
2437 );
2438 }
2439
2440 #[tokio::test]
2446 async fn erased_notes_are_marked_as_consumed() {
2447 let sender_id: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
2449 let partial_metadata = PartialNoteMetadata::new(sender_id, NoteType::Public);
2450 let metadata = NoteMetadata::new(partial_metadata, &NoteAttachments::empty());
2451 let script = CodeBuilder::new()
2452 .compile_note_script("@note_script\npub proc main\n nop\nend")
2453 .unwrap();
2454 let recipient = NoteRecipient::new(
2455 Word::from([1u32, 2, 3, 4]),
2456 script,
2457 NoteStorage::new(vec![]).unwrap(),
2458 );
2459 let output_note = OutputNoteRecord::new(
2460 recipient.digest(),
2461 NoteAssets::new(vec![]).unwrap(),
2462 metadata,
2463 OutputNoteState::ExpectedFull { recipient },
2464 BlockNumber::from(1u32),
2465 NoteAttachments::default(),
2466 );
2467 let note_id = output_note.id();
2468 let note_header = NoteHeader::new(output_note.details_commitment(), metadata);
2469
2470 let mut note_updates = NoteUpdateTracker::new(vec![], vec![output_note]);
2472
2473 let block_num = BlockNumber::from(3u32);
2475 note_updates
2476 .mark_erased_note_as_consumed(¬e_header, block_num)
2477 .expect("marking erased note should succeed");
2478
2479 let updated = note_updates
2480 .updated_output_notes()
2481 .find(|n| n.id() == note_id)
2482 .expect("output note should be in the update");
2483
2484 assert!(
2485 updated.inner().is_consumed(),
2486 "output note should be consumed after erasure detection, but state is: {}",
2487 updated.inner().state()
2488 );
2489 }
2490
2491 #[allow(clippy::too_many_lines)]
2508 #[ignore = "consumer derivation removed; see comment above"]
2509 #[tokio::test]
2510 async fn erased_notes_are_marked_as_consumed_by_network_account() {
2511 let mut builder = MockChainBuilder::new();
2514 let p2id_sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
2515 let faucet_id: AccountId = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into().unwrap();
2516 let sender_account =
2517 builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2518 let sender_id = sender_account.id();
2519
2520 let asset = Asset::Fungible(FungibleAsset::new(faucet_id, 100u64).unwrap());
2521 let note = builder
2522 .add_p2id_note(p2id_sender, sender_id, &[asset], NoteType::Public)
2523 .unwrap();
2524
2525 let mut chain = builder.build().unwrap();
2526 chain.prove_next_block().unwrap();
2527
2528 let tx = Box::pin(
2529 chain
2530 .build_transaction(MockTransactionInput::Account(sender_account.clone()))
2531 .unauthenticated_input_note(note.clone())
2532 .build()
2533 .unwrap()
2534 .execute(),
2535 )
2536 .await
2537 .unwrap();
2538 chain.add_pending_executed_transaction(&tx).unwrap();
2539 chain.prove_next_block().unwrap();
2540
2541 let network_account_id: AccountId =
2543 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into().unwrap();
2544 let target =
2545 NetworkAccountTarget::new(network_account_id, NoteExecutionHint::Always).unwrap();
2546 let attachment: NoteAttachment = target.into();
2547 let attachments = NoteAttachments::new(vec![attachment]).unwrap();
2548 let partial_metadata = PartialNoteMetadata::new(sender_id, NoteType::Public);
2549 let metadata = NoteMetadata::new(partial_metadata, &attachments);
2550 let script = CodeBuilder::new()
2551 .compile_note_script("@note_script\npub proc main\n nop\nend")
2552 .unwrap();
2553 let recipient = NoteRecipient::new(
2554 Word::from([7u32, 8, 9, 10]),
2555 script,
2556 NoteStorage::new(vec![]).unwrap(),
2557 );
2558 let recipient_digest = recipient.digest();
2559 let assets = NoteAssets::new(vec![]).unwrap();
2560
2561 let output_note = OutputNoteRecord::new(
2564 recipient_digest,
2565 assets.clone(),
2566 metadata,
2567 OutputNoteState::ExpectedFull { recipient },
2568 BlockNumber::from(1u32),
2569 NoteAttachments::default(),
2570 );
2571 let erased_note_id = output_note.id();
2572 let erased_note_header = NoteHeader::new(output_note.details_commitment(), metadata);
2573
2574 let mock_rpc = MockRpcApi::new(chain);
2575 mock_rpc.mark_note_as_erased(erased_note_header);
2576
2577 let network_header =
2580 AccountHeader::new(network_account_id, ZERO, EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
2581
2582 let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
2583
2584 let genesis_peaks =
2585 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2586 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2587
2588 let sync_input = StateSyncInput {
2589 accounts: vec![AccountHeader::from(sender_account), network_header],
2590 note_tags: BTreeSet::new(),
2591 input_notes: vec![],
2592 output_notes: vec![output_note],
2593 uncommitted_transactions: vec![],
2594 };
2595
2596 let update = state_sync.sync_state(&mut partial_mmr, sync_input).await.unwrap();
2597
2598 let updated_output = update
2600 .note_updates()
2601 .updated_output_notes()
2602 .find(|n| n.id() == erased_note_id)
2603 .expect("output note should be in the update");
2604 assert!(
2605 updated_output.inner().is_consumed(),
2606 "output note should be consumed, got: {}",
2607 updated_output.inner().state()
2608 );
2609
2610 let input_note_update = update
2612 .note_updates()
2613 .updated_input_notes()
2614 .find(|n| n.id() == Some(erased_note_id))
2615 .expect("input note should be created from the erased output note");
2616
2617 let inner = input_note_update.inner();
2618 assert!(
2619 inner.is_consumed(),
2620 "input note should be in a consumed state, got: {}",
2621 inner.state()
2622 );
2623 assert_eq!(
2624 inner.consumer_account(),
2625 Some(network_account_id),
2626 "consumer should be the tracked network account"
2627 );
2628 }
2629
2630 #[tokio::test]
2633 async fn validate_chain_mmr_response_rejects_tampered_responses() {
2634 let mock_rpc = MockRpcApi::default();
2635 mock_rpc.advance_blocks(3);
2636 let chain_tip = mock_rpc.get_chain_tip_block_num();
2637 let current = BlockNumber::GENESIS;
2638
2639 let header_of =
2640 |block_num: u32| mock_rpc.mock_chain.read().block_header(block_num as usize);
2641 let chain_mmr_response = || async {
2642 mock_rpc.sync_chain_mmr(current, SyncTarget::CommittedChainTip).await.unwrap()
2643 };
2644
2645 let response = chain_mmr_response().await;
2647 StateSync::validate_chain_mmr_response(&response, current).unwrap();
2648
2649 let mut response = chain_mmr_response().await;
2651 response.block_header = header_of(chain_tip.as_u32() - 1);
2652 let result = StateSync::validate_chain_mmr_response(&response, current);
2653 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2654
2655 let mut response = chain_mmr_response().await;
2657 response.block_from = current + 1;
2658 let result = StateSync::validate_chain_mmr_response(&response, current);
2659 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2660
2661 let mut response = chain_mmr_response().await;
2663 response.block_from = chain_tip;
2664 response.block_to = BlockNumber::GENESIS;
2665 response.block_header = header_of(0);
2666 let result = StateSync::validate_chain_mmr_response(&response, chain_tip);
2667 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2668 }
2669
2670 #[test]
2673 fn validate_note_blocks_range_rejects_out_of_range_blocks() {
2674 let mock_rpc = MockRpcApi::default();
2675 mock_rpc.advance_blocks(3);
2676 let chain_tip = mock_rpc.get_chain_tip_block_num();
2677 let current = BlockNumber::GENESIS;
2678
2679 StateSync::validate_note_blocks_range(&[], current, chain_tip).unwrap();
2681
2682 let genesis_note_block = ResolvedSyncNotesBlock {
2684 block_header: mock_rpc.mock_chain.read().block_header(0),
2685 mmr_path: MerklePath::new(Vec::new()),
2686 notes: BTreeMap::new(),
2687 };
2688 let result =
2689 StateSync::validate_note_blocks_range(&[genesis_note_block], current, chain_tip);
2690 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2691 }
2692
2693 #[test]
2696 fn advance_mmr_rejects_delta_inconsistent_with_chain_commitment() {
2697 let mock_rpc = MockRpcApi::default();
2698 mock_rpc.advance_blocks(3);
2699 let chain_tip = mock_rpc.get_chain_tip_block_num();
2700
2701 let chain_tip_header = mock_rpc.mock_chain.read().block_header(chain_tip.as_usize());
2702 let genesis_partial_mmr = || {
2703 let peaks = mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2704 PartialMmr::from_peaks(peaks)
2705 };
2706
2707 let full_delta = mock_rpc
2709 .get_mmr()
2710 .get_delta(Forest::new(1).unwrap(), Forest::new(chain_tip.as_usize()).unwrap())
2711 .unwrap();
2712 StateSync::advance_mmr(
2713 full_delta,
2714 &chain_tip_header,
2715 &mut genesis_partial_mmr(),
2716 &mut PartialBlockchainUpdates::default(),
2717 )
2718 .unwrap();
2719
2720 let truncated_delta = mock_rpc
2722 .get_mmr()
2723 .get_delta(Forest::new(1).unwrap(), Forest::new(chain_tip.as_usize() - 1).unwrap())
2724 .unwrap();
2725 let result = StateSync::advance_mmr(
2726 truncated_delta,
2727 &chain_tip_header,
2728 &mut genesis_partial_mmr(),
2729 &mut PartialBlockchainUpdates::default(),
2730 );
2731 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2732 }
2733
2734 fn make_tx_record(account_id: AccountId, block_num: u32) -> RpcTransactionRecord {
2736 RpcTransactionRecord {
2737 block_num: BlockNumber::from(block_num),
2738 transaction_header: TransactionHeader::new(
2739 account_id,
2740 word(1),
2741 word(2),
2742 InputNotes::new_unchecked(vec![]),
2743 vec![],
2744 ),
2745 output_notes: vec![],
2746 erased_output_notes: vec![],
2747 consumed_note_refs: vec![],
2748 }
2749 }
2750}