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) {
238 self.sync_nullifiers = false;
239 }
240
241 pub fn enable_nullifier_sync(&mut self) {
243 self.sync_nullifiers = true;
244 }
245
246 pub(crate) async fn run_apply_hooks(
251 &self,
252 state_sync_update: &StateSyncUpdate,
253 ) -> Result<(), ClientError> {
254 for observer in &self.note_observers {
255 crate::errors::log_observer_failure(
256 observer.name(),
257 "NoteObserver::apply",
258 observer.apply(state_sync_update).await,
259 );
260 }
261 Ok(())
262 }
263
264 pub async fn sync_state(
287 &self,
288 current_partial_mmr: &mut PartialMmr,
289 input: StateSyncInput,
290 ) -> Result<StateSyncUpdate, ClientError> {
291 let block_num = block_num_from_forest(current_partial_mmr)?;
292
293 let mut chain_sync_data = self.fetch_state(block_num, input).await?;
294 self.derive_state_updates(&mut chain_sync_data).await?;
295 self.fetch_nullifiers(&mut chain_sync_data).await?;
296
297 let mut working_mmr = current_partial_mmr.clone();
299 let update = Self::build_update(chain_sync_data, &mut working_mmr)?;
300 *current_partial_mmr = working_mmr;
301
302 Ok(update)
303 }
304
305 pub async fn fetch_state(
314 &self,
315 block_from: BlockNumber,
316 input: StateSyncInput,
317 ) -> Result<ChainSyncData, ClientError> {
318 let StateSyncInput {
319 accounts,
320 note_tags,
321 input_notes,
322 output_notes,
323 uncommitted_transactions,
324 } = input;
325
326 let note_tags = Arc::new(note_tags);
327 let account_ids: Vec<AccountId> = accounts.iter().map(AccountHeader::id).collect();
328
329 let note_updates = NoteUpdateTracker::new(input_notes, output_notes);
330 let transaction_updates = TransactionUpdateTracker::new(uncommitted_transactions);
331 let mut account_updates = AccountUpdates::default();
332
333 let Some(sync_data) = self.fetch_sync_data(block_from, &account_ids, ¬e_tags).await?
334 else {
335 return Ok(ChainSyncData {
337 block_from,
338 advance: None,
339 superseded_states: Vec::new(),
340 note_updates,
341 transaction_updates,
342 account_updates,
343 });
344 };
345
346 let FetchedSyncData {
347 mmr_delta,
348 chain_tip_header,
349 note_blocks,
350 transactions,
351 } = sync_data;
352
353 let new_commitments = derive_account_commitments(&transactions);
354 let superseded_states = self
355 .account_state_sync(
356 &mut account_updates,
357 &accounts,
358 &new_commitments,
359 block_from,
360 &chain_tip_header,
361 )
362 .await?;
363
364 Ok(ChainSyncData {
365 block_from,
366 advance: Some(ChainAdvance {
367 chain_tip_header,
368 mmr_delta,
369 note_blocks_awaiting_screening: note_blocks,
370 transactions,
371 relevant_note_blocks: Vec::new(),
372 }),
373 superseded_states,
374 note_updates,
375 transaction_updates,
376 account_updates,
377 })
378 }
379
380 pub async fn derive_state_updates(
386 &self,
387 chain_sync_data: &mut ChainSyncData,
388 ) -> Result<(), ClientError> {
389 let ChainSyncData {
390 advance,
391 superseded_states,
392 note_updates,
393 transaction_updates,
394 ..
395 } = chain_sync_data;
396
397 let Some(advance) = advance.as_mut() else {
398 return Ok(());
399 };
400
401 for superseded_state in core::mem::take(superseded_states) {
403 transaction_updates.apply_superseded_account_state(superseded_state);
404 }
405
406 advance.relevant_note_blocks = self
407 .screen_note_blocks(
408 core::mem::take(&mut advance.note_blocks_awaiting_screening),
409 note_updates,
410 )
411 .await?;
412
413 self.apply_transactions_and_nullifiers(
414 &advance.chain_tip_header,
415 &advance.transactions,
416 note_updates,
417 transaction_updates,
418 )?;
419
420 self.recover_consumed_public_notes(note_updates, &advance.transactions).await?;
421
422 Ok(())
423 }
424
425 pub fn build_update(
431 chain_sync_data: ChainSyncData,
432 partial_mmr: &mut PartialMmr,
433 ) -> Result<StateSyncUpdate, ClientError> {
434 let ChainSyncData {
435 block_from,
436 advance,
437 note_updates,
438 transaction_updates,
439 account_updates,
440 ..
441 } = chain_sync_data;
442
443 let mut partial_blockchain_updates = PartialBlockchainUpdates::default();
444
445 let Some(ChainAdvance {
446 chain_tip_header,
447 mmr_delta,
448 note_blocks_awaiting_screening,
449 relevant_note_blocks,
450 ..
451 }) = advance
452 else {
453 return Ok(StateSyncUpdate::from_parts(
455 block_from,
456 partial_blockchain_updates,
457 note_updates,
458 transaction_updates,
459 account_updates,
460 ));
461 };
462 if !note_blocks_awaiting_screening.is_empty() {
464 return Err(ClientError::UnscreenedNoteBlocks);
465 }
466
467 let chain_tip = chain_tip_header.block_num();
468
469 Self::advance_mmr(
470 mmr_delta,
471 &chain_tip_header,
472 partial_mmr,
473 &mut partial_blockchain_updates,
474 )?;
475
476 let blocks_with_unspent_notes: BTreeSet<BlockNumber> =
477 note_updates.unspent_input_note_block_numbers().collect();
478
479 Self::validate_and_track_note_blocks(
480 relevant_note_blocks,
481 &blocks_with_unspent_notes,
482 partial_mmr,
483 &mut partial_blockchain_updates,
484 )?;
485
486 Ok(StateSyncUpdate::from_parts(
487 chain_tip,
488 partial_blockchain_updates,
489 note_updates,
490 transaction_updates,
491 account_updates,
492 ))
493 }
494
495 pub async fn fetch_nullifiers(
504 &self,
505 chain_sync_data: &mut ChainSyncData,
506 ) -> Result<(), ClientError> {
507 if !self.sync_nullifiers {
508 return Ok(());
509 }
510
511 let Some(chain_tip) = chain_sync_data
512 .advance
513 .as_ref()
514 .map(|advance| advance.chain_tip_header.block_num())
515 else {
516 return Ok(());
517 };
518
519 self.nullifiers_state_sync(
520 &mut chain_sync_data.note_updates,
521 &mut chain_sync_data.transaction_updates,
522 chain_tip,
523 chain_sync_data.block_from,
524 )
525 .await
526 }
527
528 async fn recover_consumed_public_notes(
533 &self,
534 note_updates: &mut NoteUpdateTracker,
535 transactions: &[RpcTransactionRecord],
536 ) -> Result<(), ClientError> {
537 let mut recoverable_consumed_notes: BTreeMap<NoteId, RecoverableConsumedNote> =
538 BTreeMap::new();
539 for tx in transactions {
540 for (nullifier, note_id) in tx.trusted_consumed_note_refs() {
541 recoverable_consumed_notes.insert(
542 note_id,
543 RecoverableConsumedNote {
544 nullifier,
545 consumer: tx.transaction_header.account_id(),
546 block_num: tx.block_num,
547 },
548 );
549 }
550 }
551 recoverable_consumed_notes.retain(|note_id, _| !note_updates.tracks_note(*note_id));
554
555 let note_ids: Vec<NoteId> = recoverable_consumed_notes.keys().copied().collect();
556 if note_ids.is_empty() {
557 return Ok(());
558 }
559
560 for fetched in self.rpc_api.get_notes_by_id(¬e_ids).await? {
561 match fetched {
562 FetchedNote::Public(note, _) => {
563 let Some(reference) = recoverable_consumed_notes.get(¬e.id()) else {
564 continue;
565 };
566 if note.nullifier() != reference.nullifier {
569 return Err(RpcError::InvalidResponse(format!(
570 "node returned note {} whose nullifier doesn't match the consumed reference",
571 note.id()
572 ))
573 .into());
574 }
575 note_updates.insert_consumed_public_note(
576 note,
577 reference.consumer,
578 reference.block_num,
579 )?;
580 },
581 FetchedNote::Private(note_id, ..) => {
582 return Err(RpcError::InvalidResponse(format!(
583 "node returned private note {note_id} for a public consumed-note reference"
584 ))
585 .into());
586 },
587 }
588 }
589
590 Ok(())
591 }
592
593 async fn fetch_sync_data(
603 &self,
604 current_block_num: BlockNumber,
605 account_ids: &[AccountId],
606 note_tags: &Arc<BTreeSet<NoteTag>>,
607 ) -> Result<Option<FetchedSyncData>, ClientError> {
608 let chain_mmr_info = self
610 .rpc_api
611 .sync_chain_mmr(current_block_num, SyncTarget::CommittedChainTip)
612 .await?;
613 let chain_tip = chain_mmr_info.block_to;
614
615 Self::validate_chain_mmr_response(&chain_mmr_info, current_block_num)?;
617
618 if chain_tip == current_block_num {
620 info!(block_num = %current_block_num, "Already at chain tip, nothing to sync.");
621 return Ok(None);
622 }
623
624 info!(
625 block_from = %current_block_num,
626 block_to = %chain_tip,
627 "Syncing state.",
628 );
629
630 let (note_blocks, transaction_records) = futures::try_join!(
633 self.fetch_note_blocks(current_block_num + 1, chain_tip, note_tags),
634 self.fetch_transactions(current_block_num + 1, chain_tip, account_ids),
635 )?;
636
637 Self::validate_note_blocks_range(¬e_blocks, current_block_num, chain_tip)?;
639
640 let note_count: usize = note_blocks.iter().map(|b| b.notes.len()).sum();
641 info!(
642 blocks_with_notes = note_blocks.len(),
643 notes = note_count,
644 "Fetched note sync data.",
645 );
646
647 Self::validate_transaction_records_range(
648 &transaction_records,
649 current_block_num,
650 chain_tip,
651 )?;
652
653 Ok(Some(FetchedSyncData {
654 mmr_delta: chain_mmr_info.mmr_delta,
655 chain_tip_header: chain_mmr_info.block_header,
656 note_blocks,
657 transactions: transaction_records,
658 }))
659 }
660
661 async fn fetch_note_blocks(
667 &self,
668 block_from: BlockNumber,
669 block_to: BlockNumber,
670 note_tags: &BTreeSet<NoteTag>,
671 ) -> Result<Vec<ResolvedSyncNotesBlock>, ClientError> {
672 if note_tags.is_empty() {
673 return Ok(Vec::new());
674 }
675
676 self.rpc_api
677 .sync_notes_with_content(
678 block_from,
679 block_to,
680 note_tags,
681 NoteContentFetch::PublicDetailsAndAttachments,
682 )
683 .await
684 .map_err(ClientError::RpcError)
685 }
686
687 async fn fetch_transactions(
689 &self,
690 block_from: BlockNumber,
691 block_to: BlockNumber,
692 account_ids: &[AccountId],
693 ) -> Result<Vec<RpcTransactionRecord>, ClientError> {
694 if account_ids.is_empty() {
695 return Ok(Vec::new());
696 }
697
698 self.rpc_api
699 .sync_transactions(block_from, block_to, account_ids.to_vec())
700 .await
701 .map_err(ClientError::RpcError)
702 }
703
704 fn validate_chain_mmr_response(
706 chain_mmr_info: &ChainMmrInfo,
707 current_block_num: BlockNumber,
708 ) -> Result<(), ClientError> {
709 if chain_mmr_info.block_header.block_num() != chain_mmr_info.block_to {
710 return Err(ClientError::ChainValidationError(format!(
711 "sync_chain_mmr block_header.block_num ({}) does not match block_to ({})",
712 chain_mmr_info.block_header.block_num(),
713 chain_mmr_info.block_to
714 )));
715 }
716 if chain_mmr_info.block_from != current_block_num {
717 return Err(ClientError::ChainValidationError(format!(
718 "sync_chain_mmr block_from mismatch: expected {current_block_num}, got {}",
719 chain_mmr_info.block_from
720 )));
721 }
722 if chain_mmr_info.block_to < current_block_num {
723 return Err(ClientError::ChainValidationError(format!(
724 "sync_chain_mmr block_to ({}) is behind current block {current_block_num}",
725 chain_mmr_info.block_to
726 )));
727 }
728 Ok(())
729 }
730
731 fn validate_note_blocks_range(
734 note_blocks: &[ResolvedSyncNotesBlock],
735 current_block_num: BlockNumber,
736 chain_tip: BlockNumber,
737 ) -> Result<(), ClientError> {
738 for block in note_blocks {
739 let block_num = block.block_header.block_num();
740 if block_num <= current_block_num || block_num > chain_tip {
741 return Err(ClientError::ChainValidationError(format!(
742 "sync_notes returned block {block_num} outside requested range ({current_block_num}, {chain_tip}]"
743 )));
744 }
745 }
746 Ok(())
747 }
748
749 fn validate_transaction_records_range(
752 records: &[RpcTransactionRecord],
753 current_block_num: BlockNumber,
754 chain_tip: BlockNumber,
755 ) -> Result<(), ClientError> {
756 for record in records {
757 let block_num = record.block_num;
758 if block_num <= current_block_num || block_num > chain_tip {
759 return Err(ClientError::ChainValidationError(format!(
760 "sync_transactions returned block {block_num} outside requested range ({current_block_num}, {chain_tip}]"
761 )));
762 }
763 }
764 Ok(())
765 }
766
767 fn advance_mmr(
774 mmr_delta: MmrDelta,
775 chain_tip_header: &BlockHeader,
776 current_partial_mmr: &mut PartialMmr,
777 partial_blockchain_updates: &mut PartialBlockchainUpdates,
778 ) -> Result<(), ClientError> {
779 let mut new_authentication_nodes =
780 current_partial_mmr.apply(mmr_delta).map_err(StoreError::MmrError)?;
781 let new_peaks = current_partial_mmr.peaks();
782
783 let peaks_commitment = new_peaks.hash_peaks();
787 if peaks_commitment != chain_tip_header.chain_commitment() {
788 return Err(ClientError::ChainValidationError(format!(
789 "MMR peaks commitment is {} and does not match block header chain commitment {}",
790 peaks_commitment.to_hex(),
791 chain_tip_header.chain_commitment().to_hex()
792 )));
793 }
794
795 partial_blockchain_updates.new_peaks = new_peaks;
796
797 new_authentication_nodes.append(
801 &mut current_partial_mmr
802 .add(chain_tip_header.commitment(), false)
803 .map_err(StoreError::MmrError)?,
804 );
805
806 partial_blockchain_updates.insert(chain_tip_header.clone(), false);
807 partial_blockchain_updates.extend_authentication_nodes(new_authentication_nodes);
808
809 Ok(())
810 }
811
812 async fn screen_note_blocks(
820 &self,
821 note_blocks: Vec<ResolvedSyncNotesBlock>,
822 note_updates: &mut NoteUpdateTracker,
823 ) -> Result<Vec<RelevantNoteBlock>, ClientError> {
824 let mut relevant_blocks = Vec::new();
825
826 for block in note_blocks {
827 let relevance =
828 self.note_state_sync(note_updates, block.notes, &block.block_header).await?;
829
830 if relevance.is_relevant() {
831 relevant_blocks.push(RelevantNoteBlock {
832 block_header: block.block_header,
833 mmr_path: block.mmr_path,
834 observer_requires_block: relevance.observer_requires_block,
835 });
836 }
837 }
838
839 Ok(relevant_blocks)
840 }
841
842 fn validate_and_track_note_blocks(
852 relevant_blocks: Vec<RelevantNoteBlock>,
853 blocks_with_unspent_notes: &BTreeSet<BlockNumber>,
854 partial_mmr: &mut PartialMmr,
855 partial_blockchain_updates: &mut PartialBlockchainUpdates,
856 ) -> Result<(), ClientError> {
857 let nodes_before: BTreeSet<InOrderIndex> = partial_mmr.nodes().map(|(k, _)| *k).collect();
858
859 for RelevantNoteBlock {
860 block_header,
861 mmr_path,
862 observer_requires_block,
863 } in relevant_blocks
864 {
865 let block_pos = block_header.block_num().as_usize();
866 let was_tracked = partial_mmr.is_tracked(block_pos);
867
868 partial_mmr
871 .track(block_pos, block_header.commitment(), &mmr_path)
872 .map_err(StoreError::MmrError)?;
873
874 if observer_requires_block
875 || blocks_with_unspent_notes.contains(&block_header.block_num())
876 {
877 partial_blockchain_updates.insert(block_header, true);
878 } else if !was_tracked {
879 partial_mmr.untrack(block_pos);
880 }
881 }
882
883 partial_blockchain_updates.extend_authentication_nodes(
885 partial_mmr
886 .nodes()
887 .filter(|(index, _)| !nodes_before.contains(index))
888 .map(|(index, value)| (*index, *value)),
889 );
890
891 Ok(())
892 }
893
894 fn apply_transactions_and_nullifiers(
898 &self,
899 chain_tip_header: &BlockHeader,
900 transactions: &[RpcTransactionRecord],
901 note_updates: &mut NoteUpdateTracker,
902 transaction_updates: &mut TransactionUpdateTracker,
903 ) -> Result<(), ClientError> {
904 note_updates.extend_nullifiers(compute_ordered_nullifiers(transactions));
905
906 for record in transactions {
907 transaction_updates
908 .apply_transaction_inclusion(record, u64::from(chain_tip_header.timestamp())); }
910 transaction_updates
911 .apply_sync_height_update(chain_tip_header.block_num(), self.tx_discard_delta);
912
913 for transaction in transactions {
914 note_updates.apply_output_note_inclusion_proofs(&transaction.output_notes)?;
918
919 Self::mark_erased_notes_as_consumed(note_updates, transaction);
921 }
922
923 Ok(())
924 }
925
926 fn mark_erased_notes_as_consumed(
932 note_updates: &mut NoteUpdateTracker,
933 transaction: &RpcTransactionRecord,
934 ) {
935 for note_header in &transaction.erased_output_notes {
936 let _ = note_updates.mark_erased_note_as_consumed(note_header, transaction.block_num);
938 }
939 }
940
941 async fn account_state_sync(
954 &self,
955 account_updates: &mut AccountUpdates,
956 accounts: &[AccountHeader],
957 account_commitment_updates: &[(AccountId, Word)],
958 block_from: BlockNumber,
959 chain_tip_header: &BlockHeader,
960 ) -> Result<Vec<Word>, ClientError> {
961 let (public_accounts, private_accounts): (Vec<_>, Vec<_>) =
964 accounts.iter().partition(|header| !header.id().is_private());
965
966 let superseded_states = self
967 .sync_public_accounts(
968 account_updates,
969 account_commitment_updates,
970 &public_accounts,
971 block_from,
972 chain_tip_header,
973 )
974 .await?;
975
976 let diverging_private_accounts: Vec<&AccountHeader> = private_accounts
979 .into_iter()
980 .filter(|header| {
981 let local_commitment = header.to_commitment();
982 account_commitment_updates
983 .iter()
984 .any(|(id, digest)| *id == header.id() && *digest != local_commitment)
985 })
986 .collect();
987
988 let proven_commitments: Vec<Option<Word>> =
989 futures::stream::iter(diverging_private_accounts.iter().map(|header| {
990 self.verify_private_account_mismatch(
991 header.id(),
992 header.to_commitment(),
993 chain_tip_header,
994 )
995 }))
996 .buffered(MAX_CONCURRENT_ACCOUNT_FETCHES)
997 .try_collect()
998 .await?;
999
1000 let mismatched_private_accounts: Vec<(AccountId, Word)> = diverging_private_accounts
1001 .iter()
1002 .zip(proven_commitments)
1003 .filter_map(|(header, proven_commitment)| {
1004 proven_commitment.map(|commitment| (header.id(), commitment))
1005 })
1006 .collect();
1007
1008 account_updates.extend(AccountUpdates::new(Vec::new(), mismatched_private_accounts));
1009
1010 Ok(superseded_states)
1011 }
1012
1013 async fn verify_private_account_mismatch(
1024 &self,
1025 account_id: AccountId,
1026 local_commitment: Word,
1027 chain_tip_header: &BlockHeader,
1028 ) -> Result<Option<Word>, ClientError> {
1029 let chain_tip = chain_tip_header.block_num();
1030 let (proof_block_num, proof) = self
1031 .rpc_api
1032 .get_account(account_id, GetAccountRequest::new().at(AccountStateAt::Block(chain_tip)))
1033 .await?;
1034
1035 if proof_block_num != chain_tip {
1036 return Err(ClientError::ChainValidationError(format!(
1037 "get_account returned a proof at block {proof_block_num}, expected chain tip {chain_tip}"
1038 )));
1039 }
1040
1041 let (witness, _) = proof.into_parts();
1042 let witness_id = witness.id();
1043 let proven_commitment = witness.state_commitment();
1044 if witness.into_proof().compute_root() != chain_tip_header.account_root() {
1047 return Err(ClientError::ChainValidationError(format!(
1048 "account witness for {account_id} does not verify against the chain tip account root"
1049 )));
1050 }
1051
1052 if witness_id != account_id
1055 || proven_commitment == Word::empty()
1056 || proven_commitment == local_commitment
1057 {
1058 return Ok(None);
1059 }
1060
1061 Ok(Some(proven_commitment))
1062 }
1063
1064 async fn sync_public_accounts(
1073 &self,
1074 account_updates: &mut AccountUpdates,
1075 commitment_updates: &[(AccountId, Word)],
1076 current_public_accounts: &[&AccountHeader],
1077 block_from: BlockNumber,
1078 chain_tip_header: &BlockHeader,
1079 ) -> Result<Vec<Word>, ClientError> {
1080 let local_headers: BTreeMap<AccountId, &AccountHeader> =
1081 current_public_accounts.iter().map(|header| (header.id(), *header)).collect();
1082
1083 let diverging_accounts: Vec<(AccountId, &AccountHeader)> = commitment_updates
1086 .iter()
1087 .filter_map(|(id, commitment)| {
1088 let local_header = local_headers.get(id).copied()?;
1089 (local_header.to_commitment() != *commitment).then_some((*id, local_header))
1090 })
1091 .collect();
1092
1093 let synced_accounts: Vec<PublicAccountSync> =
1096 futures::stream::iter(diverging_accounts.iter().map(|(id, local_header)| {
1097 self.sync_public_account(*id, local_header, block_from, chain_tip_header)
1098 }))
1099 .buffered(MAX_CONCURRENT_ACCOUNT_FETCHES)
1100 .try_collect()
1101 .await?;
1102
1103 let mut superseded_states = Vec::new();
1105 for ((_, local_header), synced_account) in diverging_accounts.iter().zip(synced_accounts) {
1106 match synced_account {
1107 PublicAccountSync::Apply(public_update) => {
1108 account_updates.extend(AccountUpdates::new(vec![*public_update], Vec::new()));
1109 },
1110 PublicAccountSync::Superseded => {
1111 superseded_states.push(local_header.to_commitment());
1112 },
1113 PublicAccountSync::Ignore => {},
1114 }
1115 }
1116
1117 Ok(superseded_states)
1118 }
1119
1120 async fn sync_public_account(
1129 &self,
1130 account_id: AccountId,
1131 local_header: &AccountHeader,
1132 block_from: BlockNumber,
1133 chain_tip_header: &BlockHeader,
1134 ) -> Result<PublicAccountSync, ClientError> {
1135 let target_block_num = chain_tip_header.block_num();
1136
1137 let (proof_block_num, proof) = self
1140 .rpc_api
1141 .get_account(
1142 account_id,
1143 GetAccountRequest::new()
1144 .at(AccountStateAt::Block(target_block_num))
1145 .with_storage(StorageMapFetch::All)
1146 .with_vault(VaultFetch::Always),
1147 )
1148 .await
1149 .map_err(ClientError::RpcError)?;
1150
1151 let details =
1152 Self::validate_account_proof(proof, proof_block_num, account_id, chain_tip_header)?;
1153
1154 match details
1155 .header
1156 .nonce()
1157 .as_canonical_u64()
1158 .cmp(&local_header.nonce().as_canonical_u64())
1159 {
1160 Ordering::Less => return Ok(PublicAccountSync::Ignore),
1163 Ordering::Equal => return Ok(PublicAccountSync::Superseded),
1165 Ordering::Greater => {},
1167 }
1168
1169 let vault_oversized = details.vault_details.too_many_assets;
1170 let any_map_oversized = details
1171 .storage_details
1172 .map_details
1173 .iter()
1174 .any(AccountStorageMapDetails::is_limit_exceeded);
1175
1176 let public_update = if vault_oversized || any_map_oversized {
1180 self.build_patch_update(account_id, &details, block_from, proof_block_num)
1182 .await?
1183 } else {
1184 let account = Account::try_from(&details).map_err(ClientError::RpcError)?;
1186 PublicAccountUpdate::Full(account)
1187 };
1188
1189 Ok(PublicAccountSync::Apply(Box::new(public_update)))
1190 }
1191
1192 fn validate_account_proof(
1205 proof: AccountProof,
1206 proof_block_num: BlockNumber,
1207 account_id: AccountId,
1208 chain_tip_header: &BlockHeader,
1209 ) -> Result<AccountDetails, ClientError> {
1210 let target_block_num = chain_tip_header.block_num();
1211
1212 if proof_block_num != target_block_num {
1213 return Err(ClientError::ChainValidationError(format!(
1214 "get_account returned block {proof_block_num} but {target_block_num} was requested"
1215 )));
1216 }
1217
1218 let (witness, details) = proof.into_parts();
1219
1220 if witness.id() != account_id {
1222 return Err(ClientError::ChainValidationError(format!(
1223 "get_account returned account {} but {account_id} was requested",
1224 witness.id()
1225 )));
1226 }
1227
1228 let account_key = AccountIdKey::from(account_id).as_word();
1229 let state_commitment = witness.state_commitment();
1230 witness
1231 .into_proof()
1232 .verify_presence(&account_key, &state_commitment, &chain_tip_header.account_root())
1233 .map_err(|err| {
1234 ClientError::ChainValidationError(format!(
1235 "get_account witness for account {account_id} does not open under block \
1236 {target_block_num} account root: {err}"
1237 ))
1238 })?;
1239
1240 details.ok_or_else(|| {
1241 ClientError::ChainValidationError(format!(
1242 "get_account returned no details for public account {account_id}"
1243 ))
1244 })
1245 }
1246
1247 async fn build_patch_update(
1250 &self,
1251 account_id: AccountId,
1252 details: &AccountDetails,
1253 block_from: BlockNumber,
1254 block_to: BlockNumber,
1255 ) -> Result<PublicAccountUpdate, ClientError> {
1256 let value_slot_updates: Vec<(_, Word)> = details
1257 .storage_details
1258 .header
1259 .slots()
1260 .filter(|slot| slot.slot_type() == StorageSlotType::Value)
1261 .map(|slot| (slot.name().clone(), slot.value()))
1262 .collect();
1263
1264 let map_info = self
1267 .rpc_api
1268 .sync_storage_maps(block_from + 1, block_to, account_id)
1269 .await
1270 .map_err(ClientError::RpcError)?;
1271 let vault_info = self
1272 .rpc_api
1273 .sync_account_vault(block_from + 1, block_to, account_id)
1274 .await
1275 .map_err(ClientError::RpcError)?;
1276
1277 let patch = build_account_patch(
1278 &details.header,
1279 value_slot_updates,
1280 map_info.map_entries,
1281 vault_info.vault_patch,
1282 details.code.clone(),
1283 )
1284 .map_err(StoreError::AccountPatchError)?;
1285
1286 Ok(PublicAccountUpdate::Patch {
1287 new_header: details.header.clone(),
1288 patch,
1289 })
1290 }
1291
1292 async fn note_state_sync(
1310 &self,
1311 note_updates: &mut NoteUpdateTracker,
1312 notes: BTreeMap<NoteId, SyncedNote>,
1313 block_header: &BlockHeader,
1314 ) -> Result<NoteBlockRelevance, ClientError> {
1315 let mut relevance = NoteBlockRelevance::default();
1316
1317 for (_, mut note) in notes {
1318 if !self.note_observers.is_empty() {
1321 for obs in &self.note_observers {
1322 match obs.observe(¬e).await {
1323 Ok(true) => relevance.observer_requires_block = true,
1324 Ok(false) => {},
1325 Err(err) => {
1326 tracing::warn!(
1327 observer = obs.name(),
1328 error = ?err,
1329 "note observer failed; sync continues",
1330 );
1331 },
1332 }
1333 }
1334 }
1335
1336 let public_note = note.details.take().map(|details| {
1339 let state = UnverifiedNoteState {
1340 metadata: note.metadata,
1341 inclusion_proof: note.inclusion_proof.clone(),
1342 }
1343 .into();
1344 InputNoteRecord::new(details, note.attachments.clone(), None, state)
1345 });
1346
1347 let committed = note.into_committed_note();
1348
1349 match self.note_screener.on_note_received(committed, public_note).await? {
1350 NoteUpdateAction::Commit(committed_note) => {
1351 relevance.has_client_note |= note_updates
1355 .apply_committed_note_state_transitions(&committed_note, block_header)?;
1356 },
1357 NoteUpdateAction::Insert(public_note) => {
1358 relevance.has_client_note = true;
1359
1360 note_updates.apply_new_public_note(public_note, block_header)?;
1361 },
1362 NoteUpdateAction::Discard => {},
1363 }
1364 }
1365
1366 Ok(relevance)
1367 }
1368
1369 async fn nullifiers_state_sync(
1375 &self,
1376 note_updates: &mut NoteUpdateTracker,
1377 transaction_updates: &mut TransactionUpdateTracker,
1378 chain_tip: BlockNumber,
1379 current_block_num: BlockNumber,
1380 ) -> Result<(), ClientError> {
1381 let nullifiers_tags: Vec<u16> =
1387 note_updates.unspent_nullifiers().map(|nullifier| nullifier.prefix()).collect();
1388
1389 let mut new_nullifiers = self
1390 .rpc_api
1391 .sync_nullifiers(&nullifiers_tags, current_block_num + 1, chain_tip)
1392 .await?;
1393
1394 new_nullifiers.retain(|update| update.block_num <= chain_tip);
1397
1398 let consumptions: Vec<NoteConsumption> = new_nullifiers
1400 .into_iter()
1401 .map(|update| NoteConsumption {
1402 external_consumer: transaction_updates
1403 .external_nullifier_account(&update.nullifier),
1404 nullifier: update.nullifier,
1405 block_num: update.block_num,
1406 })
1407 .collect();
1408
1409 for consumption in consumptions {
1410 note_updates.apply_note_consumption(
1411 &consumption,
1412 transaction_updates.committed_transactions(),
1413 )?;
1414
1415 transaction_updates.apply_input_note_nullified(consumption.nullifier);
1419 }
1420
1421 Ok(())
1422 }
1423}
1424
1425pub struct ChainSyncData {
1434 pub(crate) block_from: BlockNumber,
1436 advance: Option<ChainAdvance>,
1439 superseded_states: Vec<Word>,
1441 pub(crate) note_updates: NoteUpdateTracker,
1444 transaction_updates: TransactionUpdateTracker,
1445 account_updates: AccountUpdates,
1446}
1447
1448struct ChainAdvance {
1450 chain_tip_header: BlockHeader,
1452 mmr_delta: MmrDelta,
1454 note_blocks_awaiting_screening: Vec<ResolvedSyncNotesBlock>,
1457 transactions: Vec<RpcTransactionRecord>,
1459 relevant_note_blocks: Vec<RelevantNoteBlock>,
1461}
1462
1463pub(crate) fn block_num_from_forest(partial_mmr: &PartialMmr) -> Result<BlockNumber, ClientError> {
1468 Ok(u32::try_from(partial_mmr.forest().num_leaves().saturating_sub(1))
1469 .map_err(|_| ClientError::InvalidPartialMmrForest)?
1470 .into())
1471}
1472
1473fn group_txs_by_account_block(
1475 transaction_records: &[RpcTransactionRecord],
1476) -> BTreeMap<(AccountId, BlockNumber), Vec<&RpcTransactionRecord>> {
1477 let mut groups: BTreeMap<(AccountId, BlockNumber), Vec<&RpcTransactionRecord>> =
1478 BTreeMap::new();
1479 for record in transaction_records {
1480 let account_id = record.transaction_header.account_id();
1481 groups.entry((account_id, record.block_num)).or_default().push(record);
1482 }
1483 groups
1484}
1485
1486fn walk_execution_chain<'a>(
1492 txs: &'a [&'a RpcTransactionRecord],
1493) -> impl Iterator<Item = &'a RpcTransactionRecord> + 'a {
1494 let (self_loops, chained): (Vec<&RpcTransactionRecord>, Vec<&RpcTransactionRecord>) =
1495 txs.iter().copied().partition(|tx| {
1496 tx.transaction_header.initial_state_commitment()
1497 == tx.transaction_header.final_state_commitment()
1498 });
1499
1500 let final_states: BTreeSet<Word> = chained
1501 .iter()
1502 .map(|tx| tx.transaction_header.final_state_commitment())
1503 .collect();
1504
1505 let mut init_to_tx: BTreeMap<Word, &RpcTransactionRecord> = chained
1506 .iter()
1507 .map(|tx| (tx.transaction_header.initial_state_commitment(), *tx))
1508 .collect();
1509
1510 let start = chained
1511 .iter()
1512 .find(|tx| !final_states.contains(&tx.transaction_header.initial_state_commitment()))
1513 .copied();
1514
1515 assert!(start.is_some() || chained.is_empty(), "cannot walk cyclic execution chain");
1516
1517 let mut current =
1518 start.and_then(|tx| init_to_tx.remove(&tx.transaction_header.initial_state_commitment()));
1519 let mut self_loops_iter = self_loops.into_iter();
1520
1521 core::iter::from_fn(move || {
1522 if let Some(tx) = current {
1523 current = init_to_tx.remove(&tx.transaction_header.final_state_commitment());
1524 return Some(tx);
1525 }
1526 self_loops_iter.next()
1527 })
1528}
1529
1530fn derive_account_commitments(
1535 transaction_records: &[RpcTransactionRecord],
1536) -> Vec<(AccountId, Word)> {
1537 let mut latest_by_account: BTreeMap<AccountId, (BlockNumber, Word)> = BTreeMap::new();
1538
1539 for ((account_id, block_num), txs) in &group_txs_by_account_block(transaction_records) {
1540 let terminal_state = walk_execution_chain(txs)
1541 .last()
1542 .expect("account must have a final state")
1543 .transaction_header
1544 .final_state_commitment();
1545
1546 latest_by_account
1547 .entry(*account_id)
1548 .and_modify(|(existing_block, existing_state)| {
1549 if *block_num > *existing_block {
1550 *existing_block = *block_num;
1551 *existing_state = terminal_state;
1552 }
1553 })
1554 .or_insert((*block_num, terminal_state));
1555 }
1556
1557 latest_by_account
1558 .into_iter()
1559 .map(|(account_id, (_, state))| (account_id, state))
1560 .collect()
1561}
1562
1563fn compute_ordered_nullifiers(transaction_records: &[RpcTransactionRecord]) -> Vec<Nullifier> {
1570 let mut result = Vec::new();
1571
1572 for txs in group_txs_by_account_block(transaction_records).values() {
1573 for tx in walk_execution_chain(txs) {
1574 for commitment in tx.transaction_header.input_notes().iter() {
1575 result.push(commitment.nullifier());
1576 }
1577 }
1578 }
1579
1580 result
1581}
1582
1583#[cfg(all(test, feature = "testing"))]
1584mod tests {
1585 use alloc::collections::BTreeSet;
1586 use alloc::sync::Arc;
1587
1588 use async_trait::async_trait;
1589 use miden_protocol::account::Account;
1590 use miden_protocol::assembly::DefaultSourceManager;
1591 use miden_protocol::asset::{Asset, FungibleAsset};
1592 use miden_protocol::block::BlockNumber;
1593 use miden_protocol::crypto::merkle::MerklePath;
1594 use miden_protocol::crypto::merkle::mmr::{Forest, InOrderIndex, PartialMmr};
1595 use miden_protocol::note::{
1596 Note,
1597 NoteAssets,
1598 NoteAttachment,
1599 NoteAttachments,
1600 NoteDetails,
1601 NoteHeader,
1602 NoteMetadata,
1603 NoteRecipient,
1604 NoteStorage,
1605 NoteTag,
1606 NoteType,
1607 PartialNoteMetadata,
1608 };
1609 use miden_protocol::testing::account_id::{
1610 ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET,
1611 ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
1612 ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE,
1613 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
1614 ACCOUNT_ID_SENDER,
1615 };
1616 use miden_protocol::transaction::{InputNotes, TransactionArgs, TransactionHeader};
1617 use miden_protocol::vm::AdviceMap;
1618 use miden_protocol::{EMPTY_WORD, Felt, Word, ZERO};
1619 use miden_standards::code_builder::CodeBuilder;
1620 use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint};
1621 use miden_testing::{MockChainBuilder, MockTransactionInput};
1622
1623 use super::*;
1624 use crate::store::{OutputNoteRecord, OutputNoteState};
1625 use crate::test_utils::mock::MockRpcApi;
1626
1627 struct MockScreener;
1629
1630 #[async_trait(?Send)]
1631 impl OnNoteReceived for MockScreener {
1632 async fn on_note_received(
1633 &self,
1634 _committed_note: CommittedNote,
1635 _public_note: Option<InputNoteRecord>,
1636 ) -> Result<NoteUpdateAction, ClientError> {
1637 Ok(NoteUpdateAction::Discard)
1638 }
1639 }
1640
1641 struct AlwaysRelevantObserver;
1643
1644 #[async_trait(?Send)]
1645 impl NoteObserver for AlwaysRelevantObserver {
1646 fn name(&self) -> &'static str {
1647 "always-relevant"
1648 }
1649
1650 async fn observe(&self, _note: &SyncedNote) -> Result<bool, ClientError> {
1651 Ok(true)
1652 }
1653 }
1654
1655 fn empty() -> StateSyncInput {
1656 StateSyncInput {
1657 accounts: vec![],
1658 note_tags: BTreeSet::new(),
1659 input_notes: vec![],
1660 output_notes: vec![],
1661 uncommitted_transactions: vec![],
1662 }
1663 }
1664
1665 fn word(n: u64) -> miden_protocol::Word {
1666 [
1667 Felt::new(n).expect("test value should fit into the base field"),
1668 ZERO,
1669 ZERO,
1670 ZERO,
1671 ]
1672 .into()
1673 }
1674
1675 fn header_with_account_root(header: &BlockHeader, account_root: Word) -> BlockHeader {
1676 BlockHeader::new(
1677 header.prev_block_commitment(),
1678 header.block_num(),
1679 header.chain_commitment(),
1680 account_root,
1681 header.nullifier_root(),
1682 header.note_root(),
1683 header.tx_commitment(),
1684 header.validator_config().clone(),
1685 header.fee_parameters().clone(),
1686 header.protocol_config_commitment(),
1687 header.next_protocol_config().cloned(),
1688 header.timestamp(),
1689 )
1690 }
1691
1692 #[tokio::test]
1693 async fn sync_public_accounts_ignores_older_node_snapshot() {
1694 let mut builder = MockChainBuilder::new();
1695 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1696 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1697 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1698 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1699
1700 let local_header =
1703 AccountHeader::new(account.id(), Felt::from(2u32), EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
1704 let current_public_accounts = vec![&local_header];
1705 let commitment_updates = vec![(account.id(), account.to_commitment())];
1706 let mut account_updates = AccountUpdates::default();
1707
1708 let superseded = state_sync
1709 .sync_public_accounts(
1710 &mut account_updates,
1711 &commitment_updates,
1712 ¤t_public_accounts,
1713 BlockNumber::GENESIS,
1714 &chain_tip_header,
1715 )
1716 .await
1717 .unwrap();
1718
1719 assert!(
1720 account_updates.updated_public_accounts().is_empty(),
1721 "public account sync should ignore node snapshots that are older than local"
1722 );
1723 assert!(
1724 superseded.is_empty(),
1725 "an older node snapshot must not supersede the local state"
1726 );
1727 }
1728
1729 #[tokio::test]
1730 async fn sync_public_accounts_marks_same_nonce_mismatch_as_superseded() {
1731 let mut builder = MockChainBuilder::new();
1732 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1733 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1734 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1735 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1736
1737 let local_header =
1740 AccountHeader::new(account.id(), account.nonce(), EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
1741 let current_public_accounts = vec![&local_header];
1742 let commitment_updates = vec![(account.id(), account.to_commitment())];
1743 let mut account_updates = AccountUpdates::default();
1744
1745 let superseded = state_sync
1746 .sync_public_accounts(
1747 &mut account_updates,
1748 &commitment_updates,
1749 ¤t_public_accounts,
1750 BlockNumber::GENESIS,
1751 &chain_tip_header,
1752 )
1753 .await
1754 .unwrap();
1755
1756 assert!(
1757 account_updates.updated_public_accounts().is_empty(),
1758 "a same-nonce fork must not overwrite the account while its tx is still pending"
1759 );
1760 assert_eq!(
1761 superseded,
1762 vec![local_header.to_commitment()],
1763 "the superseded local state should be reported so its transaction is discarded"
1764 );
1765 }
1766
1767 #[test]
1773 fn validate_transaction_records_range_rejects_out_of_range_blocks() {
1774 let account_id: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap();
1775 let current = BlockNumber::from(5u32);
1776 let chain_tip = BlockNumber::from(10u32);
1777
1778 StateSync::validate_transaction_records_range(
1779 &[make_tx_record(account_id, 7)],
1780 current,
1781 chain_tip,
1782 )
1783 .unwrap();
1784
1785 let result = StateSync::validate_transaction_records_range(
1786 &[make_tx_record(account_id, 11)],
1787 current,
1788 chain_tip,
1789 );
1790 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1791
1792 let result = StateSync::validate_transaction_records_range(
1793 &[make_tx_record(account_id, 5)],
1794 current,
1795 chain_tip,
1796 );
1797 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1798 }
1799
1800 #[tokio::test]
1803 async fn verify_private_account_mismatch_ignores_forged_commitment() {
1804 let mut builder = MockChainBuilder::new();
1805 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1806 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1807 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1808 let on_chain_commitment = account.to_commitment();
1809 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1810
1811 let result = state_sync
1812 .verify_private_account_mismatch(account.id(), on_chain_commitment, &chain_tip_header)
1813 .await
1814 .unwrap();
1815
1816 assert!(
1817 result.is_none(),
1818 "an unproven commitment must not lock an account whose on-chain state matches local"
1819 );
1820 }
1821
1822 #[tokio::test]
1825 async fn verify_private_account_mismatch_reports_proven_divergence() {
1826 let mut builder = MockChainBuilder::new();
1827 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1828 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1829 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1830 let on_chain_commitment = account.to_commitment();
1831 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1832 let stale_local_commitment = word(0xdead_beef);
1833
1834 let result = state_sync
1835 .verify_private_account_mismatch(
1836 account.id(),
1837 stale_local_commitment,
1838 &chain_tip_header,
1839 )
1840 .await
1841 .unwrap();
1842
1843 assert_eq!(
1844 result,
1845 Some(on_chain_commitment),
1846 "a proven divergence should return the proven commitment to lock with"
1847 );
1848 }
1849
1850 #[tokio::test]
1853 async fn verify_private_account_mismatch_rejects_unverifiable_proof() {
1854 let mut builder = MockChainBuilder::new();
1855 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1856 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1857 let real_header = rpc_api.mock_chain.read().latest_block_header();
1858 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1859
1860 let tampered_header = BlockHeader::new(
1863 real_header.prev_block_commitment(),
1864 real_header.block_num(),
1865 real_header.chain_commitment(),
1866 word(0xbad0_bad0),
1867 real_header.nullifier_root(),
1868 real_header.note_root(),
1869 real_header.tx_commitment(),
1870 real_header.validator_config().clone(),
1871 real_header.fee_parameters().clone(),
1872 real_header.protocol_config_commitment(),
1873 real_header.next_protocol_config().cloned(),
1874 real_header.timestamp(),
1875 );
1876
1877 let result = state_sync
1878 .verify_private_account_mismatch(
1879 account.id(),
1880 account.to_commitment(),
1881 &tampered_header,
1882 )
1883 .await;
1884 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1885 }
1886
1887 #[tokio::test]
1890 async fn sync_public_accounts_pins_account_fetch_to_sync_target() {
1891 let mut builder = MockChainBuilder::new();
1892 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1893 let mut chain = builder.build().unwrap();
1894
1895 let sync_target_header = chain.latest_block_header();
1897 let tx = Box::pin(
1898 chain
1899 .build_transaction(MockTransactionInput::AccountId(account.id()))
1900 .build()
1901 .unwrap()
1902 .execute(),
1903 )
1904 .await
1905 .unwrap();
1906 let local_header = tx.final_account().clone();
1907 assert_ne!(local_header.to_commitment(), account.to_commitment());
1908 chain.add_pending_executed_transaction(&tx).unwrap();
1909
1910 let rpc_api = MockRpcApi::new(chain);
1911 rpc_api.prove_block();
1913 assert_eq!(
1914 rpc_api
1915 .mock_chain
1916 .read()
1917 .committed_account(account.id())
1918 .unwrap()
1919 .to_commitment(),
1920 local_header.to_commitment()
1921 );
1922 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1923
1924 let current_public_accounts = vec![&local_header];
1925 let commitment_updates = vec![(account.id(), account.to_commitment())];
1926 let mut account_updates = AccountUpdates::default();
1927
1928 let superseded = state_sync
1929 .sync_public_accounts(
1930 &mut account_updates,
1931 &commitment_updates,
1932 ¤t_public_accounts,
1933 BlockNumber::GENESIS,
1934 &sync_target_header,
1935 )
1936 .await
1937 .unwrap();
1938
1939 assert!(superseded.is_empty(), "the transaction must not be superseded");
1940 assert!(
1941 account_updates.updated_public_accounts().is_empty(),
1942 "the target state must not overwrite the local account"
1943 );
1944 }
1945
1946 async fn get_account_proof(
1948 rpc_api: &MockRpcApi,
1949 account_id: AccountId,
1950 ) -> (BlockNumber, AccountProof) {
1951 rpc_api
1952 .get_account(
1953 account_id,
1954 GetAccountRequest::new()
1955 .with_storage(StorageMapFetch::All)
1956 .with_vault(VaultFetch::Always),
1957 )
1958 .await
1959 .unwrap()
1960 }
1961
1962 #[tokio::test]
1964 async fn validate_account_proof_rejects_mismatched_account() {
1965 let mut builder = MockChainBuilder::new();
1966 let account_a = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1967 let account_b = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1968 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1969 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1970
1971 let (proof_block_num, proof) = get_account_proof(&rpc_api, account_b.id()).await;
1973 let result = StateSync::validate_account_proof(
1974 proof,
1975 proof_block_num,
1976 account_a.id(),
1977 &chain_tip_header,
1978 );
1979
1980 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1981 }
1982
1983 #[tokio::test]
1985 async fn validate_account_proof_rejects_wrong_account_root() {
1986 let mut builder = MockChainBuilder::new();
1987 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1988 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1989 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1990 let wrong_header = header_with_account_root(&chain_tip_header, word(999));
1991
1992 let (proof_block_num, proof) = get_account_proof(&rpc_api, account.id()).await;
1994 let result =
1995 StateSync::validate_account_proof(proof, proof_block_num, account.id(), &wrong_header);
1996
1997 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1998 }
1999
2000 #[tokio::test]
2002 async fn validate_account_proof_rejects_missing_details() {
2003 let mut builder = MockChainBuilder::new();
2004 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2005 let rpc_api = MockRpcApi::new(builder.build().unwrap());
2006 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
2007
2008 let (proof_block_num, proof) = get_account_proof(&rpc_api, account.id()).await;
2010 let (witness, _) = proof.into_parts();
2011 let proof = AccountProof::new(witness, None).unwrap();
2012 let result = StateSync::validate_account_proof(
2013 proof,
2014 proof_block_num,
2015 account.id(),
2016 &chain_tip_header,
2017 );
2018
2019 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2020 }
2021
2022 #[tokio::test]
2024 async fn validate_account_proof_rejects_wrong_block() {
2025 let mut builder = MockChainBuilder::new();
2026 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2027 let rpc_api = MockRpcApi::new(builder.build().unwrap());
2028 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
2029
2030 let (proof_block_num, proof) = get_account_proof(&rpc_api, account.id()).await;
2032 let result = StateSync::validate_account_proof(
2033 proof,
2034 proof_block_num + 1,
2035 account.id(),
2036 &chain_tip_header,
2037 );
2038
2039 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2040 }
2041
2042 mod compute_nullifiers_tests {
2046 use alloc::vec;
2047
2048 use miden_protocol::block::BlockNumber;
2049 use miden_protocol::note::Nullifier;
2050 use miden_protocol::transaction::{InputNoteCommitment, InputNotes, TransactionHeader};
2051
2052 use super::word;
2053 use crate::rpc::domain::transaction::TransactionRecord as RpcTransactionRecord;
2054
2055 fn make_rpc_tx(
2056 init_state: u64,
2057 final_state: u64,
2058 nullifier_vals: &[u64],
2059 block_number: u32,
2060 ) -> RpcTransactionRecord {
2061 let account_id = miden_protocol::account::AccountId::try_from(
2062 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE,
2063 )
2064 .unwrap();
2065
2066 let input_notes = InputNotes::new_unchecked(
2067 nullifier_vals
2068 .iter()
2069 .map(|v| InputNoteCommitment::from(Nullifier::from_raw(word(*v))))
2070 .collect(),
2071 );
2072
2073 RpcTransactionRecord {
2074 block_num: BlockNumber::from(block_number),
2075 transaction_header: TransactionHeader::new(
2076 account_id,
2077 word(init_state),
2078 word(final_state),
2079 input_notes,
2080 vec![],
2081 )
2082 .unwrap(),
2083 output_notes: vec![],
2084 erased_output_notes: vec![],
2085 consumed_note_refs: vec![],
2086 }
2087 }
2088
2089 #[test]
2090 fn chains_rpc_transactions_by_state_commitment() {
2091 let tx_a = make_rpc_tx(1, 2, &[10], 5);
2095 let tx_b = make_rpc_tx(2, 3, &[20], 5);
2096 let tx_c = make_rpc_tx(3, 4, &[30], 5);
2097
2098 let result = super::super::compute_ordered_nullifiers(&[tx_c, tx_a, tx_b]);
2099
2100 assert_eq!(result[0], Nullifier::from_raw(word(10)));
2101 assert_eq!(result[1], Nullifier::from_raw(word(20)));
2102 assert_eq!(result[2], Nullifier::from_raw(word(30)));
2103 }
2104
2105 #[test]
2106 fn groups_independently_by_account_and_block() {
2107 let tx_a1 = make_rpc_tx(1, 2, &[10], 5);
2109 let tx_a2 = make_rpc_tx(2, 3, &[20], 5);
2110
2111 let tx_a3 = make_rpc_tx(3, 4, &[30], 6);
2113
2114 let account_b = miden_protocol::account::AccountId::try_from(
2116 miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
2117 )
2118 .unwrap();
2119
2120 let tx_b1 = RpcTransactionRecord {
2121 block_num: BlockNumber::from(5u32),
2122 transaction_header: TransactionHeader::new(
2123 account_b,
2124 word(100),
2125 word(200),
2126 InputNotes::new_unchecked(vec![InputNoteCommitment::from(
2127 Nullifier::from_raw(word(40)),
2128 )]),
2129 vec![],
2130 )
2131 .unwrap(),
2132 output_notes: vec![],
2133 erased_output_notes: vec![],
2134 consumed_note_refs: vec![],
2135 };
2136
2137 let result = super::super::compute_ordered_nullifiers(&[tx_a2, tx_b1, tx_a3, tx_a1]);
2138
2139 let pos = |val: u64| -> usize {
2142 result.iter().position(|n| *n == Nullifier::from_raw(word(val))).unwrap()
2143 };
2144
2145 assert!(pos(10) < pos(20)); assert!(result.contains(&Nullifier::from_raw(word(30)))); assert!(result.contains(&Nullifier::from_raw(word(40)))); }
2151
2152 #[test]
2153 fn multiple_nullifiers_per_transaction_are_consecutive() {
2154 let tx = make_rpc_tx(1, 2, &[10, 20, 30], 5);
2156
2157 let result = super::super::compute_ordered_nullifiers(&[tx]);
2158
2159 assert_eq!(result.len(), 3);
2160 assert!(result.contains(&Nullifier::from_raw(word(10))));
2161 assert!(result.contains(&Nullifier::from_raw(word(20))));
2162 assert!(result.contains(&Nullifier::from_raw(word(30))));
2163 }
2164
2165 #[test]
2166 fn empty_input_returns_empty_vec() {
2167 let result = super::super::compute_ordered_nullifiers(&[]);
2168 assert!(result.is_empty());
2169 }
2170 }
2171
2172 #[test]
2183 fn derive_account_commitments_walks_chains_per_account() {
2184 let make_tx = |account: AccountId, init_state: u64, final_state: u64, block_num: u32| {
2185 RpcTransactionRecord {
2186 block_num: BlockNumber::from(block_num),
2187 transaction_header: TransactionHeader::new(
2188 account,
2189 word(init_state),
2190 word(final_state),
2191 InputNotes::new_unchecked(vec![]),
2192 vec![],
2193 )
2194 .unwrap(),
2195 output_notes: vec![],
2196 erased_output_notes: vec![],
2197 consumed_note_refs: vec![],
2198 }
2199 };
2200
2201 let account_a: AccountId =
2202 ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE.try_into().unwrap();
2203 let account_b: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap();
2204
2205 let tx_a_b5_1 = make_tx(account_a, 1, 2, 5);
2206 let tx_a_b5_2 = make_tx(account_a, 2, 3, 5);
2207 let tx_a_b6_1 = make_tx(account_a, 3, 4, 6);
2208 let tx_a_b6_2 = make_tx(account_a, 4, 5, 6);
2209 let tx_b_b6 = make_tx(account_b, 10, 20, 6);
2210
2211 let result = super::derive_account_commitments(&[
2213 tx_a_b6_1, tx_b_b6, tx_a_b5_2, tx_a_b6_2, tx_a_b5_1,
2214 ]);
2215
2216 assert_eq!(result.len(), 2, "one entry per account");
2217 assert!(
2218 result.contains(&(account_a, word(5))),
2219 "account A: must walk block 6's chain, not return block 5 or an intermediate",
2220 );
2221 assert!(
2222 result.contains(&(account_b, word(20))),
2223 "account B: must be resolved independently of account A",
2224 );
2225 }
2226
2227 struct CommitAllScreener;
2233
2234 #[async_trait(?Send)]
2235 impl OnNoteReceived for CommitAllScreener {
2236 async fn on_note_received(
2237 &self,
2238 committed_note: CommittedNote,
2239 _public_note: Option<InputNoteRecord>,
2240 ) -> Result<NoteUpdateAction, ClientError> {
2241 Ok(NoteUpdateAction::Commit(committed_note))
2242 }
2243 }
2244
2245 async fn build_chain_with_chained_consume_txs() -> (miden_testing::MockChain, Account, [Note; 3])
2249 {
2250 let sender_id: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
2251 let faucet_id: AccountId = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into().unwrap();
2252
2253 let mut builder = MockChainBuilder::new();
2254 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2255 let account_id = account.id();
2256
2257 let asset = Asset::from(FungibleAsset::new(faucet_id, 100u64).unwrap());
2258 let note1 = builder
2259 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
2260 .unwrap();
2261 let note2 = builder
2262 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
2263 .unwrap();
2264 let note3 = builder
2265 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
2266 .unwrap();
2267
2268 let mut chain = builder.build().unwrap();
2269 chain.prove_next_block().unwrap(); let mut current_account = account.clone();
2273 for note in [¬e1, ¬e2, ¬e3] {
2274 let tx = Box::pin(
2275 chain
2276 .build_transaction(MockTransactionInput::Account(current_account.clone()))
2277 .unauthenticated_input_note(note.clone())
2278 .build()
2279 .unwrap()
2280 .execute(),
2281 )
2282 .await
2283 .unwrap();
2284 current_account.apply_patch(tx.account_patch()).unwrap();
2285 chain.add_pending_executed_transaction(&tx).unwrap();
2286 }
2287
2288 chain.prove_next_block().unwrap(); (chain, account, [note1, note2, note3])
2290 }
2291
2292 #[tokio::test]
2295 async fn sync_state_sets_consumed_tx_order_for_chained_transactions() {
2296 use miden_protocol::note::NoteMetadata;
2297
2298 let (chain, account, [note1, note2, note3]) = build_chain_with_chained_consume_txs().await;
2299
2300 let mock_rpc = MockRpcApi::new(chain);
2301 let state_sync =
2302 StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(CommitAllScreener), None);
2303
2304 let genesis_peaks =
2305 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2306 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2307
2308 let input_notes: Vec<InputNoteRecord> = [¬e1, ¬e2, ¬e3]
2309 .into_iter()
2310 .map(|n| InputNoteRecord::from(n.clone()))
2311 .collect();
2312
2313 let note_tags: BTreeSet<NoteTag> =
2314 input_notes.iter().filter_map(|n| n.metadata().map(NoteMetadata::tag)).collect();
2315
2316 let account_id = account.id();
2317 let sync_input = StateSyncInput {
2318 accounts: vec![AccountHeader::from(&account)],
2319 note_tags,
2320 input_notes,
2321 output_notes: vec![],
2322 uncommitted_transactions: vec![],
2323 };
2324
2325 let update = state_sync.sync_state(&mut partial_mmr, sync_input).await.unwrap();
2326
2327 let updated_notes: Vec<_> = update.note_updates().updated_input_notes().collect();
2328
2329 let find_order = |details_commitment| -> Option<u32> {
2330 updated_notes
2331 .iter()
2332 .find(|n| n.inner().details_commitment() == details_commitment)
2333 .and_then(|n| n.consumed_tx_order())
2334 };
2335
2336 assert_eq!(find_order(note1.details_commitment()), Some(0), "note1 should have tx_order 0");
2337 assert_eq!(find_order(note2.details_commitment()), Some(1), "note2 should have tx_order 1");
2338 assert_eq!(find_order(note3.details_commitment()), Some(2), "note3 should have tx_order 2");
2339
2340 for note in &updated_notes {
2343 let record = note.inner();
2344 assert!(record.is_consumed(), "note should be in a consumed state");
2345 assert_eq!(
2346 record.consumer_account(),
2347 Some(account_id),
2348 "externally-consumed notes by a tracked account should have consumer_account set",
2349 );
2350 }
2351 }
2352
2353 #[tokio::test]
2354 async fn sync_state_across_multiple_iterations_with_same_mmr() {
2355 let mock_rpc = MockRpcApi::default();
2357 mock_rpc.advance_blocks(3);
2358 let chain_tip_1 = mock_rpc.get_chain_tip_block_num();
2359
2360 let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
2361
2362 let genesis_peaks =
2364 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2365 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2366 assert_eq!(partial_mmr.forest().num_leaves(), 1);
2367
2368 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
2370
2371 assert_eq!(update.block_num(), chain_tip_1);
2372 let forest_1 = partial_mmr.forest();
2373 assert_eq!(forest_1.num_leaves(), chain_tip_1.as_u32() as usize + 1);
2375
2376 mock_rpc.advance_blocks(2);
2378 let chain_tip_2 = mock_rpc.get_chain_tip_block_num();
2379
2380 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
2381
2382 assert_eq!(update.block_num(), chain_tip_2);
2383 let forest_2 = partial_mmr.forest();
2384 assert!(forest_2 > forest_1);
2385 assert_eq!(forest_2.num_leaves(), chain_tip_2.as_u32() as usize + 1);
2386
2387 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
2389
2390 assert_eq!(update.block_num(), chain_tip_2);
2391 assert_eq!(partial_mmr.forest(), forest_2);
2392 }
2393
2394 async fn build_chain_with_mint_notes(
2397 num_blocks: u64,
2398 ) -> (miden_testing::MockChain, BTreeSet<NoteTag>) {
2399 let mut builder = MockChainBuilder::new();
2400 let faucet = builder
2401 .add_existing_basic_faucet(
2402 miden_testing::Auth::BasicAuth {
2403 auth_scheme: miden_protocol::account::auth::AuthScheme::Falcon512Poseidon2,
2404 },
2405 "TST",
2406 10_000,
2407 None,
2408 )
2409 .unwrap();
2410 let _target = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2411 let mut chain = builder.build().unwrap();
2412
2413 let note_script = CodeBuilder::new()
2418 .compile_note_script("@note_script\npub proc main\n nop\nend")
2419 .unwrap();
2420 let note_recipient = NoteRecipient::new(
2421 Word::from([1u32, 2, 3, 4]),
2422 note_script,
2423 NoteStorage::new(vec![]).unwrap(),
2424 );
2425 let recipient = note_recipient.digest();
2426 let note_details = NoteDetails::new(NoteAssets::new(vec![]).unwrap(), note_recipient);
2429 let mut recipient_args = TransactionArgs::new(AdviceMap::default());
2430 recipient_args.add_output_note_recipient(¬e_details);
2431 let recipient_advice = recipient_args.advice_inputs().clone();
2432
2433 let tag = NoteTag::default();
2434 let mut faucet_account = faucet.clone();
2435 let mut note_tags = BTreeSet::new();
2436
2437 for i in 0..num_blocks {
2438 let amount = 100 + i;
2439 let source_manager = Arc::new(DefaultSourceManager::default());
2440 let mint_asset = FungibleAsset::new(faucet_account.id(), amount).unwrap();
2444 let asset_id_word = mint_asset.id().to_word();
2445 let asset_value_word = mint_asset.to_value_word();
2446 let tx_script_code = format!(
2447 "
2448 @transaction_script
2449 pub proc main
2450 push.{recipient}
2451 push.{note_type}
2452 push.{tag}
2453 push.{asset_value}
2454 push.{asset_id}
2455 call.::miden::standards::faucets::fungible::mint_and_send
2456 dropw dropw dropw dropw
2457 end
2458 ",
2459 recipient = recipient,
2460 note_type = NoteType::Private as u8,
2461 tag = u32::from(tag),
2462 asset_value = asset_value_word,
2463 asset_id = asset_id_word,
2464 );
2465 let tx_script = CodeBuilder::with_source_manager(source_manager.clone())
2466 .compile_tx_script(tx_script_code)
2467 .unwrap();
2468 let tx = Box::pin(
2469 chain
2470 .build_transaction(miden_testing::MockTransactionInput::Account(
2471 faucet_account.clone(),
2472 ))
2473 .extend_advice_inputs(recipient_advice.clone())
2474 .tx_script(tx_script)
2475 .with_source_manager(source_manager)
2476 .build()
2477 .unwrap()
2478 .execute(),
2479 )
2480 .await
2481 .unwrap();
2482
2483 for output_note in tx.output_notes().iter() {
2484 note_tags.insert(output_note.metadata().tag());
2485 }
2486
2487 faucet_account.apply_patch(tx.account_patch()).unwrap();
2488 chain.add_pending_executed_transaction(&tx).unwrap();
2489 chain.prove_next_block().unwrap();
2490 }
2491
2492 (chain, note_tags)
2493 }
2494
2495 #[tokio::test]
2498 async fn observer_relevance_persists_discarded_note_block() {
2499 let (chain, note_tags) = build_chain_with_mint_notes(2).await;
2500 let mock_rpc = MockRpcApi::new(chain);
2501 let chain_tip = mock_rpc.get_chain_tip_block_num();
2502
2503 let genesis_peaks =
2504 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2505 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2506
2507 let state_sync = StateSync::new(Arc::new(mock_rpc), Arc::new(MockScreener), None)
2508 .with_note_observer(Arc::new(AlwaysRelevantObserver));
2509 let mut input = empty();
2510 input.note_tags = note_tags;
2511
2512 let update = state_sync.sync_state(&mut partial_mmr, input).await.unwrap();
2513 let observed_non_tip_block = BlockNumber::from(1u32);
2514
2515 assert!(
2516 update.partial_blockchain_updates().block_headers_to_store(chain_tip).any(
2517 |(header, is_relevant)| {
2518 header.block_num() == observed_non_tip_block && *is_relevant
2519 }
2520 ),
2521 "an observer-relevant block must be staged as relevant"
2522 );
2523 assert!(
2524 partial_mmr.is_tracked(observed_non_tip_block.as_usize()),
2525 "an observer-relevant block must remain tracked in the partial MMR"
2526 );
2527 }
2528
2529 #[tokio::test]
2539 async fn sync_state_tracks_note_blocks_in_mmr() {
2540 let (chain, note_tags) = build_chain_with_mint_notes(3).await;
2541 let mock_rpc = MockRpcApi::new(chain);
2542 let chain_tip = mock_rpc.get_chain_tip_block_num();
2543
2544 let note_blocks = mock_rpc
2546 .sync_notes(BlockNumber::from(0u32), chain_tip, ¬e_tags)
2547 .await
2548 .unwrap();
2549 assert!(
2550 note_blocks.len() >= 2,
2551 "expected notes in multiple blocks, got {}",
2552 note_blocks.len()
2553 );
2554
2555 let note_block_nums: BTreeSet<BlockNumber> =
2557 note_blocks.iter().map(|b| b.block_header.block_num()).collect();
2558
2559 let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
2562
2563 let genesis_peaks =
2564 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2565 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2566
2567 let sync_data = state_sync
2568 .fetch_sync_data(BlockNumber::GENESIS, &[], &Arc::new(note_tags.clone()))
2569 .await
2570 .unwrap()
2571 .expect("should have progressed past genesis");
2572
2573 assert_eq!(sync_data.chain_tip_header.block_num(), chain_tip);
2575 assert!(!sync_data.note_blocks.is_empty(), "should have note blocks");
2576
2577 let _auth_nodes: Vec<(InOrderIndex, Word)> =
2579 partial_mmr.apply(sync_data.mmr_delta).map_err(StoreError::MmrError).unwrap();
2580 partial_mmr
2581 .add(sync_data.chain_tip_header.commitment(), false)
2582 .expect("chain tip should append to the partial MMR");
2583
2584 assert_eq!(partial_mmr.forest().num_leaves(), chain_tip.as_u32() as usize + 1);
2585
2586 for block in &sync_data.note_blocks {
2588 let bn = block.block_header.block_num();
2589 partial_mmr
2590 .track(bn.as_usize(), block.block_header.commitment(), &block.mmr_path)
2591 .map_err(StoreError::MmrError)
2592 .unwrap();
2593
2594 assert!(
2595 partial_mmr.is_tracked(bn.as_usize()),
2596 "block {bn} should be tracked after calling track()"
2597 );
2598 }
2599
2600 for &bn in ¬e_block_nums {
2602 assert!(
2603 partial_mmr.is_tracked(bn.as_usize()),
2604 "block {bn} with notes should be tracked in partial MMR"
2605 );
2606 }
2607 }
2608
2609 #[tokio::test]
2610 async fn sync_notes_with_content_fetches_inclusive_upper_bound_page() {
2611 let (chain, note_tags) = build_chain_with_mint_notes(10).await;
2612 let mock_rpc = MockRpcApi::new(chain);
2613
2614 let blocks = mock_rpc
2615 .sync_notes_with_content(
2616 4_u32.into(),
2617 10_u32.into(),
2618 ¬e_tags,
2619 NoteContentFetch::PublicDetailsAndAttachments,
2620 )
2621 .await
2622 .expect("sync notes should succeed");
2623
2624 assert_eq!(blocks.last().unwrap().block_header.block_num(), BlockNumber::from(10u32));
2625 assert!(
2626 blocks
2627 .iter()
2628 .any(|block| block.block_header.block_num() == BlockNumber::from(9u32))
2629 );
2630 }
2631
2632 #[tokio::test]
2638 async fn erased_notes_are_marked_as_consumed() {
2639 let sender_id: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
2641 let partial_metadata = PartialNoteMetadata::new(sender_id, NoteType::Public);
2642 let metadata = NoteMetadata::new(partial_metadata, &NoteAttachments::empty());
2643 let script = CodeBuilder::new()
2644 .compile_note_script("@note_script\npub proc main\n nop\nend")
2645 .unwrap();
2646 let recipient = NoteRecipient::new(
2647 Word::from([1u32, 2, 3, 4]),
2648 script,
2649 NoteStorage::new(vec![]).unwrap(),
2650 );
2651 let output_note = OutputNoteRecord::new(
2652 recipient.digest(),
2653 NoteAssets::new(vec![]).unwrap(),
2654 metadata,
2655 OutputNoteState::ExpectedFull { recipient },
2656 BlockNumber::from(1u32),
2657 NoteAttachments::default(),
2658 );
2659 let note_id = output_note.id();
2660 let note_header = NoteHeader::new(output_note.details_commitment(), metadata);
2661
2662 let mut note_updates = NoteUpdateTracker::new(vec![], vec![output_note]);
2664
2665 let block_num = BlockNumber::from(3u32);
2667 note_updates
2668 .mark_erased_note_as_consumed(¬e_header, block_num)
2669 .expect("marking erased note should succeed");
2670
2671 let updated = note_updates
2672 .updated_output_notes()
2673 .find(|n| n.id() == note_id)
2674 .expect("output note should be in the update");
2675
2676 assert!(
2677 updated.inner().is_consumed(),
2678 "output note should be consumed after erasure detection, but state is: {}",
2679 updated.inner().state()
2680 );
2681 }
2682
2683 #[allow(clippy::too_many_lines)]
2699 #[ignore = "consumer derivation removed; see comment above"]
2700 #[tokio::test]
2701 async fn erased_notes_are_marked_as_consumed_by_network_account() {
2702 let mut builder = MockChainBuilder::new();
2705 let p2id_sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
2706 let faucet_id: AccountId = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into().unwrap();
2707 let sender_account =
2708 builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2709 let sender_id = sender_account.id();
2710
2711 let asset = Asset::from(FungibleAsset::new(faucet_id, 100u64).unwrap());
2712 let note = builder
2713 .add_p2id_note(p2id_sender, sender_id, &[asset], NoteType::Public)
2714 .unwrap();
2715
2716 let mut chain = builder.build().unwrap();
2717 chain.prove_next_block().unwrap();
2718
2719 let tx = Box::pin(
2720 chain
2721 .build_transaction(MockTransactionInput::Account(sender_account.clone()))
2722 .unauthenticated_input_note(note.clone())
2723 .build()
2724 .unwrap()
2725 .execute(),
2726 )
2727 .await
2728 .unwrap();
2729 chain.add_pending_executed_transaction(&tx).unwrap();
2730 chain.prove_next_block().unwrap();
2731
2732 let network_account_id: AccountId =
2734 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into().unwrap();
2735 let target =
2736 NetworkAccountTarget::new(network_account_id, NoteExecutionHint::Always).unwrap();
2737 let attachment: NoteAttachment = target.into();
2738 let attachments = NoteAttachments::new(vec![attachment]).unwrap();
2739 let partial_metadata = PartialNoteMetadata::new(sender_id, NoteType::Public);
2740 let metadata = NoteMetadata::new(partial_metadata, &attachments);
2741 let script = CodeBuilder::new()
2742 .compile_note_script("@note_script\npub proc main\n nop\nend")
2743 .unwrap();
2744 let recipient = NoteRecipient::new(
2745 Word::from([7u32, 8, 9, 10]),
2746 script,
2747 NoteStorage::new(vec![]).unwrap(),
2748 );
2749 let recipient_digest = recipient.digest();
2750 let assets = NoteAssets::new(vec![]).unwrap();
2751
2752 let output_note = OutputNoteRecord::new(
2755 recipient_digest,
2756 assets.clone(),
2757 metadata,
2758 OutputNoteState::ExpectedFull { recipient },
2759 BlockNumber::from(1u32),
2760 NoteAttachments::default(),
2761 );
2762 let erased_note_id = output_note.id();
2763 let erased_note_header = NoteHeader::new(output_note.details_commitment(), metadata);
2764
2765 let mock_rpc = MockRpcApi::new(chain);
2766 mock_rpc.mark_note_as_erased(erased_note_header);
2767
2768 let network_header =
2771 AccountHeader::new(network_account_id, ZERO, EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
2772
2773 let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
2774
2775 let genesis_peaks =
2776 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2777 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2778
2779 let sync_input = StateSyncInput {
2780 accounts: vec![AccountHeader::from(&sender_account), network_header],
2781 note_tags: BTreeSet::new(),
2782 input_notes: vec![],
2783 output_notes: vec![output_note],
2784 uncommitted_transactions: vec![],
2785 };
2786
2787 let update = state_sync.sync_state(&mut partial_mmr, sync_input).await.unwrap();
2788
2789 let updated_output = update
2791 .note_updates()
2792 .updated_output_notes()
2793 .find(|n| n.id() == erased_note_id)
2794 .expect("output note should be in the update");
2795 assert!(
2796 updated_output.inner().is_consumed(),
2797 "output note should be consumed, got: {}",
2798 updated_output.inner().state()
2799 );
2800
2801 let input_note_update = update
2803 .note_updates()
2804 .updated_input_notes()
2805 .find(|n| n.id() == Some(erased_note_id))
2806 .expect("input note should be created from the erased output note");
2807
2808 let inner = input_note_update.inner();
2809 assert!(
2810 inner.is_consumed(),
2811 "input note should be in a consumed state, got: {}",
2812 inner.state()
2813 );
2814 assert_eq!(
2815 inner.consumer_account(),
2816 Some(network_account_id),
2817 "consumer should be the tracked network account"
2818 );
2819 }
2820
2821 #[tokio::test]
2824 async fn validate_chain_mmr_response_rejects_tampered_responses() {
2825 let mock_rpc = MockRpcApi::default();
2826 mock_rpc.advance_blocks(3);
2827 let chain_tip = mock_rpc.get_chain_tip_block_num();
2828 let current = BlockNumber::GENESIS;
2829
2830 let header_of =
2831 |block_num: u32| mock_rpc.mock_chain.read().block_header(block_num as usize);
2832 let chain_mmr_response = || async {
2833 mock_rpc.sync_chain_mmr(current, SyncTarget::CommittedChainTip).await.unwrap()
2834 };
2835
2836 let response = chain_mmr_response().await;
2838 StateSync::validate_chain_mmr_response(&response, current).unwrap();
2839
2840 let mut response = chain_mmr_response().await;
2842 response.block_header = header_of(chain_tip.as_u32() - 1);
2843 let result = StateSync::validate_chain_mmr_response(&response, current);
2844 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2845
2846 let mut response = chain_mmr_response().await;
2848 response.block_from = current + 1;
2849 let result = StateSync::validate_chain_mmr_response(&response, current);
2850 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2851
2852 let mut response = chain_mmr_response().await;
2854 response.block_from = chain_tip;
2855 response.block_to = BlockNumber::GENESIS;
2856 response.block_header = header_of(0);
2857 let result = StateSync::validate_chain_mmr_response(&response, chain_tip);
2858 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2859 }
2860
2861 #[test]
2864 fn validate_note_blocks_range_rejects_out_of_range_blocks() {
2865 let mock_rpc = MockRpcApi::default();
2866 mock_rpc.advance_blocks(3);
2867 let chain_tip = mock_rpc.get_chain_tip_block_num();
2868 let current = BlockNumber::GENESIS;
2869
2870 StateSync::validate_note_blocks_range(&[], current, chain_tip).unwrap();
2872
2873 let genesis_note_block = ResolvedSyncNotesBlock {
2875 block_header: mock_rpc.mock_chain.read().block_header(0),
2876 mmr_path: MerklePath::new(Vec::new()),
2877 notes: BTreeMap::new(),
2878 };
2879 let result =
2880 StateSync::validate_note_blocks_range(&[genesis_note_block], current, chain_tip);
2881 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2882 }
2883
2884 #[test]
2887 fn advance_mmr_rejects_delta_inconsistent_with_chain_commitment() {
2888 let mock_rpc = MockRpcApi::default();
2889 mock_rpc.advance_blocks(3);
2890 let chain_tip = mock_rpc.get_chain_tip_block_num();
2891
2892 let chain_tip_header = mock_rpc.mock_chain.read().block_header(chain_tip.as_usize());
2893 let genesis_partial_mmr = || {
2894 let peaks = mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2895 PartialMmr::from_peaks(peaks)
2896 };
2897
2898 let full_delta = mock_rpc
2900 .get_mmr()
2901 .get_delta(Forest::new(1).unwrap(), Forest::new(chain_tip.as_usize()).unwrap())
2902 .unwrap();
2903 StateSync::advance_mmr(
2904 full_delta,
2905 &chain_tip_header,
2906 &mut genesis_partial_mmr(),
2907 &mut PartialBlockchainUpdates::default(),
2908 )
2909 .unwrap();
2910
2911 let truncated_delta = mock_rpc
2913 .get_mmr()
2914 .get_delta(Forest::new(1).unwrap(), Forest::new(chain_tip.as_usize() - 1).unwrap())
2915 .unwrap();
2916 let result = StateSync::advance_mmr(
2917 truncated_delta,
2918 &chain_tip_header,
2919 &mut genesis_partial_mmr(),
2920 &mut PartialBlockchainUpdates::default(),
2921 );
2922 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2923 }
2924
2925 fn make_tx_record(account_id: AccountId, block_num: u32) -> RpcTransactionRecord {
2927 RpcTransactionRecord {
2928 block_num: BlockNumber::from(block_num),
2929 transaction_header: TransactionHeader::new(
2930 account_id,
2931 word(1),
2932 word(2),
2933 InputNotes::new_unchecked(vec![]),
2934 vec![],
2935 )
2936 .unwrap(),
2937 output_notes: vec![],
2938 erased_output_notes: vec![],
2939 consumed_note_refs: vec![],
2940 }
2941 }
2942}