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, AccountWitness};
12use miden_protocol::block::{BlockHeader, BlockNumber, ValidatorConfig};
13use miden_protocol::crypto::merkle::MerklePath;
14use miden_protocol::crypto::merkle::mmr::{InOrderIndex, MmrDelta, PartialMmr};
15use miden_protocol::note::{NoteId, NoteTag, Nullifier};
16use miden_protocol::protocol_config::ProtocolConfig;
17use tracing::info;
18
19use super::state_sync_update::{TransactionUpdateTracker, build_account_patch};
20use super::{
21 AccountUpdates,
22 NoteObserver,
23 PartialBlockchainUpdates,
24 PublicAccountUpdate,
25 StateSyncUpdate,
26};
27use crate::ClientError;
28use crate::note::{NoteConsumption, NoteUpdateTracker};
29use crate::rpc::domain::account::{
30 AccountDetails,
31 AccountFetch,
32 AccountProof,
33 AccountStorageMapDetails,
34 GetAccountRequest,
35 StorageMapFetch,
36 VaultFetch,
37};
38use crate::rpc::domain::note::{CommittedNote, FetchedNote, ResolvedSyncNotesBlock, SyncedNote};
39use crate::rpc::domain::sync::{ChainMmrInfo, SyncTarget};
40use crate::rpc::domain::transaction::TransactionRecord as RpcTransactionRecord;
41use crate::rpc::{AccountStateAt, NodeRpcClient, NoteContentFetch, RpcError};
42use crate::store::input_note_states::UnverifiedNoteState;
43use crate::store::{InputNoteRecord, OutputNoteRecord, StoreError};
44use crate::transaction::TransactionRecord;
45
46pub(crate) const MAX_CONCURRENT_ACCOUNT_FETCHES: usize = 4;
48
49enum PublicAccountSync {
54 Apply(Box<PublicAccountUpdate>),
56 Superseded,
58 Ignore,
60}
61
62struct FetchedSyncData {
68 mmr_delta: MmrDelta,
70 chain_tip_header: BlockHeader,
72 note_blocks: Vec<ResolvedSyncNotesBlock>,
75 transactions: Vec<RpcTransactionRecord>,
77 protocol_config: Option<ProtocolConfig>,
80}
81
82struct RecoverableConsumedNote {
87 nullifier: Nullifier,
88 consumer: AccountId,
89 block_num: BlockNumber,
90}
91
92struct RelevantNoteBlock {
97 block_header: BlockHeader,
98 mmr_path: MerklePath,
99 observer_requires_block: bool,
100}
101
102#[derive(Default)]
104struct NoteBlockRelevance {
105 has_client_note: bool,
106 observer_requires_block: bool,
107}
108
109impl NoteBlockRelevance {
110 fn is_relevant(&self) -> bool {
111 self.has_client_note || self.observer_requires_block
112 }
113}
114
115pub struct StateSyncInput {
131 pub accounts: Vec<AccountHeader>,
133 pub note_tags: BTreeSet<NoteTag>,
135 pub input_notes: Vec<InputNoteRecord>,
137 pub output_notes: Vec<OutputNoteRecord>,
143 pub uncommitted_transactions: Vec<TransactionRecord>,
145}
146
147#[allow(clippy::large_enum_variant)]
152pub enum NoteUpdateAction {
153 Commit(CommittedNote),
156 Insert(InputNoteRecord),
158 Discard,
160}
161
162#[async_trait(?Send)]
163pub trait OnNoteReceived {
164 async fn on_note_received(
175 &self,
176 committed_note: CommittedNote,
177 public_note: Option<InputNoteRecord>,
178 ) -> Result<NoteUpdateAction, ClientError>;
179}
180#[derive(Clone)]
187pub struct StateSync {
188 rpc_api: Arc<dyn NodeRpcClient>,
190 note_screener: Arc<dyn OnNoteReceived>,
193 note_observers: Vec<Arc<dyn NoteObserver>>,
196 tx_discard_delta: Option<u32>,
199 sync_nullifiers: bool,
202 validator_config: ValidatorConfig,
205}
206
207impl StateSync {
208 pub fn new(
220 rpc_api: Arc<dyn NodeRpcClient>,
221 note_screener: Arc<dyn OnNoteReceived>,
222 tx_discard_delta: Option<u32>,
223 validator_config: ValidatorConfig,
224 ) -> Self {
225 Self {
226 rpc_api,
227 note_screener,
228 note_observers: Vec::new(),
229 tx_discard_delta,
230 sync_nullifiers: true,
231 validator_config,
232 }
233 }
234
235 #[must_use]
239 pub fn with_note_observer(mut self, observer: Arc<dyn NoteObserver>) -> Self {
240 self.note_observers.push(observer);
241 self
242 }
243
244 pub fn disable_nullifier_sync(&mut self) {
249 self.sync_nullifiers = false;
250 }
251
252 pub fn enable_nullifier_sync(&mut self) {
254 self.sync_nullifiers = true;
255 }
256
257 pub(crate) async fn run_apply_hooks(
262 &self,
263 state_sync_update: &StateSyncUpdate,
264 ) -> Result<(), ClientError> {
265 for observer in &self.note_observers {
266 crate::errors::log_observer_failure(
267 observer.name(),
268 "NoteObserver::apply",
269 observer.apply(state_sync_update).await,
270 );
271 }
272 Ok(())
273 }
274
275 pub async fn sync_state(
298 &self,
299 current_partial_mmr: &mut PartialMmr,
300 input: StateSyncInput,
301 ) -> Result<StateSyncUpdate, ClientError> {
302 let block_num = block_num_from_forest(current_partial_mmr)?;
303
304 let mut chain_sync_data = self.fetch_state(block_num, input).await?;
305 self.derive_state_updates(&mut chain_sync_data).await?;
306 self.fetch_nullifiers(&mut chain_sync_data).await?;
307
308 let mut working_mmr = current_partial_mmr.clone();
310 let update = Self::build_update(chain_sync_data, &mut working_mmr)?;
311 *current_partial_mmr = working_mmr;
312
313 Ok(update)
314 }
315
316 pub async fn fetch_state(
325 &self,
326 block_from: BlockNumber,
327 input: StateSyncInput,
328 ) -> Result<ChainSyncData, ClientError> {
329 let StateSyncInput {
330 accounts,
331 note_tags,
332 input_notes,
333 output_notes,
334 uncommitted_transactions,
335 } = input;
336
337 let note_tags = Arc::new(note_tags);
338 let account_ids: Vec<AccountId> = accounts.iter().map(AccountHeader::id).collect();
339
340 let note_updates = NoteUpdateTracker::new(input_notes, output_notes);
341 let transaction_updates = TransactionUpdateTracker::new(uncommitted_transactions);
342 let mut account_updates = AccountUpdates::default();
343
344 let Some(sync_data) = self.fetch_sync_data(block_from, &account_ids, ¬e_tags).await?
345 else {
346 return Ok(ChainSyncData {
348 block_from,
349 advance: None,
350 superseded_states: Vec::new(),
351 note_updates,
352 transaction_updates,
353 account_updates,
354 });
355 };
356
357 let FetchedSyncData {
358 mmr_delta,
359 chain_tip_header,
360 note_blocks,
361 transactions,
362 protocol_config,
363 } = sync_data;
364
365 let new_commitments = derive_account_commitments(&transactions);
366 let superseded_states = self
367 .account_state_sync(
368 &mut account_updates,
369 &accounts,
370 &new_commitments,
371 block_from,
372 &chain_tip_header,
373 )
374 .await?;
375
376 Ok(ChainSyncData {
377 block_from,
378 advance: Some(ChainAdvance {
379 chain_tip_header,
380 mmr_delta,
381 note_blocks_awaiting_screening: note_blocks,
382 transactions,
383 relevant_note_blocks: Vec::new(),
384 protocol_config,
385 }),
386 superseded_states,
387 note_updates,
388 transaction_updates,
389 account_updates,
390 })
391 }
392
393 pub async fn derive_state_updates(
399 &self,
400 chain_sync_data: &mut ChainSyncData,
401 ) -> Result<(), ClientError> {
402 let ChainSyncData {
403 advance,
404 superseded_states,
405 note_updates,
406 transaction_updates,
407 ..
408 } = chain_sync_data;
409
410 let Some(advance) = advance.as_mut() else {
411 return Ok(());
412 };
413
414 for superseded_state in core::mem::take(superseded_states) {
416 transaction_updates.apply_superseded_account_state(superseded_state);
417 }
418
419 advance.relevant_note_blocks = self
420 .screen_note_blocks(
421 core::mem::take(&mut advance.note_blocks_awaiting_screening),
422 note_updates,
423 )
424 .await?;
425
426 self.apply_transactions_and_nullifiers(
427 &advance.chain_tip_header,
428 &advance.transactions,
429 note_updates,
430 transaction_updates,
431 )?;
432
433 self.recover_consumed_public_notes(note_updates, &advance.transactions).await?;
434
435 Ok(())
436 }
437
438 pub fn build_update(
444 chain_sync_data: ChainSyncData,
445 partial_mmr: &mut PartialMmr,
446 ) -> Result<StateSyncUpdate, ClientError> {
447 let ChainSyncData {
448 block_from,
449 advance,
450 note_updates,
451 transaction_updates,
452 account_updates,
453 ..
454 } = chain_sync_data;
455
456 let mut partial_blockchain_updates = PartialBlockchainUpdates::default();
457
458 let Some(ChainAdvance {
459 chain_tip_header,
460 mmr_delta,
461 note_blocks_awaiting_screening,
462 relevant_note_blocks,
463 protocol_config,
464 ..
465 }) = advance
466 else {
467 return Ok(StateSyncUpdate::from_parts(
469 block_from,
470 partial_blockchain_updates,
471 note_updates,
472 transaction_updates,
473 account_updates,
474 None,
475 ));
476 };
477 if !note_blocks_awaiting_screening.is_empty() {
479 return Err(ClientError::UnscreenedNoteBlocks);
480 }
481
482 let chain_tip = chain_tip_header.block_num();
483
484 Self::advance_mmr(
485 mmr_delta,
486 &chain_tip_header,
487 partial_mmr,
488 &mut partial_blockchain_updates,
489 )?;
490
491 let blocks_with_unspent_notes: BTreeSet<BlockNumber> =
492 note_updates.unspent_input_note_block_numbers().collect();
493
494 Self::validate_and_track_note_blocks(
495 relevant_note_blocks,
496 &blocks_with_unspent_notes,
497 partial_mmr,
498 &mut partial_blockchain_updates,
499 )?;
500
501 Ok(StateSyncUpdate::from_parts(
502 chain_tip,
503 partial_blockchain_updates,
504 note_updates,
505 transaction_updates,
506 account_updates,
507 protocol_config,
508 ))
509 }
510
511 pub async fn fetch_nullifiers(
520 &self,
521 chain_sync_data: &mut ChainSyncData,
522 ) -> Result<(), ClientError> {
523 if !self.sync_nullifiers {
524 return Ok(());
525 }
526
527 let Some(chain_tip) = chain_sync_data
528 .advance
529 .as_ref()
530 .map(|advance| advance.chain_tip_header.block_num())
531 else {
532 return Ok(());
533 };
534
535 self.nullifiers_state_sync(
536 &mut chain_sync_data.note_updates,
537 &mut chain_sync_data.transaction_updates,
538 chain_tip,
539 chain_sync_data.block_from,
540 )
541 .await
542 }
543
544 async fn recover_consumed_public_notes(
549 &self,
550 note_updates: &mut NoteUpdateTracker,
551 transactions: &[RpcTransactionRecord],
552 ) -> Result<(), ClientError> {
553 let mut recoverable_consumed_notes: BTreeMap<NoteId, RecoverableConsumedNote> =
554 BTreeMap::new();
555 for tx in transactions {
556 for (nullifier, note_id) in tx.trusted_consumed_note_refs() {
557 recoverable_consumed_notes.insert(
558 note_id,
559 RecoverableConsumedNote {
560 nullifier,
561 consumer: tx.transaction_header.account_id(),
562 block_num: tx.block_num,
563 },
564 );
565 }
566 }
567 recoverable_consumed_notes.retain(|note_id, _| !note_updates.tracks_note(*note_id));
570
571 let note_ids: Vec<NoteId> = recoverable_consumed_notes.keys().copied().collect();
572 if note_ids.is_empty() {
573 return Ok(());
574 }
575
576 for fetched in self.rpc_api.get_notes_by_id(¬e_ids).await? {
577 match fetched {
578 FetchedNote::Public(note, _) => {
579 let Some(reference) = recoverable_consumed_notes.get(¬e.id()) else {
580 continue;
581 };
582 if note.nullifier() != reference.nullifier {
585 return Err(RpcError::InvalidResponse(format!(
586 "node returned note {} whose nullifier doesn't match the consumed reference",
587 note.id()
588 ))
589 .into());
590 }
591 note_updates.insert_consumed_public_note(
592 note,
593 reference.consumer,
594 reference.block_num,
595 )?;
596 },
597 FetchedNote::Private(note_id, ..) => {
598 return Err(RpcError::InvalidResponse(format!(
599 "node returned private note {note_id} for a public consumed-note reference"
600 ))
601 .into());
602 },
603 }
604 }
605
606 Ok(())
607 }
608
609 async fn fetch_sync_data(
619 &self,
620 current_block_num: BlockNumber,
621 account_ids: &[AccountId],
622 note_tags: &Arc<BTreeSet<NoteTag>>,
623 ) -> Result<Option<FetchedSyncData>, ClientError> {
624 let chain_mmr_info = self
626 .rpc_api
627 .sync_chain_mmr(current_block_num, SyncTarget::CommittedChainTip)
628 .await?;
629 let chain_tip = chain_mmr_info.block_to;
630
631 Self::validate_chain_mmr_response(
633 &chain_mmr_info,
634 current_block_num,
635 &self.validator_config,
636 )?;
637
638 if chain_tip == current_block_num {
640 info!(block_num = %current_block_num, "Already at chain tip, nothing to sync.");
641 return Ok(None);
642 }
643
644 info!(
645 block_from = %current_block_num,
646 block_to = %chain_tip,
647 "Syncing state.",
648 );
649
650 let (note_blocks, transaction_records) = futures::try_join!(
653 self.fetch_note_blocks(current_block_num + 1, chain_tip, note_tags),
654 self.fetch_transactions(current_block_num + 1, chain_tip, account_ids),
655 )?;
656
657 Self::validate_note_blocks_range(¬e_blocks, current_block_num, chain_tip)?;
659
660 let note_count: usize = note_blocks.iter().map(|b| b.notes.len()).sum();
661 info!(
662 blocks_with_notes = note_blocks.len(),
663 notes = note_count,
664 "Fetched note sync data.",
665 );
666
667 Self::validate_transaction_records_range(
668 &transaction_records,
669 current_block_num,
670 chain_tip,
671 )?;
672
673 Ok(Some(FetchedSyncData {
674 mmr_delta: chain_mmr_info.mmr_delta,
675 chain_tip_header: chain_mmr_info.block_header,
676 note_blocks,
677 transactions: transaction_records,
678 protocol_config: chain_mmr_info.protocol_config,
679 }))
680 }
681
682 async fn fetch_note_blocks(
688 &self,
689 block_from: BlockNumber,
690 block_to: BlockNumber,
691 note_tags: &BTreeSet<NoteTag>,
692 ) -> Result<Vec<ResolvedSyncNotesBlock>, ClientError> {
693 if note_tags.is_empty() {
694 return Ok(Vec::new());
695 }
696
697 self.rpc_api
698 .sync_notes_with_content(
699 block_from,
700 block_to,
701 note_tags,
702 NoteContentFetch::PublicDetailsAndAttachments,
703 )
704 .await
705 .map_err(ClientError::RpcError)
706 }
707
708 async fn fetch_transactions(
710 &self,
711 block_from: BlockNumber,
712 block_to: BlockNumber,
713 account_ids: &[AccountId],
714 ) -> Result<Vec<RpcTransactionRecord>, ClientError> {
715 if account_ids.is_empty() {
716 return Ok(Vec::new());
717 }
718
719 self.rpc_api
720 .sync_transactions(block_from, block_to, account_ids.to_vec())
721 .await
722 .map_err(ClientError::RpcError)
723 }
724
725 fn validate_chain_mmr_response(
728 chain_mmr_info: &ChainMmrInfo,
729 current_block_num: BlockNumber,
730 validator_config: &ValidatorConfig,
731 ) -> Result<(), ClientError> {
732 if chain_mmr_info.block_header.block_num() != chain_mmr_info.block_to {
733 return Err(ClientError::ChainValidationError(format!(
734 "sync_chain_mmr block_header.block_num ({}) does not match block_to ({})",
735 chain_mmr_info.block_header.block_num(),
736 chain_mmr_info.block_to
737 )));
738 }
739 if chain_mmr_info.block_from != current_block_num {
740 return Err(ClientError::ChainValidationError(format!(
741 "sync_chain_mmr block_from mismatch: expected {current_block_num}, got {}",
742 chain_mmr_info.block_from
743 )));
744 }
745 if chain_mmr_info.block_to < current_block_num {
746 return Err(ClientError::ChainValidationError(format!(
747 "sync_chain_mmr block_to ({}) is behind current block {current_block_num}",
748 chain_mmr_info.block_to
749 )));
750 }
751
752 chain_mmr_info
754 .block_signatures
755 .verify_against(chain_mmr_info.block_header.commitment(), validator_config)
756 .map_err(|err| {
757 ClientError::ChainValidationError(format!(
758 "chain tip block header {} does not carry valid validator signatures: {err}",
759 chain_mmr_info.block_header.block_num()
760 ))
761 })?;
762
763 Ok(())
764 }
765
766 fn validate_note_blocks_range(
769 note_blocks: &[ResolvedSyncNotesBlock],
770 current_block_num: BlockNumber,
771 chain_tip: BlockNumber,
772 ) -> Result<(), ClientError> {
773 for block in note_blocks {
774 let block_num = block.block_header.block_num();
775 if block_num <= current_block_num || block_num > chain_tip {
776 return Err(ClientError::ChainValidationError(format!(
777 "sync_notes returned block {block_num} outside requested range ({current_block_num}, {chain_tip}]"
778 )));
779 }
780 }
781 Ok(())
782 }
783
784 fn validate_transaction_records_range(
787 records: &[RpcTransactionRecord],
788 current_block_num: BlockNumber,
789 chain_tip: BlockNumber,
790 ) -> Result<(), ClientError> {
791 for record in records {
792 let block_num = record.block_num;
793 if block_num <= current_block_num || block_num > chain_tip {
794 return Err(ClientError::ChainValidationError(format!(
795 "sync_transactions returned block {block_num} outside requested range ({current_block_num}, {chain_tip}]"
796 )));
797 }
798 }
799 Ok(())
800 }
801
802 fn advance_mmr(
809 mmr_delta: MmrDelta,
810 chain_tip_header: &BlockHeader,
811 current_partial_mmr: &mut PartialMmr,
812 partial_blockchain_updates: &mut PartialBlockchainUpdates,
813 ) -> Result<(), ClientError> {
814 let mut new_authentication_nodes =
815 current_partial_mmr.apply(mmr_delta).map_err(StoreError::MmrError)?;
816 let new_peaks = current_partial_mmr.peaks();
817
818 let peaks_commitment = new_peaks.hash_peaks();
822 if peaks_commitment != chain_tip_header.chain_commitment() {
823 return Err(ClientError::ChainValidationError(format!(
824 "MMR peaks commitment is {} and does not match block header chain commitment {}",
825 peaks_commitment.to_hex(),
826 chain_tip_header.chain_commitment().to_hex()
827 )));
828 }
829
830 partial_blockchain_updates.new_peaks = new_peaks;
831
832 new_authentication_nodes.append(
836 &mut current_partial_mmr
837 .add(chain_tip_header.commitment(), false)
838 .map_err(StoreError::MmrError)?,
839 );
840
841 partial_blockchain_updates.insert(chain_tip_header.clone(), false);
842 partial_blockchain_updates.extend_authentication_nodes(new_authentication_nodes);
843
844 Ok(())
845 }
846
847 async fn screen_note_blocks(
855 &self,
856 note_blocks: Vec<ResolvedSyncNotesBlock>,
857 note_updates: &mut NoteUpdateTracker,
858 ) -> Result<Vec<RelevantNoteBlock>, ClientError> {
859 let mut relevant_blocks = Vec::new();
860
861 for block in note_blocks {
862 let relevance =
863 self.note_state_sync(note_updates, block.notes, &block.block_header).await?;
864
865 if relevance.is_relevant() {
866 relevant_blocks.push(RelevantNoteBlock {
867 block_header: block.block_header,
868 mmr_path: block.mmr_path,
869 observer_requires_block: relevance.observer_requires_block,
870 });
871 }
872 }
873
874 Ok(relevant_blocks)
875 }
876
877 fn validate_and_track_note_blocks(
887 relevant_blocks: Vec<RelevantNoteBlock>,
888 blocks_with_unspent_notes: &BTreeSet<BlockNumber>,
889 partial_mmr: &mut PartialMmr,
890 partial_blockchain_updates: &mut PartialBlockchainUpdates,
891 ) -> Result<(), ClientError> {
892 let nodes_before: BTreeSet<InOrderIndex> = partial_mmr.nodes().map(|(k, _)| *k).collect();
893
894 for RelevantNoteBlock {
895 block_header,
896 mmr_path,
897 observer_requires_block,
898 } in relevant_blocks
899 {
900 let block_pos = block_header.block_num().as_usize();
901 let was_tracked = partial_mmr.is_tracked(block_pos);
902
903 partial_mmr
906 .track(block_pos, block_header.commitment(), &mmr_path)
907 .map_err(StoreError::MmrError)?;
908
909 if observer_requires_block
910 || blocks_with_unspent_notes.contains(&block_header.block_num())
911 {
912 partial_blockchain_updates.insert(block_header, true);
913 } else if !was_tracked {
914 partial_mmr.untrack(block_pos);
915 }
916 }
917
918 partial_blockchain_updates.extend_authentication_nodes(
920 partial_mmr
921 .nodes()
922 .filter(|(index, _)| !nodes_before.contains(index))
923 .map(|(index, value)| (*index, *value)),
924 );
925
926 Ok(())
927 }
928
929 fn apply_transactions_and_nullifiers(
933 &self,
934 chain_tip_header: &BlockHeader,
935 transactions: &[RpcTransactionRecord],
936 note_updates: &mut NoteUpdateTracker,
937 transaction_updates: &mut TransactionUpdateTracker,
938 ) -> Result<(), ClientError> {
939 note_updates.extend_nullifiers(compute_ordered_nullifiers(transactions));
940
941 for record in transactions {
942 transaction_updates
943 .apply_transaction_inclusion(record, u64::from(chain_tip_header.timestamp())); }
945 transaction_updates
946 .apply_sync_height_update(chain_tip_header.block_num(), self.tx_discard_delta);
947
948 for transaction in transactions {
949 note_updates.apply_output_note_inclusion_proofs(&transaction.output_notes)?;
953
954 Self::mark_erased_notes_as_consumed(note_updates, transaction);
956 }
957
958 Ok(())
959 }
960
961 fn mark_erased_notes_as_consumed(
967 note_updates: &mut NoteUpdateTracker,
968 transaction: &RpcTransactionRecord,
969 ) {
970 for note_header in &transaction.erased_output_notes {
971 let _ = note_updates.mark_erased_note_as_consumed(note_header, transaction.block_num);
973 }
974 }
975
976 async fn account_state_sync(
989 &self,
990 account_updates: &mut AccountUpdates,
991 accounts: &[AccountHeader],
992 account_commitment_updates: &[(AccountId, Word)],
993 block_from: BlockNumber,
994 chain_tip_header: &BlockHeader,
995 ) -> Result<Vec<Word>, ClientError> {
996 let (public_accounts, private_accounts): (Vec<_>, Vec<_>) =
999 accounts.iter().partition(|header| !header.id().is_private());
1000
1001 let superseded_states = self
1002 .sync_public_accounts(
1003 account_updates,
1004 account_commitment_updates,
1005 &public_accounts,
1006 block_from,
1007 chain_tip_header,
1008 )
1009 .await?;
1010
1011 let diverging_private_accounts: Vec<&AccountHeader> = private_accounts
1014 .into_iter()
1015 .filter(|header| {
1016 let local_commitment = header.to_commitment();
1017 account_commitment_updates
1018 .iter()
1019 .any(|(id, digest)| *id == header.id() && *digest != local_commitment)
1020 })
1021 .collect();
1022
1023 let proven_commitments: Vec<Option<Word>> =
1024 futures::stream::iter(diverging_private_accounts.iter().map(|header| {
1025 self.verify_private_account_mismatch(
1026 header.id(),
1027 header.to_commitment(),
1028 chain_tip_header,
1029 )
1030 }))
1031 .buffered(MAX_CONCURRENT_ACCOUNT_FETCHES)
1032 .try_collect()
1033 .await?;
1034
1035 let mismatched_private_accounts: Vec<(AccountId, Word)> = diverging_private_accounts
1036 .iter()
1037 .zip(proven_commitments)
1038 .filter_map(|(header, proven_commitment)| {
1039 proven_commitment.map(|commitment| (header.id(), commitment))
1040 })
1041 .collect();
1042
1043 account_updates.extend(AccountUpdates::new(Vec::new(), mismatched_private_accounts));
1044
1045 Ok(superseded_states)
1046 }
1047
1048 async fn verify_private_account_mismatch(
1059 &self,
1060 account_id: AccountId,
1061 local_commitment: Word,
1062 chain_tip_header: &BlockHeader,
1063 ) -> Result<Option<Word>, ClientError> {
1064 let chain_tip = chain_tip_header.block_num();
1065 let (proof_block_num, proof) = self
1066 .rpc_api
1067 .get_account(GetAccountRequest::new(account_id).at(AccountStateAt::Block(chain_tip)))
1068 .await?;
1069
1070 if proof_block_num != chain_tip {
1071 return Err(ClientError::ChainValidationError(format!(
1072 "get_account returned a proof at block {proof_block_num}, expected chain tip {chain_tip}"
1073 )));
1074 }
1075
1076 let (witness, _) = proof.into_parts();
1077 let witness_id = witness.id();
1078 let proven_commitment = witness.state_commitment();
1079 if witness.into_proof().compute_root() != chain_tip_header.account_root() {
1082 return Err(ClientError::ChainValidationError(format!(
1083 "account witness for {account_id} does not verify against the chain tip account root"
1084 )));
1085 }
1086
1087 if witness_id != account_id
1090 || proven_commitment == Word::empty()
1091 || proven_commitment == local_commitment
1092 {
1093 return Ok(None);
1094 }
1095
1096 Ok(Some(proven_commitment))
1097 }
1098
1099 async fn sync_public_accounts(
1108 &self,
1109 account_updates: &mut AccountUpdates,
1110 commitment_updates: &[(AccountId, Word)],
1111 current_public_accounts: &[&AccountHeader],
1112 block_from: BlockNumber,
1113 chain_tip_header: &BlockHeader,
1114 ) -> Result<Vec<Word>, ClientError> {
1115 let local_headers: BTreeMap<AccountId, &AccountHeader> =
1116 current_public_accounts.iter().map(|header| (header.id(), *header)).collect();
1117
1118 let diverging_accounts: Vec<(AccountId, &AccountHeader)> = commitment_updates
1121 .iter()
1122 .filter_map(|(id, commitment)| {
1123 let local_header = local_headers.get(id).copied()?;
1124 (local_header.to_commitment() != *commitment).then_some((*id, local_header))
1125 })
1126 .collect();
1127
1128 let synced_accounts: Vec<(AccountWitness, PublicAccountSync)> =
1131 futures::stream::iter(diverging_accounts.iter().map(|(id, local_header)| {
1132 self.sync_public_account(*id, local_header, block_from, chain_tip_header)
1133 }))
1134 .buffered(MAX_CONCURRENT_ACCOUNT_FETCHES)
1135 .try_collect()
1136 .await?;
1137
1138 let mut superseded_states = Vec::new();
1140 let mut account_witnesses = Vec::with_capacity(synced_accounts.len());
1141 for ((account_id, local_header), (witness, synced_account)) in
1142 diverging_accounts.iter().zip(synced_accounts)
1143 {
1144 account_witnesses.push((*account_id, witness));
1145
1146 match synced_account {
1147 PublicAccountSync::Apply(public_update) => {
1148 account_updates.extend(AccountUpdates::new(vec![*public_update], Vec::new()));
1149 },
1150 PublicAccountSync::Superseded => {
1151 superseded_states.push(local_header.to_commitment());
1152 },
1153 PublicAccountSync::Ignore => {},
1154 }
1155 }
1156
1157 account_updates.extend(AccountUpdates::default().with_account_witnesses(account_witnesses));
1158
1159 Ok(superseded_states)
1160 }
1161
1162 async fn sync_public_account(
1173 &self,
1174 account_id: AccountId,
1175 local_header: &AccountHeader,
1176 block_from: BlockNumber,
1177 chain_tip_header: &BlockHeader,
1178 ) -> Result<(AccountWitness, PublicAccountSync), ClientError> {
1179 let target_block_num = chain_tip_header.block_num();
1180
1181 let (proof_block_num, proof) = self
1184 .rpc_api
1185 .get_account(
1186 GetAccountRequest::new(account_id)
1187 .at(AccountStateAt::Block(target_block_num))
1188 .with_fetch(
1189 AccountFetch::new()
1190 .with_storage(StorageMapFetch::All)
1191 .with_vault(VaultFetch::Always),
1192 ),
1193 )
1194 .await
1195 .map_err(ClientError::RpcError)?;
1196
1197 let (witness, details) =
1198 Self::validate_account_proof(proof, proof_block_num, account_id, chain_tip_header)?;
1199
1200 match details
1201 .header
1202 .nonce()
1203 .as_canonical_u64()
1204 .cmp(&local_header.nonce().as_canonical_u64())
1205 {
1206 Ordering::Less => return Ok((witness, PublicAccountSync::Ignore)),
1209 Ordering::Equal => return Ok((witness, PublicAccountSync::Superseded)),
1211 Ordering::Greater => {},
1213 }
1214
1215 let vault_oversized = details.vault_details.is_too_many_assets();
1216 let any_map_oversized = details
1217 .storage_details
1218 .map_details
1219 .iter()
1220 .any(AccountStorageMapDetails::is_limit_exceeded);
1221
1222 let public_update = if vault_oversized || any_map_oversized {
1226 self.build_patch_update(account_id, local_header, &details, block_from, proof_block_num)
1228 .await?
1229 } else {
1230 let account = Account::try_from(&details).map_err(ClientError::RpcError)?;
1232 PublicAccountUpdate::Full(account)
1233 };
1234
1235 Ok((witness, PublicAccountSync::Apply(Box::new(public_update))))
1236 }
1237
1238 fn validate_account_proof(
1251 proof: AccountProof,
1252 proof_block_num: BlockNumber,
1253 account_id: AccountId,
1254 chain_tip_header: &BlockHeader,
1255 ) -> Result<(AccountWitness, AccountDetails), ClientError> {
1256 let target_block_num = chain_tip_header.block_num();
1257
1258 if proof_block_num != target_block_num {
1259 return Err(ClientError::ChainValidationError(format!(
1260 "get_account returned block {proof_block_num} but {target_block_num} was requested"
1261 )));
1262 }
1263
1264 let (witness, details) = proof.into_parts();
1265
1266 validate_account_witness(&witness, account_id, chain_tip_header)?;
1267
1268 let details = details.ok_or_else(|| {
1269 ClientError::ChainValidationError(format!(
1270 "get_account returned no details for public account {account_id}"
1271 ))
1272 })?;
1273
1274 Ok((witness, details))
1275 }
1276
1277 async fn build_patch_update(
1295 &self,
1296 account_id: AccountId,
1297 local_header: &AccountHeader,
1298 details: &AccountDetails,
1299 block_from: BlockNumber,
1300 block_to: BlockNumber,
1301 ) -> Result<PublicAccountUpdate, ClientError> {
1302 let value_slot_updates: Vec<(_, Word)> = details
1303 .storage_details
1304 .header
1305 .slots()
1306 .filter(|slot| slot.slot_type() == StorageSlotType::Value)
1307 .map(|slot| (slot.name().clone(), slot.value()))
1308 .collect();
1309
1310 let map_info = self
1313 .rpc_api
1314 .sync_storage_maps(block_from + 1, block_to, account_id)
1315 .await
1316 .map_err(ClientError::RpcError)?;
1317 let vault_info = self
1318 .rpc_api
1319 .sync_account_vault(block_from + 1, block_to, account_id)
1320 .await
1321 .map_err(ClientError::RpcError)?;
1322
1323 let patch = build_account_patch(
1324 &details.header,
1325 value_slot_updates,
1326 map_info.map_entries,
1327 vault_info.vault_patch,
1328 details.code.clone(),
1329 local_header.code_commitment(),
1330 )
1331 .map_err(StoreError::AccountPatchError)?;
1332
1333 Ok(PublicAccountUpdate::Patch {
1334 new_header: details.header.clone(),
1335 patch,
1336 })
1337 }
1338
1339 async fn note_state_sync(
1357 &self,
1358 note_updates: &mut NoteUpdateTracker,
1359 notes: BTreeMap<NoteId, SyncedNote>,
1360 block_header: &BlockHeader,
1361 ) -> Result<NoteBlockRelevance, ClientError> {
1362 let mut relevance = NoteBlockRelevance::default();
1363
1364 for (_, mut note) in notes {
1365 if !self.note_observers.is_empty() {
1368 for obs in &self.note_observers {
1369 match obs.observe(¬e).await {
1370 Ok(true) => relevance.observer_requires_block = true,
1371 Ok(false) => {},
1372 Err(err) => {
1373 tracing::warn!(
1374 observer = obs.name(),
1375 error = ?err,
1376 "note observer failed; sync continues",
1377 );
1378 },
1379 }
1380 }
1381 }
1382
1383 let public_note = note.details.take().map(|details| {
1386 let state = UnverifiedNoteState {
1387 metadata: note.metadata,
1388 inclusion_proof: note.inclusion_proof.clone(),
1389 }
1390 .into();
1391 InputNoteRecord::new(details, note.attachments.clone(), None, state)
1392 });
1393
1394 let committed = note.into_committed_note();
1395
1396 match self.note_screener.on_note_received(committed, public_note).await? {
1397 NoteUpdateAction::Commit(committed_note) => {
1398 relevance.has_client_note |= note_updates
1402 .apply_committed_note_state_transitions(&committed_note, block_header)?;
1403 },
1404 NoteUpdateAction::Insert(public_note) => {
1405 relevance.has_client_note = true;
1406
1407 note_updates.apply_new_public_note(public_note, block_header)?;
1408 },
1409 NoteUpdateAction::Discard => {},
1410 }
1411 }
1412
1413 Ok(relevance)
1414 }
1415
1416 async fn nullifiers_state_sync(
1422 &self,
1423 note_updates: &mut NoteUpdateTracker,
1424 transaction_updates: &mut TransactionUpdateTracker,
1425 chain_tip: BlockNumber,
1426 current_block_num: BlockNumber,
1427 ) -> Result<(), ClientError> {
1428 let nullifiers_tags: Vec<u16> =
1434 note_updates.unspent_nullifiers().map(|nullifier| nullifier.prefix()).collect();
1435
1436 let mut new_nullifiers = self
1437 .rpc_api
1438 .sync_nullifiers(&nullifiers_tags, current_block_num + 1, chain_tip)
1439 .await?;
1440
1441 new_nullifiers.retain(|update| update.block_num <= chain_tip);
1444
1445 let consumptions: Vec<NoteConsumption> = new_nullifiers
1447 .into_iter()
1448 .map(|update| NoteConsumption {
1449 external_consumer: transaction_updates
1450 .external_nullifier_account(&update.nullifier),
1451 nullifier: update.nullifier,
1452 block_num: update.block_num,
1453 })
1454 .collect();
1455
1456 for consumption in consumptions {
1457 note_updates.apply_note_consumption(
1458 &consumption,
1459 transaction_updates.committed_transactions(),
1460 )?;
1461
1462 transaction_updates.apply_input_note_nullified(consumption.nullifier);
1466 }
1467
1468 Ok(())
1469 }
1470}
1471
1472pub struct ChainSyncData {
1481 pub(crate) block_from: BlockNumber,
1483 advance: Option<ChainAdvance>,
1486 superseded_states: Vec<Word>,
1488 pub(crate) note_updates: NoteUpdateTracker,
1491 transaction_updates: TransactionUpdateTracker,
1492 pub(crate) account_updates: AccountUpdates,
1495}
1496
1497impl ChainSyncData {
1498 pub(crate) fn chain_tip_header(&self) -> Option<&BlockHeader> {
1501 self.advance.as_ref().map(|advance| &advance.chain_tip_header)
1502 }
1503}
1504
1505struct ChainAdvance {
1507 chain_tip_header: BlockHeader,
1509 mmr_delta: MmrDelta,
1511 note_blocks_awaiting_screening: Vec<ResolvedSyncNotesBlock>,
1514 transactions: Vec<RpcTransactionRecord>,
1516 relevant_note_blocks: Vec<RelevantNoteBlock>,
1518 protocol_config: Option<ProtocolConfig>,
1520}
1521
1522pub(crate) fn validate_account_witness(
1535 witness: &AccountWitness,
1536 account_id: AccountId,
1537 chain_tip_header: &BlockHeader,
1538) -> Result<(), ClientError> {
1539 if witness.id() != account_id {
1541 return Err(ClientError::ChainValidationError(format!(
1542 "get_account returned account {} but {account_id} was requested",
1543 witness.id()
1544 )));
1545 }
1546
1547 let account_key = AccountIdKey::from(account_id).as_word();
1548 let state_commitment = witness.state_commitment();
1549 witness
1550 .clone()
1551 .into_proof()
1552 .verify_presence(&account_key, &state_commitment, &chain_tip_header.account_root())
1553 .map_err(|err| {
1554 ClientError::ChainValidationError(format!(
1555 "get_account witness for account {account_id} does not open under block {} \
1556 account root: {err}",
1557 chain_tip_header.block_num()
1558 ))
1559 })
1560}
1561
1562pub(crate) fn block_num_from_forest(partial_mmr: &PartialMmr) -> Result<BlockNumber, ClientError> {
1564 Ok(u32::try_from(partial_mmr.forest().num_leaves().saturating_sub(1))
1565 .map_err(|_| ClientError::InvalidPartialMmrForest)?
1566 .into())
1567}
1568
1569fn group_txs_by_account_block(
1571 transaction_records: &[RpcTransactionRecord],
1572) -> BTreeMap<(AccountId, BlockNumber), Vec<&RpcTransactionRecord>> {
1573 let mut groups: BTreeMap<(AccountId, BlockNumber), Vec<&RpcTransactionRecord>> =
1574 BTreeMap::new();
1575 for record in transaction_records {
1576 let account_id = record.transaction_header.account_id();
1577 groups.entry((account_id, record.block_num)).or_default().push(record);
1578 }
1579 groups
1580}
1581
1582fn walk_execution_chain<'a>(
1588 txs: &'a [&'a RpcTransactionRecord],
1589) -> impl Iterator<Item = &'a RpcTransactionRecord> + 'a {
1590 let (self_loops, chained): (Vec<&RpcTransactionRecord>, Vec<&RpcTransactionRecord>) =
1591 txs.iter().copied().partition(|tx| {
1592 tx.transaction_header.initial_state_commitment()
1593 == tx.transaction_header.final_state_commitment()
1594 });
1595
1596 let final_states: BTreeSet<Word> = chained
1597 .iter()
1598 .map(|tx| tx.transaction_header.final_state_commitment())
1599 .collect();
1600
1601 let mut init_to_tx: BTreeMap<Word, &RpcTransactionRecord> = chained
1602 .iter()
1603 .map(|tx| (tx.transaction_header.initial_state_commitment(), *tx))
1604 .collect();
1605
1606 let start = chained
1607 .iter()
1608 .find(|tx| !final_states.contains(&tx.transaction_header.initial_state_commitment()))
1609 .copied();
1610
1611 assert!(start.is_some() || chained.is_empty(), "cannot walk cyclic execution chain");
1612
1613 let mut current =
1614 start.and_then(|tx| init_to_tx.remove(&tx.transaction_header.initial_state_commitment()));
1615 let mut self_loops_iter = self_loops.into_iter();
1616
1617 core::iter::from_fn(move || {
1618 if let Some(tx) = current {
1619 current = init_to_tx.remove(&tx.transaction_header.final_state_commitment());
1620 return Some(tx);
1621 }
1622 self_loops_iter.next()
1623 })
1624}
1625
1626fn derive_account_commitments(
1631 transaction_records: &[RpcTransactionRecord],
1632) -> Vec<(AccountId, Word)> {
1633 let mut latest_by_account: BTreeMap<AccountId, (BlockNumber, Word)> = BTreeMap::new();
1634
1635 for ((account_id, block_num), txs) in &group_txs_by_account_block(transaction_records) {
1636 let terminal_state = walk_execution_chain(txs)
1637 .last()
1638 .expect("account must have a final state")
1639 .transaction_header
1640 .final_state_commitment();
1641
1642 latest_by_account
1643 .entry(*account_id)
1644 .and_modify(|(existing_block, existing_state)| {
1645 if *block_num > *existing_block {
1646 *existing_block = *block_num;
1647 *existing_state = terminal_state;
1648 }
1649 })
1650 .or_insert((*block_num, terminal_state));
1651 }
1652
1653 latest_by_account
1654 .into_iter()
1655 .map(|(account_id, (_, state))| (account_id, state))
1656 .collect()
1657}
1658
1659fn compute_ordered_nullifiers(transaction_records: &[RpcTransactionRecord]) -> Vec<Nullifier> {
1666 let mut result = Vec::new();
1667
1668 for txs in group_txs_by_account_block(transaction_records).values() {
1669 for tx in walk_execution_chain(txs) {
1670 for commitment in tx.transaction_header.input_notes().iter() {
1671 result.push(commitment.nullifier());
1672 }
1673 }
1674 }
1675
1676 result
1677}
1678
1679#[cfg(all(test, feature = "testing"))]
1680mod tests {
1681 use alloc::collections::BTreeSet;
1682 use alloc::sync::Arc;
1683
1684 use async_trait::async_trait;
1685 use miden_protocol::account::Account;
1686 use miden_protocol::assembly::DefaultSourceManager;
1687 use miden_protocol::asset::{Asset, FungibleAsset};
1688 use miden_protocol::block::{BlockNumber, BlockSignatures};
1689 use miden_protocol::crypto::merkle::MerklePath;
1690 use miden_protocol::crypto::merkle::mmr::{Forest, InOrderIndex, PartialMmr};
1691 use miden_protocol::note::{
1692 Note,
1693 NoteAssets,
1694 NoteAttachment,
1695 NoteAttachments,
1696 NoteDetails,
1697 NoteHeader,
1698 NoteMetadata,
1699 NoteRecipient,
1700 NoteStorage,
1701 NoteTag,
1702 NoteType,
1703 PartialNoteMetadata,
1704 };
1705 use miden_protocol::testing::account_id::{
1706 ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET,
1707 ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
1708 ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE,
1709 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
1710 ACCOUNT_ID_SENDER,
1711 };
1712 use miden_protocol::transaction::{InputNotes, TransactionArgs, TransactionHeader};
1713 use miden_protocol::vm::AdviceMap;
1714 use miden_protocol::{EMPTY_WORD, Felt, Word, ZERO};
1715 use miden_standards::code_builder::CodeBuilder;
1716 use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint};
1717 use miden_testing::{MockChainBuilder, MockTransactionInput};
1718
1719 use super::*;
1720 use crate::store::{OutputNoteRecord, OutputNoteState};
1721 use crate::test_utils::mock::MockRpcApi;
1722
1723 struct MockScreener;
1725
1726 #[async_trait(?Send)]
1727 impl OnNoteReceived for MockScreener {
1728 async fn on_note_received(
1729 &self,
1730 _committed_note: CommittedNote,
1731 _public_note: Option<InputNoteRecord>,
1732 ) -> Result<NoteUpdateAction, ClientError> {
1733 Ok(NoteUpdateAction::Discard)
1734 }
1735 }
1736
1737 struct AlwaysRelevantObserver;
1739
1740 #[async_trait(?Send)]
1741 impl NoteObserver for AlwaysRelevantObserver {
1742 fn name(&self) -> &'static str {
1743 "always-relevant"
1744 }
1745
1746 async fn observe(&self, _note: &SyncedNote) -> Result<bool, ClientError> {
1747 Ok(true)
1748 }
1749 }
1750
1751 fn genesis_validator_config(mock_rpc: &MockRpcApi) -> ValidatorConfig {
1753 mock_rpc.mock_chain.read().block_header(0).validator_config().clone()
1754 }
1755
1756 fn block_signatures(mock_rpc: &MockRpcApi, block_num: BlockNumber) -> BlockSignatures {
1758 mock_rpc
1759 .mock_chain
1760 .read()
1761 .proven_blocks()
1762 .iter()
1763 .find(|block| block.header().block_num() == block_num)
1764 .expect("the mock chain contains the block")
1765 .signatures()
1766 .clone()
1767 }
1768
1769 fn empty() -> StateSyncInput {
1770 StateSyncInput {
1771 accounts: vec![],
1772 note_tags: BTreeSet::new(),
1773 input_notes: vec![],
1774 output_notes: vec![],
1775 uncommitted_transactions: vec![],
1776 }
1777 }
1778
1779 fn word(n: u64) -> miden_protocol::Word {
1780 [
1781 Felt::new(n).expect("test value should fit into the base field"),
1782 ZERO,
1783 ZERO,
1784 ZERO,
1785 ]
1786 .into()
1787 }
1788
1789 fn header_with_account_root(header: &BlockHeader, account_root: Word) -> BlockHeader {
1790 BlockHeader::new(
1791 header.prev_block_commitment(),
1792 header.block_num(),
1793 header.chain_commitment(),
1794 account_root,
1795 header.nullifier_root(),
1796 header.note_root(),
1797 header.tx_commitment(),
1798 header.validator_config().clone(),
1799 header.fee_parameters().clone(),
1800 header.protocol_config_commitment(),
1801 header.next_protocol_config().cloned(),
1802 header.timestamp(),
1803 )
1804 }
1805
1806 #[tokio::test]
1807 async fn sync_public_accounts_ignores_older_node_snapshot() {
1808 let mut builder = MockChainBuilder::new();
1809 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1810 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1811 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1812 let validator_config = genesis_validator_config(&rpc_api);
1813 let state_sync =
1814 StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None, validator_config);
1815
1816 let local_header =
1819 AccountHeader::new(account.id(), Felt::from(2u32), EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
1820 let current_public_accounts = vec![&local_header];
1821 let commitment_updates = vec![(account.id(), account.to_commitment())];
1822 let mut account_updates = AccountUpdates::default();
1823
1824 let superseded = state_sync
1825 .sync_public_accounts(
1826 &mut account_updates,
1827 &commitment_updates,
1828 ¤t_public_accounts,
1829 BlockNumber::GENESIS,
1830 &chain_tip_header,
1831 )
1832 .await
1833 .unwrap();
1834
1835 assert!(
1836 account_updates.updated_public_accounts().is_empty(),
1837 "public account sync should ignore node snapshots that are older than local"
1838 );
1839 assert!(
1840 superseded.is_empty(),
1841 "an older node snapshot must not supersede the local state"
1842 );
1843 }
1844
1845 #[tokio::test]
1846 async fn sync_public_accounts_marks_same_nonce_mismatch_as_superseded() {
1847 let mut builder = MockChainBuilder::new();
1848 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1849 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1850 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1851 let validator_config = genesis_validator_config(&rpc_api);
1852 let state_sync =
1853 StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None, validator_config);
1854
1855 let local_header =
1858 AccountHeader::new(account.id(), account.nonce(), EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
1859 let current_public_accounts = vec![&local_header];
1860 let commitment_updates = vec![(account.id(), account.to_commitment())];
1861 let mut account_updates = AccountUpdates::default();
1862
1863 let superseded = state_sync
1864 .sync_public_accounts(
1865 &mut account_updates,
1866 &commitment_updates,
1867 ¤t_public_accounts,
1868 BlockNumber::GENESIS,
1869 &chain_tip_header,
1870 )
1871 .await
1872 .unwrap();
1873
1874 assert!(
1875 account_updates.updated_public_accounts().is_empty(),
1876 "a same-nonce fork must not overwrite the account while its tx is still pending"
1877 );
1878 assert_eq!(
1879 superseded,
1880 vec![local_header.to_commitment()],
1881 "the superseded local state should be reported so its transaction is discarded"
1882 );
1883 }
1884
1885 #[test]
1891 fn validate_transaction_records_range_rejects_out_of_range_blocks() {
1892 let account_id: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap();
1893 let current = BlockNumber::from(5u32);
1894 let chain_tip = BlockNumber::from(10u32);
1895
1896 StateSync::validate_transaction_records_range(
1897 &[make_tx_record(account_id, 7)],
1898 current,
1899 chain_tip,
1900 )
1901 .unwrap();
1902
1903 let result = StateSync::validate_transaction_records_range(
1904 &[make_tx_record(account_id, 11)],
1905 current,
1906 chain_tip,
1907 );
1908 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1909
1910 let result = StateSync::validate_transaction_records_range(
1911 &[make_tx_record(account_id, 5)],
1912 current,
1913 chain_tip,
1914 );
1915 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1916 }
1917
1918 #[tokio::test]
1921 async fn verify_private_account_mismatch_ignores_forged_commitment() {
1922 let mut builder = MockChainBuilder::new();
1923 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1924 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1925 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1926 let on_chain_commitment = account.to_commitment();
1927 let validator_config = genesis_validator_config(&rpc_api);
1928 let state_sync =
1929 StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None, validator_config);
1930
1931 let result = state_sync
1932 .verify_private_account_mismatch(account.id(), on_chain_commitment, &chain_tip_header)
1933 .await
1934 .unwrap();
1935
1936 assert!(
1937 result.is_none(),
1938 "an unproven commitment must not lock an account whose on-chain state matches local"
1939 );
1940 }
1941
1942 #[tokio::test]
1945 async fn verify_private_account_mismatch_reports_proven_divergence() {
1946 let mut builder = MockChainBuilder::new();
1947 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1948 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1949 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1950 let on_chain_commitment = account.to_commitment();
1951 let validator_config = genesis_validator_config(&rpc_api);
1952 let state_sync =
1953 StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None, validator_config);
1954 let stale_local_commitment = word(0xdead_beef);
1955
1956 let result = state_sync
1957 .verify_private_account_mismatch(
1958 account.id(),
1959 stale_local_commitment,
1960 &chain_tip_header,
1961 )
1962 .await
1963 .unwrap();
1964
1965 assert_eq!(
1966 result,
1967 Some(on_chain_commitment),
1968 "a proven divergence should return the proven commitment to lock with"
1969 );
1970 }
1971
1972 #[tokio::test]
1975 async fn verify_private_account_mismatch_rejects_unverifiable_proof() {
1976 let mut builder = MockChainBuilder::new();
1977 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1978 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1979 let real_header = rpc_api.mock_chain.read().latest_block_header();
1980 let validator_config = genesis_validator_config(&rpc_api);
1981 let state_sync =
1982 StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None, validator_config);
1983
1984 let tampered_header = BlockHeader::new(
1987 real_header.prev_block_commitment(),
1988 real_header.block_num(),
1989 real_header.chain_commitment(),
1990 word(0xbad0_bad0),
1991 real_header.nullifier_root(),
1992 real_header.note_root(),
1993 real_header.tx_commitment(),
1994 real_header.validator_config().clone(),
1995 real_header.fee_parameters().clone(),
1996 real_header.protocol_config_commitment(),
1997 real_header.next_protocol_config().cloned(),
1998 real_header.timestamp(),
1999 );
2000
2001 let result = state_sync
2002 .verify_private_account_mismatch(
2003 account.id(),
2004 account.to_commitment(),
2005 &tampered_header,
2006 )
2007 .await;
2008 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2009 }
2010
2011 #[tokio::test]
2014 async fn sync_public_accounts_pins_account_fetch_to_sync_target() {
2015 let mut builder = MockChainBuilder::new();
2016 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2017 let mut chain = builder.build().unwrap();
2018
2019 let sync_target_header = chain.latest_block_header();
2021 let tx = Box::pin(
2022 chain
2023 .build_transaction(MockTransactionInput::AccountId(account.id()))
2024 .build()
2025 .unwrap()
2026 .execute(),
2027 )
2028 .await
2029 .unwrap();
2030 let local_header = tx.final_account().clone();
2031 assert_ne!(local_header.to_commitment(), account.to_commitment());
2032 chain.add_pending_executed_transaction(&tx).unwrap();
2033
2034 let rpc_api = MockRpcApi::new(chain);
2035 rpc_api.prove_block();
2037 assert_eq!(
2038 rpc_api
2039 .mock_chain
2040 .read()
2041 .committed_account(account.id())
2042 .unwrap()
2043 .to_commitment(),
2044 local_header.to_commitment()
2045 );
2046 let validator_config = genesis_validator_config(&rpc_api);
2047 let state_sync =
2048 StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None, validator_config);
2049
2050 let current_public_accounts = vec![&local_header];
2051 let commitment_updates = vec![(account.id(), account.to_commitment())];
2052 let mut account_updates = AccountUpdates::default();
2053
2054 let superseded = state_sync
2055 .sync_public_accounts(
2056 &mut account_updates,
2057 &commitment_updates,
2058 ¤t_public_accounts,
2059 BlockNumber::GENESIS,
2060 &sync_target_header,
2061 )
2062 .await
2063 .unwrap();
2064
2065 assert!(superseded.is_empty(), "the transaction must not be superseded");
2066 assert!(
2067 account_updates.updated_public_accounts().is_empty(),
2068 "the target state must not overwrite the local account"
2069 );
2070 }
2071
2072 async fn get_account_proof(
2074 rpc_api: &MockRpcApi,
2075 account_id: AccountId,
2076 ) -> (BlockNumber, AccountProof) {
2077 rpc_api
2078 .get_account(
2079 GetAccountRequest::new(account_id).with_fetch(
2080 AccountFetch::new()
2081 .with_storage(StorageMapFetch::All)
2082 .with_vault(VaultFetch::Always),
2083 ),
2084 )
2085 .await
2086 .unwrap()
2087 }
2088
2089 #[tokio::test]
2091 async fn validate_account_proof_rejects_mismatched_account() {
2092 let mut builder = MockChainBuilder::new();
2093 let account_a = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2094 let account_b = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2095 let rpc_api = MockRpcApi::new(builder.build().unwrap());
2096 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
2097
2098 let (proof_block_num, proof) = get_account_proof(&rpc_api, account_b.id()).await;
2100 let result = StateSync::validate_account_proof(
2101 proof,
2102 proof_block_num,
2103 account_a.id(),
2104 &chain_tip_header,
2105 );
2106
2107 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2108 }
2109
2110 #[tokio::test]
2112 async fn validate_account_proof_rejects_wrong_account_root() {
2113 let mut builder = MockChainBuilder::new();
2114 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2115 let rpc_api = MockRpcApi::new(builder.build().unwrap());
2116 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
2117 let wrong_header = header_with_account_root(&chain_tip_header, word(999));
2118
2119 let (proof_block_num, proof) = get_account_proof(&rpc_api, account.id()).await;
2121 let result =
2122 StateSync::validate_account_proof(proof, proof_block_num, account.id(), &wrong_header);
2123
2124 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2125 }
2126
2127 #[tokio::test]
2129 async fn validate_account_proof_rejects_missing_details() {
2130 let mut builder = MockChainBuilder::new();
2131 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2132 let rpc_api = MockRpcApi::new(builder.build().unwrap());
2133 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
2134
2135 let (proof_block_num, proof) = get_account_proof(&rpc_api, account.id()).await;
2137 let (witness, _) = proof.into_parts();
2138 let proof = AccountProof::new(witness, None).unwrap();
2139 let result = StateSync::validate_account_proof(
2140 proof,
2141 proof_block_num,
2142 account.id(),
2143 &chain_tip_header,
2144 );
2145
2146 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2147 }
2148
2149 #[tokio::test]
2151 async fn validate_account_proof_rejects_wrong_block() {
2152 let mut builder = MockChainBuilder::new();
2153 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2154 let rpc_api = MockRpcApi::new(builder.build().unwrap());
2155 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
2156
2157 let (proof_block_num, proof) = get_account_proof(&rpc_api, account.id()).await;
2159 let result = StateSync::validate_account_proof(
2160 proof,
2161 proof_block_num + 1,
2162 account.id(),
2163 &chain_tip_header,
2164 );
2165
2166 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2167 }
2168
2169 mod compute_nullifiers_tests {
2173 use alloc::vec;
2174
2175 use miden_protocol::block::BlockNumber;
2176 use miden_protocol::note::Nullifier;
2177 use miden_protocol::transaction::{InputNoteCommitment, InputNotes, TransactionHeader};
2178
2179 use super::word;
2180 use crate::rpc::domain::transaction::TransactionRecord as RpcTransactionRecord;
2181
2182 fn make_rpc_tx(
2183 init_state: u64,
2184 final_state: u64,
2185 nullifier_vals: &[u64],
2186 block_number: u32,
2187 ) -> RpcTransactionRecord {
2188 let account_id = miden_protocol::account::AccountId::try_from(
2189 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE,
2190 )
2191 .unwrap();
2192
2193 let input_notes = InputNotes::new_unchecked(
2194 nullifier_vals
2195 .iter()
2196 .map(|v| InputNoteCommitment::from(Nullifier::from_raw(word(*v))))
2197 .collect(),
2198 );
2199
2200 RpcTransactionRecord {
2201 block_num: BlockNumber::from(block_number),
2202 transaction_header: TransactionHeader::new(
2203 account_id,
2204 word(init_state),
2205 word(final_state),
2206 input_notes,
2207 vec![],
2208 )
2209 .unwrap(),
2210 output_notes: vec![],
2211 erased_output_notes: vec![],
2212 consumed_note_refs: vec![],
2213 }
2214 }
2215
2216 #[test]
2217 fn chains_rpc_transactions_by_state_commitment() {
2218 let tx_a = make_rpc_tx(1, 2, &[10], 5);
2222 let tx_b = make_rpc_tx(2, 3, &[20], 5);
2223 let tx_c = make_rpc_tx(3, 4, &[30], 5);
2224
2225 let result = super::super::compute_ordered_nullifiers(&[tx_c, tx_a, tx_b]);
2226
2227 assert_eq!(result[0], Nullifier::from_raw(word(10)));
2228 assert_eq!(result[1], Nullifier::from_raw(word(20)));
2229 assert_eq!(result[2], Nullifier::from_raw(word(30)));
2230 }
2231
2232 #[test]
2233 fn groups_independently_by_account_and_block() {
2234 let tx_a1 = make_rpc_tx(1, 2, &[10], 5);
2236 let tx_a2 = make_rpc_tx(2, 3, &[20], 5);
2237
2238 let tx_a3 = make_rpc_tx(3, 4, &[30], 6);
2240
2241 let account_b = miden_protocol::account::AccountId::try_from(
2243 miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
2244 )
2245 .unwrap();
2246
2247 let tx_b1 = RpcTransactionRecord {
2248 block_num: BlockNumber::from(5u32),
2249 transaction_header: TransactionHeader::new(
2250 account_b,
2251 word(100),
2252 word(200),
2253 InputNotes::new_unchecked(vec![InputNoteCommitment::from(
2254 Nullifier::from_raw(word(40)),
2255 )]),
2256 vec![],
2257 )
2258 .unwrap(),
2259 output_notes: vec![],
2260 erased_output_notes: vec![],
2261 consumed_note_refs: vec![],
2262 };
2263
2264 let result = super::super::compute_ordered_nullifiers(&[tx_a2, tx_b1, tx_a3, tx_a1]);
2265
2266 let pos = |val: u64| -> usize {
2269 result.iter().position(|n| *n == Nullifier::from_raw(word(val))).unwrap()
2270 };
2271
2272 assert!(pos(10) < pos(20)); assert!(result.contains(&Nullifier::from_raw(word(30)))); assert!(result.contains(&Nullifier::from_raw(word(40)))); }
2278
2279 #[test]
2280 fn multiple_nullifiers_per_transaction_are_consecutive() {
2281 let tx = make_rpc_tx(1, 2, &[10, 20, 30], 5);
2283
2284 let result = super::super::compute_ordered_nullifiers(&[tx]);
2285
2286 assert_eq!(result.len(), 3);
2287 assert!(result.contains(&Nullifier::from_raw(word(10))));
2288 assert!(result.contains(&Nullifier::from_raw(word(20))));
2289 assert!(result.contains(&Nullifier::from_raw(word(30))));
2290 }
2291
2292 #[test]
2293 fn empty_input_returns_empty_vec() {
2294 let result = super::super::compute_ordered_nullifiers(&[]);
2295 assert!(result.is_empty());
2296 }
2297 }
2298
2299 #[test]
2310 fn derive_account_commitments_walks_chains_per_account() {
2311 let make_tx = |account: AccountId, init_state: u64, final_state: u64, block_num: u32| {
2312 RpcTransactionRecord {
2313 block_num: BlockNumber::from(block_num),
2314 transaction_header: TransactionHeader::new(
2315 account,
2316 word(init_state),
2317 word(final_state),
2318 InputNotes::new_unchecked(vec![]),
2319 vec![],
2320 )
2321 .unwrap(),
2322 output_notes: vec![],
2323 erased_output_notes: vec![],
2324 consumed_note_refs: vec![],
2325 }
2326 };
2327
2328 let account_a: AccountId =
2329 ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE.try_into().unwrap();
2330 let account_b: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap();
2331
2332 let tx_a_b5_1 = make_tx(account_a, 1, 2, 5);
2333 let tx_a_b5_2 = make_tx(account_a, 2, 3, 5);
2334 let tx_a_b6_1 = make_tx(account_a, 3, 4, 6);
2335 let tx_a_b6_2 = make_tx(account_a, 4, 5, 6);
2336 let tx_b_b6 = make_tx(account_b, 10, 20, 6);
2337
2338 let result = super::derive_account_commitments(&[
2340 tx_a_b6_1, tx_b_b6, tx_a_b5_2, tx_a_b6_2, tx_a_b5_1,
2341 ]);
2342
2343 assert_eq!(result.len(), 2, "one entry per account");
2344 assert!(
2345 result.contains(&(account_a, word(5))),
2346 "account A: must walk block 6's chain, not return block 5 or an intermediate",
2347 );
2348 assert!(
2349 result.contains(&(account_b, word(20))),
2350 "account B: must be resolved independently of account A",
2351 );
2352 }
2353
2354 struct CommitAllScreener;
2360
2361 #[async_trait(?Send)]
2362 impl OnNoteReceived for CommitAllScreener {
2363 async fn on_note_received(
2364 &self,
2365 committed_note: CommittedNote,
2366 _public_note: Option<InputNoteRecord>,
2367 ) -> Result<NoteUpdateAction, ClientError> {
2368 Ok(NoteUpdateAction::Commit(committed_note))
2369 }
2370 }
2371
2372 async fn build_chain_with_chained_consume_txs() -> (miden_testing::MockChain, Account, [Note; 3])
2376 {
2377 let sender_id: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
2378 let faucet_id: AccountId = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into().unwrap();
2379
2380 let mut builder = MockChainBuilder::new();
2381 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2382 let account_id = account.id();
2383
2384 let asset = Asset::from(FungibleAsset::new(faucet_id, 100u64).unwrap());
2385 let note1 = builder
2386 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
2387 .unwrap();
2388 let note2 = builder
2389 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
2390 .unwrap();
2391 let note3 = builder
2392 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
2393 .unwrap();
2394
2395 let mut chain = builder.build().unwrap();
2396 chain.prove_next_block().unwrap(); let mut current_account = account.clone();
2400 for note in [¬e1, ¬e2, ¬e3] {
2401 let tx = Box::pin(
2402 chain
2403 .build_transaction(MockTransactionInput::Account(current_account.clone()))
2404 .unauthenticated_input_note(note.clone())
2405 .build()
2406 .unwrap()
2407 .execute(),
2408 )
2409 .await
2410 .unwrap();
2411 current_account.apply_patch(tx.account_patch()).unwrap();
2412 chain.add_pending_executed_transaction(&tx).unwrap();
2413 }
2414
2415 chain.prove_next_block().unwrap(); (chain, account, [note1, note2, note3])
2417 }
2418
2419 #[tokio::test]
2422 async fn sync_state_sets_consumed_tx_order_for_chained_transactions() {
2423 use miden_protocol::note::NoteMetadata;
2424
2425 let (chain, account, [note1, note2, note3]) = build_chain_with_chained_consume_txs().await;
2426
2427 let mock_rpc = MockRpcApi::new(chain);
2428 let state_sync = StateSync::new(
2429 Arc::new(mock_rpc.clone()),
2430 Arc::new(CommitAllScreener),
2431 None,
2432 genesis_validator_config(&mock_rpc),
2433 );
2434
2435 let genesis_peaks =
2436 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2437 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2438
2439 let input_notes: Vec<InputNoteRecord> = [¬e1, ¬e2, ¬e3]
2440 .into_iter()
2441 .map(|n| InputNoteRecord::from(n.clone()))
2442 .collect();
2443
2444 let note_tags: BTreeSet<NoteTag> =
2445 input_notes.iter().filter_map(|n| n.metadata().map(NoteMetadata::tag)).collect();
2446
2447 let account_id = account.id();
2448 let sync_input = StateSyncInput {
2449 accounts: vec![AccountHeader::from(&account)],
2450 note_tags,
2451 input_notes,
2452 output_notes: vec![],
2453 uncommitted_transactions: vec![],
2454 };
2455
2456 let update = state_sync.sync_state(&mut partial_mmr, sync_input).await.unwrap();
2457
2458 let updated_notes: Vec<_> = update.note_updates().updated_input_notes().collect();
2459
2460 let find_order = |details_commitment| -> Option<u32> {
2461 updated_notes
2462 .iter()
2463 .find(|n| n.inner().details_commitment() == details_commitment)
2464 .and_then(|n| n.consumed_tx_order())
2465 };
2466
2467 assert_eq!(find_order(note1.details_commitment()), Some(0), "note1 should have tx_order 0");
2468 assert_eq!(find_order(note2.details_commitment()), Some(1), "note2 should have tx_order 1");
2469 assert_eq!(find_order(note3.details_commitment()), Some(2), "note3 should have tx_order 2");
2470
2471 for note in &updated_notes {
2474 let record = note.inner();
2475 assert!(record.is_consumed(), "note should be in a consumed state");
2476 assert_eq!(
2477 record.consumer_account(),
2478 Some(account_id),
2479 "externally-consumed notes by a tracked account should have consumer_account set",
2480 );
2481 }
2482 }
2483
2484 #[tokio::test]
2485 async fn sync_state_across_multiple_iterations_with_same_mmr() {
2486 let mock_rpc = MockRpcApi::default();
2488 mock_rpc.advance_blocks(3);
2489 let chain_tip_1 = mock_rpc.get_chain_tip_block_num();
2490
2491 let state_sync = StateSync::new(
2492 Arc::new(mock_rpc.clone()),
2493 Arc::new(MockScreener),
2494 None,
2495 genesis_validator_config(&mock_rpc),
2496 );
2497
2498 let genesis_peaks =
2500 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2501 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2502 assert_eq!(partial_mmr.forest().num_leaves(), 1);
2503
2504 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
2506
2507 assert_eq!(update.block_num(), chain_tip_1);
2508 let forest_1 = partial_mmr.forest();
2509 assert_eq!(forest_1.num_leaves(), chain_tip_1.as_u32() as usize + 1);
2511
2512 mock_rpc.advance_blocks(2);
2514 let chain_tip_2 = mock_rpc.get_chain_tip_block_num();
2515
2516 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
2517
2518 assert_eq!(update.block_num(), chain_tip_2);
2519 let forest_2 = partial_mmr.forest();
2520 assert!(forest_2 > forest_1);
2521 assert_eq!(forest_2.num_leaves(), chain_tip_2.as_u32() as usize + 1);
2522
2523 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
2525
2526 assert_eq!(update.block_num(), chain_tip_2);
2527 assert_eq!(partial_mmr.forest(), forest_2);
2528 }
2529
2530 async fn build_chain_with_mint_notes(
2533 num_blocks: u64,
2534 ) -> (miden_testing::MockChain, BTreeSet<NoteTag>) {
2535 let mut builder = MockChainBuilder::new();
2536 let faucet = builder
2537 .add_existing_basic_faucet(
2538 miden_testing::Auth::BasicAuth {
2539 auth_scheme: miden_protocol::account::auth::AuthScheme::Falcon512Poseidon2,
2540 },
2541 "TST",
2542 10_000,
2543 None,
2544 )
2545 .unwrap();
2546 let _target = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2547 let mut chain = builder.build().unwrap();
2548
2549 let note_script = CodeBuilder::new()
2554 .compile_note_script("@note_script\npub proc main\n nop\nend")
2555 .unwrap();
2556 let note_recipient = NoteRecipient::new(
2557 Word::from([1u32, 2, 3, 4]),
2558 note_script,
2559 NoteStorage::new(vec![]).unwrap(),
2560 );
2561 let recipient = note_recipient.digest();
2562 let note_details = NoteDetails::new(NoteAssets::new(vec![]).unwrap(), note_recipient);
2565 let mut recipient_args = TransactionArgs::new(AdviceMap::default());
2566 recipient_args.add_output_note_recipient(¬e_details);
2567 let recipient_advice = recipient_args.advice_inputs().clone();
2568
2569 let tag = NoteTag::default();
2570 let mut faucet_account = faucet.clone();
2571 let mut note_tags = BTreeSet::new();
2572
2573 for i in 0..num_blocks {
2574 let amount = 100 + i;
2575 let source_manager = Arc::new(DefaultSourceManager::default());
2576 let mint_asset = FungibleAsset::new(faucet_account.id(), amount).unwrap();
2580 let asset_id_word = mint_asset.id().to_word();
2581 let asset_value_word = mint_asset.to_value_word();
2582 let tx_script_code = format!(
2583 "
2584 @transaction_script
2585 pub proc main
2586 push.{recipient}
2587 push.{note_type}
2588 push.{tag}
2589 push.{asset_value}
2590 push.{asset_id}
2591 call.::miden::standards::faucets::fungible::mint_and_send
2592 dropw dropw dropw dropw
2593 end
2594 ",
2595 recipient = recipient,
2596 note_type = NoteType::Private as u8,
2597 tag = u32::from(tag),
2598 asset_value = asset_value_word,
2599 asset_id = asset_id_word,
2600 );
2601 let tx_script = CodeBuilder::with_source_manager(source_manager.clone())
2602 .compile_tx_script(tx_script_code)
2603 .unwrap();
2604 let tx = Box::pin(
2605 chain
2606 .build_transaction(miden_testing::MockTransactionInput::Account(
2607 faucet_account.clone(),
2608 ))
2609 .extend_advice_inputs(recipient_advice.clone())
2610 .tx_script(tx_script)
2611 .with_source_manager(source_manager)
2612 .build()
2613 .unwrap()
2614 .execute(),
2615 )
2616 .await
2617 .unwrap();
2618
2619 for output_note in tx.output_notes().iter() {
2620 note_tags.insert(output_note.metadata().tag());
2621 }
2622
2623 faucet_account.apply_patch(tx.account_patch()).unwrap();
2624 chain.add_pending_executed_transaction(&tx).unwrap();
2625 chain.prove_next_block().unwrap();
2626 }
2627
2628 (chain, note_tags)
2629 }
2630
2631 #[tokio::test]
2634 async fn observer_relevance_persists_discarded_note_block() {
2635 let (chain, note_tags) = build_chain_with_mint_notes(2).await;
2636 let mock_rpc = MockRpcApi::new(chain);
2637 let chain_tip = mock_rpc.get_chain_tip_block_num();
2638
2639 let genesis_peaks =
2640 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2641 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2642
2643 let validator_config = genesis_validator_config(&mock_rpc);
2644 let mut input = empty();
2645 let state_sync =
2646 StateSync::new(Arc::new(mock_rpc), Arc::new(MockScreener), None, validator_config)
2647 .with_note_observer(Arc::new(AlwaysRelevantObserver));
2648 input.note_tags = note_tags;
2649
2650 let update = state_sync.sync_state(&mut partial_mmr, input).await.unwrap();
2651 let observed_non_tip_block = BlockNumber::from(1u32);
2652
2653 assert!(
2654 update.partial_blockchain_updates().block_headers_to_store(chain_tip).any(
2655 |(header, is_relevant)| {
2656 header.block_num() == observed_non_tip_block && *is_relevant
2657 }
2658 ),
2659 "an observer-relevant block must be staged as relevant"
2660 );
2661 assert!(
2662 partial_mmr.is_tracked(observed_non_tip_block.as_usize()),
2663 "an observer-relevant block must remain tracked in the partial MMR"
2664 );
2665 }
2666
2667 #[tokio::test]
2677 async fn sync_state_tracks_note_blocks_in_mmr() {
2678 let (chain, note_tags) = build_chain_with_mint_notes(3).await;
2679 let mock_rpc = MockRpcApi::new(chain);
2680 let chain_tip = mock_rpc.get_chain_tip_block_num();
2681
2682 let note_blocks = mock_rpc
2684 .sync_notes(BlockNumber::from(0u32), chain_tip, ¬e_tags)
2685 .await
2686 .unwrap();
2687 assert!(
2688 note_blocks.len() >= 2,
2689 "expected notes in multiple blocks, got {}",
2690 note_blocks.len()
2691 );
2692
2693 let note_block_nums: BTreeSet<BlockNumber> =
2695 note_blocks.iter().map(|b| b.block_header.block_num()).collect();
2696
2697 let state_sync = StateSync::new(
2700 Arc::new(mock_rpc.clone()),
2701 Arc::new(MockScreener),
2702 None,
2703 genesis_validator_config(&mock_rpc),
2704 );
2705
2706 let genesis_peaks =
2707 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2708 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2709
2710 let sync_data = state_sync
2711 .fetch_sync_data(BlockNumber::GENESIS, &[], &Arc::new(note_tags.clone()))
2712 .await
2713 .unwrap()
2714 .expect("should have progressed past genesis");
2715
2716 assert_eq!(sync_data.chain_tip_header.block_num(), chain_tip);
2718 assert!(!sync_data.note_blocks.is_empty(), "should have note blocks");
2719
2720 let _auth_nodes: Vec<(InOrderIndex, Word)> =
2722 partial_mmr.apply(sync_data.mmr_delta).map_err(StoreError::MmrError).unwrap();
2723 partial_mmr
2724 .add(sync_data.chain_tip_header.commitment(), false)
2725 .expect("chain tip should append to the partial MMR");
2726
2727 assert_eq!(partial_mmr.forest().num_leaves(), chain_tip.as_u32() as usize + 1);
2728
2729 for block in &sync_data.note_blocks {
2731 let bn = block.block_header.block_num();
2732 partial_mmr
2733 .track(bn.as_usize(), block.block_header.commitment(), &block.mmr_path)
2734 .map_err(StoreError::MmrError)
2735 .unwrap();
2736
2737 assert!(
2738 partial_mmr.is_tracked(bn.as_usize()),
2739 "block {bn} should be tracked after calling track()"
2740 );
2741 }
2742
2743 for &bn in ¬e_block_nums {
2745 assert!(
2746 partial_mmr.is_tracked(bn.as_usize()),
2747 "block {bn} with notes should be tracked in partial MMR"
2748 );
2749 }
2750 }
2751
2752 #[tokio::test]
2753 async fn sync_notes_with_content_fetches_inclusive_upper_bound_page() {
2754 let (chain, note_tags) = build_chain_with_mint_notes(10).await;
2755 let mock_rpc = MockRpcApi::new(chain);
2756
2757 let blocks = mock_rpc
2758 .sync_notes_with_content(
2759 4_u32.into(),
2760 10_u32.into(),
2761 ¬e_tags,
2762 NoteContentFetch::PublicDetailsAndAttachments,
2763 )
2764 .await
2765 .expect("sync notes should succeed");
2766
2767 assert_eq!(blocks.last().unwrap().block_header.block_num(), BlockNumber::from(10u32));
2768 assert!(
2769 blocks
2770 .iter()
2771 .any(|block| block.block_header.block_num() == BlockNumber::from(9u32))
2772 );
2773 }
2774
2775 #[tokio::test]
2781 async fn erased_notes_are_marked_as_consumed() {
2782 let sender_id: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
2784 let partial_metadata = PartialNoteMetadata::new(sender_id, NoteType::Public);
2785 let metadata = NoteMetadata::new(partial_metadata, &NoteAttachments::empty());
2786 let script = CodeBuilder::new()
2787 .compile_note_script("@note_script\npub proc main\n nop\nend")
2788 .unwrap();
2789 let recipient = NoteRecipient::new(
2790 Word::from([1u32, 2, 3, 4]),
2791 script,
2792 NoteStorage::new(vec![]).unwrap(),
2793 );
2794 let output_note = OutputNoteRecord::new(
2795 recipient.digest(),
2796 NoteAssets::new(vec![]).unwrap(),
2797 metadata,
2798 OutputNoteState::ExpectedFull { recipient },
2799 BlockNumber::from(1u32),
2800 NoteAttachments::default(),
2801 );
2802 let note_id = output_note.id();
2803 let note_header = NoteHeader::new(output_note.details_commitment(), metadata);
2804
2805 let mut note_updates = NoteUpdateTracker::new(vec![], vec![output_note]);
2807
2808 let block_num = BlockNumber::from(3u32);
2810 note_updates
2811 .mark_erased_note_as_consumed(¬e_header, block_num)
2812 .expect("marking erased note should succeed");
2813
2814 let updated = note_updates
2815 .updated_output_notes()
2816 .find(|n| n.id() == note_id)
2817 .expect("output note should be in the update");
2818
2819 assert!(
2820 updated.inner().is_consumed(),
2821 "output note should be consumed after erasure detection, but state is: {}",
2822 updated.inner().state()
2823 );
2824 }
2825
2826 #[allow(clippy::too_many_lines)]
2842 #[ignore = "consumer derivation removed; see comment above"]
2843 #[tokio::test]
2844 async fn erased_notes_are_marked_as_consumed_by_network_account() {
2845 let mut builder = MockChainBuilder::new();
2848 let p2id_sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
2849 let faucet_id: AccountId = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into().unwrap();
2850 let sender_account =
2851 builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2852 let sender_id = sender_account.id();
2853
2854 let asset = Asset::from(FungibleAsset::new(faucet_id, 100u64).unwrap());
2855 let note = builder
2856 .add_p2id_note(p2id_sender, sender_id, &[asset], NoteType::Public)
2857 .unwrap();
2858
2859 let mut chain = builder.build().unwrap();
2860 chain.prove_next_block().unwrap();
2861
2862 let tx = Box::pin(
2863 chain
2864 .build_transaction(MockTransactionInput::Account(sender_account.clone()))
2865 .unauthenticated_input_note(note.clone())
2866 .build()
2867 .unwrap()
2868 .execute(),
2869 )
2870 .await
2871 .unwrap();
2872 chain.add_pending_executed_transaction(&tx).unwrap();
2873 chain.prove_next_block().unwrap();
2874
2875 let network_account_id: AccountId =
2877 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into().unwrap();
2878 let target =
2879 NetworkAccountTarget::new(network_account_id, NoteExecutionHint::Always).unwrap();
2880 let attachment: NoteAttachment = target.into();
2881 let attachments = NoteAttachments::new(vec![attachment]).unwrap();
2882 let partial_metadata = PartialNoteMetadata::new(sender_id, NoteType::Public);
2883 let metadata = NoteMetadata::new(partial_metadata, &attachments);
2884 let script = CodeBuilder::new()
2885 .compile_note_script("@note_script\npub proc main\n nop\nend")
2886 .unwrap();
2887 let recipient = NoteRecipient::new(
2888 Word::from([7u32, 8, 9, 10]),
2889 script,
2890 NoteStorage::new(vec![]).unwrap(),
2891 );
2892 let recipient_digest = recipient.digest();
2893 let assets = NoteAssets::new(vec![]).unwrap();
2894
2895 let output_note = OutputNoteRecord::new(
2898 recipient_digest,
2899 assets.clone(),
2900 metadata,
2901 OutputNoteState::ExpectedFull { recipient },
2902 BlockNumber::from(1u32),
2903 NoteAttachments::default(),
2904 );
2905 let erased_note_id = output_note.id();
2906 let erased_note_header = NoteHeader::new(output_note.details_commitment(), metadata);
2907
2908 let mock_rpc = MockRpcApi::new(chain);
2909 mock_rpc.mark_note_as_erased(erased_note_header);
2910
2911 let network_header =
2914 AccountHeader::new(network_account_id, ZERO, EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
2915
2916 let state_sync = StateSync::new(
2917 Arc::new(mock_rpc.clone()),
2918 Arc::new(MockScreener),
2919 None,
2920 genesis_validator_config(&mock_rpc),
2921 );
2922
2923 let genesis_peaks =
2924 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2925 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2926
2927 let sync_input = StateSyncInput {
2928 accounts: vec![AccountHeader::from(&sender_account), network_header],
2929 note_tags: BTreeSet::new(),
2930 input_notes: vec![],
2931 output_notes: vec![output_note],
2932 uncommitted_transactions: vec![],
2933 };
2934
2935 let update = state_sync.sync_state(&mut partial_mmr, sync_input).await.unwrap();
2936
2937 let updated_output = update
2939 .note_updates()
2940 .updated_output_notes()
2941 .find(|n| n.id() == erased_note_id)
2942 .expect("output note should be in the update");
2943 assert!(
2944 updated_output.inner().is_consumed(),
2945 "output note should be consumed, got: {}",
2946 updated_output.inner().state()
2947 );
2948
2949 let input_note_update = update
2951 .note_updates()
2952 .updated_input_notes()
2953 .find(|n| n.id() == Some(erased_note_id))
2954 .expect("input note should be created from the erased output note");
2955
2956 let inner = input_note_update.inner();
2957 assert!(
2958 inner.is_consumed(),
2959 "input note should be in a consumed state, got: {}",
2960 inner.state()
2961 );
2962 assert_eq!(
2963 inner.consumer_account(),
2964 Some(network_account_id),
2965 "consumer should be the tracked network account"
2966 );
2967 }
2968
2969 #[tokio::test]
2972 async fn validate_chain_mmr_response_rejects_tampered_responses() {
2973 let mock_rpc = MockRpcApi::default();
2974 mock_rpc.advance_blocks(3);
2975 let chain_tip = mock_rpc.get_chain_tip_block_num();
2976 let current = BlockNumber::GENESIS;
2977 let validator_config = genesis_validator_config(&mock_rpc);
2978
2979 let header_of =
2980 |block_num: u32| mock_rpc.mock_chain.read().block_header(block_num as usize);
2981 let chain_mmr_response = || async {
2982 mock_rpc.sync_chain_mmr(current, SyncTarget::CommittedChainTip).await.unwrap()
2983 };
2984
2985 let response = chain_mmr_response().await;
2987 StateSync::validate_chain_mmr_response(&response, current, &validator_config).unwrap();
2988
2989 let mut response = chain_mmr_response().await;
2991 response.block_header = header_of(chain_tip.as_u32() - 1);
2992 let result = StateSync::validate_chain_mmr_response(&response, current, &validator_config);
2993 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2994
2995 let mut response = chain_mmr_response().await;
2997 response.block_from = current + 1;
2998 let result = StateSync::validate_chain_mmr_response(&response, current, &validator_config);
2999 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
3000
3001 let mut response = chain_mmr_response().await;
3003 response.block_from = chain_tip;
3004 response.block_to = BlockNumber::GENESIS;
3005 response.block_header = header_of(0);
3006 let result =
3007 StateSync::validate_chain_mmr_response(&response, chain_tip, &validator_config);
3008 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
3009 }
3010
3011 #[test]
3014 fn validate_note_blocks_range_rejects_out_of_range_blocks() {
3015 let mock_rpc = MockRpcApi::default();
3016 mock_rpc.advance_blocks(3);
3017 let chain_tip = mock_rpc.get_chain_tip_block_num();
3018 let current = BlockNumber::GENESIS;
3019
3020 StateSync::validate_note_blocks_range(&[], current, chain_tip).unwrap();
3022
3023 let genesis_note_block = ResolvedSyncNotesBlock {
3025 block_header: mock_rpc.mock_chain.read().block_header(0),
3026 mmr_path: MerklePath::new(Vec::new()),
3027 notes: BTreeMap::new(),
3028 };
3029 let result =
3030 StateSync::validate_note_blocks_range(&[genesis_note_block], current, chain_tip);
3031 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
3032 }
3033
3034 #[tokio::test]
3037 async fn validate_chain_mmr_response_rejects_chain_tip_without_valid_signatures() {
3038 let mock_rpc = MockRpcApi::default();
3039 mock_rpc.advance_blocks(3);
3040 let chain_tip = mock_rpc.get_chain_tip_block_num();
3041 let current = BlockNumber::GENESIS;
3042
3043 let validator_config = genesis_validator_config(&mock_rpc);
3044 assert!(!validator_config.is_empty(), "the mock chain must commit a validator set");
3045
3046 let mut chain_mmr_info =
3048 mock_rpc.sync_chain_mmr(current, SyncTarget::CommittedChainTip).await.unwrap();
3049 StateSync::validate_chain_mmr_response(&chain_mmr_info, current, &validator_config)
3050 .unwrap();
3051
3052 let parent = BlockNumber::from(chain_tip.as_u32() - 1);
3053 chain_mmr_info.block_signatures = block_signatures(&mock_rpc, parent);
3055 let result =
3057 StateSync::validate_chain_mmr_response(&chain_mmr_info, current, &validator_config);
3058 assert!(
3059 matches!(result, Err(ClientError::ChainValidationError(_))),
3060 "signatures of another block must be rejected, got {result:?}"
3061 );
3062 }
3063
3064 #[test]
3067 fn advance_mmr_rejects_delta_inconsistent_with_chain_commitment() {
3068 let mock_rpc = MockRpcApi::default();
3069 mock_rpc.advance_blocks(3);
3070 let chain_tip = mock_rpc.get_chain_tip_block_num();
3071
3072 let chain_tip_header = mock_rpc.mock_chain.read().block_header(chain_tip.as_usize());
3073 let genesis_partial_mmr = || {
3074 let peaks = mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
3075 PartialMmr::from_peaks(peaks)
3076 };
3077
3078 let full_delta = mock_rpc
3080 .get_mmr()
3081 .get_delta(Forest::new(1).unwrap(), Forest::new(chain_tip.as_usize()).unwrap())
3082 .unwrap();
3083 StateSync::advance_mmr(
3084 full_delta,
3085 &chain_tip_header,
3086 &mut genesis_partial_mmr(),
3087 &mut PartialBlockchainUpdates::default(),
3088 )
3089 .unwrap();
3090
3091 let truncated_delta = mock_rpc
3093 .get_mmr()
3094 .get_delta(Forest::new(1).unwrap(), Forest::new(chain_tip.as_usize() - 1).unwrap())
3095 .unwrap();
3096 let result = StateSync::advance_mmr(
3097 truncated_delta,
3098 &chain_tip_header,
3099 &mut genesis_partial_mmr(),
3100 &mut PartialBlockchainUpdates::default(),
3101 );
3102 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
3103 }
3104
3105 fn make_tx_record(account_id: AccountId, block_num: u32) -> RpcTransactionRecord {
3107 RpcTransactionRecord {
3108 block_num: BlockNumber::from(block_num),
3109 transaction_header: TransactionHeader::new(
3110 account_id,
3111 word(1),
3112 word(2),
3113 InputNotes::new_unchecked(vec![]),
3114 vec![],
3115 )
3116 .unwrap(),
3117 output_notes: vec![],
3118 erased_output_notes: vec![],
3119 consumed_note_refs: vec![],
3120 }
3121 }
3122}