1use alloc::boxed::Box;
2use alloc::collections::{BTreeMap, BTreeSet};
3use alloc::sync::Arc;
4use alloc::vec::Vec;
5use core::cmp::Ordering;
6
7use async_trait::async_trait;
8use miden_protocol::Word;
9use miden_protocol::account::{Account, AccountHeader, AccountId, StorageSlotType};
10use miden_protocol::block::account_tree::AccountIdKey;
11use miden_protocol::block::{BlockHeader, BlockNumber};
12use miden_protocol::crypto::merkle::MerklePath;
13use miden_protocol::crypto::merkle::mmr::{InOrderIndex, MmrDelta, PartialMmr};
14use miden_protocol::note::{NoteAttachments, NoteId, NoteTag, Nullifier};
15use tracing::info;
16
17use super::state_sync_update::{TransactionUpdateTracker, build_account_patch};
18use super::{
19 AccountUpdates,
20 NoteObserver,
21 PartialBlockchainUpdates,
22 PublicAccountUpdate,
23 StateSyncUpdate,
24};
25use crate::ClientError;
26use crate::note::{NoteConsumption, NoteUpdateTracker};
27use crate::rpc::domain::account::{
28 AccountDetails,
29 AccountProof,
30 GetAccountRequest,
31 StorageMapFetch,
32 VaultFetch,
33};
34use crate::rpc::domain::note::{
35 CommittedNote,
36 FetchedNote,
37 ResolvedNoteContent,
38 ResolvedSyncNotesBlock,
39 SyncedNote,
40};
41use crate::rpc::domain::sync::{ChainMmrInfo, SyncTarget};
42use crate::rpc::domain::transaction::TransactionRecord as RpcTransactionRecord;
43use crate::rpc::{AccountStateAt, NodeRpcClient, NoteContentFetch, RpcError};
44use crate::store::input_note_states::UnverifiedNoteState;
45use crate::store::{InputNoteRecord, OutputNoteRecord, StoreError};
46use crate::transaction::TransactionRecord;
47
48enum PublicAccountSync {
53 Apply(Box<PublicAccountUpdate>),
55 Superseded,
57 Ignore,
59}
60
61struct FetchedSyncData {
67 mmr_delta: MmrDelta,
69 chain_tip_header: BlockHeader,
71 note_blocks: Vec<ResolvedSyncNotesBlock>,
74 transactions: Vec<RpcTransactionRecord>,
76}
77
78struct RecoverableConsumedNote {
83 nullifier: Nullifier,
84 consumer: AccountId,
85 block_num: BlockNumber,
86}
87
88struct RelevantNoteBlock {
93 block_header: BlockHeader,
94 mmr_path: MerklePath,
95 observer_requires_block: bool,
96}
97
98#[derive(Default)]
100struct NoteBlockRelevance {
101 has_client_note: bool,
102 observer_requires_block: bool,
103}
104
105impl NoteBlockRelevance {
106 fn is_relevant(&self) -> bool {
107 self.has_client_note || self.observer_requires_block
108 }
109}
110
111pub struct StateSyncInput {
127 pub accounts: Vec<AccountHeader>,
129 pub note_tags: BTreeSet<NoteTag>,
131 pub input_notes: Vec<InputNoteRecord>,
133 pub output_notes: Vec<OutputNoteRecord>,
139 pub uncommitted_transactions: Vec<TransactionRecord>,
141}
142
143#[allow(clippy::large_enum_variant)]
148pub enum NoteUpdateAction {
149 Commit(CommittedNote),
152 Insert(InputNoteRecord),
154 Discard,
156}
157
158#[async_trait(?Send)]
159pub trait OnNoteReceived {
160 async fn on_note_received(
171 &self,
172 committed_note: CommittedNote,
173 public_note: Option<InputNoteRecord>,
174 ) -> Result<NoteUpdateAction, ClientError>;
175}
176#[derive(Clone)]
183pub struct StateSync {
184 rpc_api: Arc<dyn NodeRpcClient>,
186 note_screener: Arc<dyn OnNoteReceived>,
189 note_observers: Vec<Arc<dyn NoteObserver>>,
192 tx_discard_delta: Option<u32>,
195 sync_nullifiers: bool,
198}
199
200impl StateSync {
201 pub fn new(
212 rpc_api: Arc<dyn NodeRpcClient>,
213 note_screener: Arc<dyn OnNoteReceived>,
214 tx_discard_delta: Option<u32>,
215 ) -> Self {
216 Self {
217 rpc_api,
218 note_screener,
219 note_observers: Vec::new(),
220 tx_discard_delta,
221 sync_nullifiers: true,
222 }
223 }
224
225 #[must_use]
229 pub fn with_note_observer(mut self, observer: Arc<dyn NoteObserver>) -> Self {
230 self.note_observers.push(observer);
231 self
232 }
233
234 pub fn disable_nullifier_sync(&mut self) {
240 self.sync_nullifiers = false;
241 }
242
243 pub fn enable_nullifier_sync(&mut self) {
245 self.sync_nullifiers = true;
246 }
247
248 pub(crate) async fn run_apply_hooks(
255 &self,
256 state_sync_update: &StateSyncUpdate,
257 ) -> Result<(), ClientError> {
258 for observer in &self.note_observers {
259 crate::errors::log_observer_failure(
260 observer.name(),
261 "NoteObserver::apply",
262 observer.apply(state_sync_update).await,
263 );
264 }
265 Ok(())
266 }
267
268 pub async fn sync_state(
286 &self,
287 current_partial_mmr: &mut PartialMmr,
288 input: StateSyncInput,
289 ) -> Result<StateSyncUpdate, ClientError> {
290 let StateSyncInput {
291 accounts,
292 note_tags,
293 input_notes,
294 output_notes,
295 uncommitted_transactions,
296 } = input;
297 let block_num = u32::try_from(current_partial_mmr.forest().num_leaves().saturating_sub(1))
298 .map_err(|_| ClientError::InvalidPartialMmrForest)?
299 .into();
300
301 let note_tags = Arc::new(note_tags);
302 let account_ids: Vec<AccountId> = accounts.iter().map(AccountHeader::id).collect();
303
304 let mut note_updates = NoteUpdateTracker::new(input_notes, output_notes);
305 let mut transaction_updates = TransactionUpdateTracker::new(uncommitted_transactions);
306 let mut partial_blockchain_updates = PartialBlockchainUpdates::default();
307 let mut account_updates = AccountUpdates::default();
308
309 let Some(sync_data) = self.fetch_sync_data(block_num, &account_ids, ¬e_tags).await?
310 else {
311 return Ok(StateSyncUpdate::from_parts(
313 block_num,
314 partial_blockchain_updates,
315 note_updates,
316 transaction_updates,
317 account_updates,
318 ));
319 };
320
321 let FetchedSyncData {
322 mmr_delta,
323 chain_tip_header,
324 note_blocks,
325 transactions,
326 } = sync_data;
327 let chain_tip = chain_tip_header.block_num();
328
329 let new_commitments = derive_account_commitments(&transactions);
330 let superseded_states = self
331 .account_state_sync(
332 &mut account_updates,
333 &accounts,
334 &new_commitments,
335 block_num,
336 &chain_tip_header,
337 )
338 .await?;
339
340 for superseded_state in superseded_states {
342 transaction_updates.apply_superseded_account_state(superseded_state);
343 }
344
345 let mut working_mmr = current_partial_mmr.clone();
347
348 Self::advance_mmr(
349 mmr_delta,
350 &chain_tip_header,
351 &mut working_mmr,
352 &mut partial_blockchain_updates,
353 )?;
354 let relevant_note_blocks = self.screen_note_blocks(note_blocks, &mut note_updates).await?;
355 self.apply_transactions_and_nullifiers(
356 &chain_tip_header,
357 &transactions,
358 &mut note_updates,
359 &mut transaction_updates,
360 )?;
361
362 if self.sync_nullifiers {
363 self.nullifiers_state_sync(
364 &mut note_updates,
365 &mut transaction_updates,
366 chain_tip,
367 block_num,
368 )
369 .await?;
370 }
371
372 self.recover_consumed_public_notes(&mut note_updates, &transactions).await?;
373
374 let blocks_with_unspent_notes: BTreeSet<BlockNumber> =
375 note_updates.unspent_input_note_block_numbers().collect();
376
377 Self::validate_and_track_note_blocks(
378 relevant_note_blocks,
379 &blocks_with_unspent_notes,
380 &mut working_mmr,
381 &mut partial_blockchain_updates,
382 )?;
383
384 *current_partial_mmr = working_mmr;
385
386 Ok(StateSyncUpdate::from_parts(
387 chain_tip,
388 partial_blockchain_updates,
389 note_updates,
390 transaction_updates,
391 account_updates,
392 ))
393 }
394
395 async fn recover_consumed_public_notes(
400 &self,
401 note_updates: &mut NoteUpdateTracker,
402 transactions: &[RpcTransactionRecord],
403 ) -> Result<(), ClientError> {
404 let mut recoverable_consumed_notes: BTreeMap<NoteId, RecoverableConsumedNote> =
405 BTreeMap::new();
406 for tx in transactions {
407 for (nullifier, note_id) in tx.trusted_consumed_note_refs() {
408 recoverable_consumed_notes.insert(
409 note_id,
410 RecoverableConsumedNote {
411 nullifier,
412 consumer: tx.transaction_header.account_id(),
413 block_num: tx.block_num,
414 },
415 );
416 }
417 }
418 recoverable_consumed_notes.retain(|note_id, _| !note_updates.tracks_note(*note_id));
421
422 let note_ids: Vec<NoteId> = recoverable_consumed_notes.keys().copied().collect();
423 if note_ids.is_empty() {
424 return Ok(());
425 }
426
427 for fetched in self.rpc_api.get_notes_by_id(¬e_ids).await? {
428 match fetched {
429 FetchedNote::Public(note, _) => {
430 let Some(reference) = recoverable_consumed_notes.get(¬e.id()) else {
431 continue;
432 };
433 if note.nullifier() != reference.nullifier {
436 return Err(RpcError::InvalidResponse(format!(
437 "node returned note {} whose nullifier doesn't match the consumed reference",
438 note.id()
439 ))
440 .into());
441 }
442 note_updates.insert_consumed_public_note(
443 note,
444 reference.consumer,
445 reference.block_num,
446 )?;
447 },
448 FetchedNote::Private(note_id, ..) => {
449 return Err(RpcError::InvalidResponse(format!(
450 "node returned private note {note_id} for a public consumed-note reference"
451 ))
452 .into());
453 },
454 }
455 }
456
457 Ok(())
458 }
459
460 async fn fetch_sync_data(
469 &self,
470 current_block_num: BlockNumber,
471 account_ids: &[AccountId],
472 note_tags: &Arc<BTreeSet<NoteTag>>,
473 ) -> Result<Option<FetchedSyncData>, ClientError> {
474 let chain_mmr_info = self
476 .rpc_api
477 .sync_chain_mmr(current_block_num, SyncTarget::CommittedChainTip)
478 .await?;
479 let chain_tip = chain_mmr_info.block_to;
480
481 Self::validate_chain_mmr_response(&chain_mmr_info, current_block_num)?;
483
484 if chain_tip == current_block_num {
486 info!(block_num = %current_block_num, "Already at chain tip, nothing to sync.");
487 return Ok(None);
488 }
489
490 info!(
491 block_from = %current_block_num,
492 block_to = %chain_tip,
493 "Syncing state.",
494 );
495
496 let note_blocks = if note_tags.is_empty() {
501 Vec::new()
502 } else {
503 self.rpc_api
504 .sync_notes_with_content(
505 current_block_num + 1,
506 chain_tip,
507 note_tags.as_ref(),
508 NoteContentFetch::PublicDetailsAndAttachments,
509 )
510 .await?
511 };
512
513 Self::validate_note_blocks_range(¬e_blocks, current_block_num, chain_tip)?;
515
516 let note_count: usize = note_blocks.iter().map(|b| b.notes.len()).sum();
517 info!(
518 blocks_with_notes = note_blocks.len(),
519 notes = note_count,
520 "Fetched note sync data.",
521 );
522
523 let transaction_records = if account_ids.is_empty() {
526 Vec::new()
527 } else {
528 self.rpc_api
529 .sync_transactions(current_block_num + 1, chain_tip, account_ids.to_vec())
530 .await?
531 };
532
533 Self::validate_transaction_records_range(
534 &transaction_records,
535 current_block_num,
536 chain_tip,
537 )?;
538
539 Ok(Some(FetchedSyncData {
540 mmr_delta: chain_mmr_info.mmr_delta,
541 chain_tip_header: chain_mmr_info.block_header,
542 note_blocks,
543 transactions: transaction_records,
544 }))
545 }
546
547 fn validate_chain_mmr_response(
552 chain_mmr_info: &ChainMmrInfo,
553 current_block_num: BlockNumber,
554 ) -> Result<(), ClientError> {
555 if chain_mmr_info.block_header.block_num() != chain_mmr_info.block_to {
556 return Err(ClientError::ChainValidationError(format!(
557 "sync_chain_mmr block_header.block_num ({}) does not match block_to ({})",
558 chain_mmr_info.block_header.block_num(),
559 chain_mmr_info.block_to
560 )));
561 }
562 if chain_mmr_info.block_from != current_block_num {
563 return Err(ClientError::ChainValidationError(format!(
564 "sync_chain_mmr block_from mismatch: expected {current_block_num}, got {}",
565 chain_mmr_info.block_from
566 )));
567 }
568 if chain_mmr_info.block_to < current_block_num {
569 return Err(ClientError::ChainValidationError(format!(
570 "sync_chain_mmr block_to ({}) is behind current block {current_block_num}",
571 chain_mmr_info.block_to
572 )));
573 }
574 Ok(())
575 }
576
577 fn validate_note_blocks_range(
580 note_blocks: &[ResolvedSyncNotesBlock],
581 current_block_num: BlockNumber,
582 chain_tip: BlockNumber,
583 ) -> Result<(), ClientError> {
584 for block in note_blocks {
585 let block_num = block.block_header.block_num();
586 if block_num <= current_block_num || block_num > chain_tip {
587 return Err(ClientError::ChainValidationError(format!(
588 "sync_notes returned block {block_num} outside requested range ({current_block_num}, {chain_tip}]"
589 )));
590 }
591 }
592 Ok(())
593 }
594
595 fn validate_transaction_records_range(
598 records: &[RpcTransactionRecord],
599 current_block_num: BlockNumber,
600 chain_tip: BlockNumber,
601 ) -> Result<(), ClientError> {
602 for record in records {
603 let block_num = record.block_num;
604 if block_num <= current_block_num || block_num > chain_tip {
605 return Err(ClientError::ChainValidationError(format!(
606 "sync_transactions returned block {block_num} outside requested range ({current_block_num}, {chain_tip}]"
607 )));
608 }
609 }
610 Ok(())
611 }
612
613 fn advance_mmr(
620 mmr_delta: MmrDelta,
621 chain_tip_header: &BlockHeader,
622 current_partial_mmr: &mut PartialMmr,
623 partial_blockchain_updates: &mut PartialBlockchainUpdates,
624 ) -> Result<(), ClientError> {
625 let mut new_authentication_nodes =
626 current_partial_mmr.apply(mmr_delta).map_err(StoreError::MmrError)?;
627 let new_peaks = current_partial_mmr.peaks();
628
629 let peaks_commitment = new_peaks.hash_peaks();
633 if peaks_commitment != chain_tip_header.chain_commitment() {
634 return Err(ClientError::ChainValidationError(format!(
635 "MMR peaks commitment is {} and does not match block header chain commitment {}",
636 peaks_commitment.to_hex(),
637 chain_tip_header.chain_commitment().to_hex()
638 )));
639 }
640
641 partial_blockchain_updates.new_peaks = new_peaks;
642
643 new_authentication_nodes.append(
648 &mut current_partial_mmr
649 .add(chain_tip_header.commitment(), false)
650 .map_err(StoreError::MmrError)?,
651 );
652
653 partial_blockchain_updates.insert(chain_tip_header.clone(), false);
654 partial_blockchain_updates.extend_authentication_nodes(new_authentication_nodes);
655
656 Ok(())
657 }
658
659 async fn screen_note_blocks(
667 &self,
668 note_blocks: Vec<ResolvedSyncNotesBlock>,
669 note_updates: &mut NoteUpdateTracker,
670 ) -> Result<Vec<RelevantNoteBlock>, ClientError> {
671 let mut relevant_blocks = Vec::new();
672
673 for block in note_blocks {
674 let relevance =
675 self.note_state_sync(note_updates, block.notes, &block.block_header).await?;
676
677 if relevance.is_relevant() {
678 relevant_blocks.push(RelevantNoteBlock {
679 block_header: block.block_header,
680 mmr_path: block.mmr_path,
681 observer_requires_block: relevance.observer_requires_block,
682 });
683 }
684 }
685
686 Ok(relevant_blocks)
687 }
688
689 fn validate_and_track_note_blocks(
699 relevant_blocks: Vec<RelevantNoteBlock>,
700 blocks_with_unspent_notes: &BTreeSet<BlockNumber>,
701 partial_mmr: &mut PartialMmr,
702 partial_blockchain_updates: &mut PartialBlockchainUpdates,
703 ) -> Result<(), ClientError> {
704 let nodes_before: BTreeSet<InOrderIndex> = partial_mmr.nodes().map(|(k, _)| *k).collect();
705
706 for RelevantNoteBlock {
707 block_header,
708 mmr_path,
709 observer_requires_block,
710 } in relevant_blocks
711 {
712 let block_pos = block_header.block_num().as_usize();
713 let was_tracked = partial_mmr.is_tracked(block_pos);
714
715 partial_mmr
718 .track(block_pos, block_header.commitment(), &mmr_path)
719 .map_err(StoreError::MmrError)?;
720
721 if observer_requires_block
722 || blocks_with_unspent_notes.contains(&block_header.block_num())
723 {
724 partial_blockchain_updates.insert(block_header, true);
725 } else if !was_tracked {
726 partial_mmr.untrack(block_pos);
727 }
728 }
729
730 partial_blockchain_updates.extend_authentication_nodes(
732 partial_mmr
733 .nodes()
734 .filter(|(index, _)| !nodes_before.contains(index))
735 .map(|(index, value)| (*index, *value)),
736 );
737
738 Ok(())
739 }
740
741 fn apply_transactions_and_nullifiers(
745 &self,
746 chain_tip_header: &BlockHeader,
747 transactions: &[RpcTransactionRecord],
748 note_updates: &mut NoteUpdateTracker,
749 transaction_updates: &mut TransactionUpdateTracker,
750 ) -> Result<(), ClientError> {
751 note_updates.extend_nullifiers(compute_ordered_nullifiers(transactions));
752
753 for record in transactions {
754 transaction_updates
755 .apply_transaction_inclusion(record, u64::from(chain_tip_header.timestamp())); }
757 transaction_updates
758 .apply_sync_height_update(chain_tip_header.block_num(), self.tx_discard_delta);
759
760 for transaction in transactions {
761 note_updates.apply_output_note_inclusion_proofs(&transaction.output_notes)?;
765
766 Self::mark_erased_notes_as_consumed(note_updates, transaction);
768 }
769
770 Ok(())
771 }
772
773 fn mark_erased_notes_as_consumed(
779 note_updates: &mut NoteUpdateTracker,
780 transaction: &RpcTransactionRecord,
781 ) {
782 for note_header in &transaction.erased_output_notes {
783 let _ = note_updates.mark_erased_note_as_consumed(note_header, transaction.block_num);
785 }
786 }
787
788 async fn account_state_sync(
801 &self,
802 account_updates: &mut AccountUpdates,
803 accounts: &[AccountHeader],
804 account_commitment_updates: &[(AccountId, Word)],
805 block_from: BlockNumber,
806 chain_tip_header: &BlockHeader,
807 ) -> Result<Vec<Word>, ClientError> {
808 let (public_accounts, private_accounts): (Vec<_>, Vec<_>) =
811 accounts.iter().partition(|header| !header.id().is_private());
812
813 let superseded_states = self
814 .sync_public_accounts(
815 account_updates,
816 account_commitment_updates,
817 &public_accounts,
818 block_from,
819 chain_tip_header,
820 )
821 .await?;
822
823 let mut mismatched_private_accounts = Vec::new();
826 for header in &private_accounts {
827 let account_id = header.id();
828 let local_commitment = header.to_commitment();
829 let record_diverges = account_commitment_updates
830 .iter()
831 .any(|(id, digest)| *id == account_id && *digest != local_commitment);
832 if !record_diverges {
833 continue;
834 }
835
836 if let Some(proven_commitment) = self
837 .verify_private_account_mismatch(account_id, local_commitment, chain_tip_header)
838 .await?
839 {
840 mismatched_private_accounts.push((account_id, proven_commitment));
841 }
842 }
843
844 account_updates.extend(AccountUpdates::new(Vec::new(), mismatched_private_accounts));
845
846 Ok(superseded_states)
847 }
848
849 async fn verify_private_account_mismatch(
860 &self,
861 account_id: AccountId,
862 local_commitment: Word,
863 chain_tip_header: &BlockHeader,
864 ) -> Result<Option<Word>, ClientError> {
865 let chain_tip = chain_tip_header.block_num();
866 let (proof_block_num, proof) = self
867 .rpc_api
868 .get_account(account_id, GetAccountRequest::new().at(AccountStateAt::Block(chain_tip)))
869 .await?;
870
871 if proof_block_num != chain_tip {
872 return Err(ClientError::ChainValidationError(format!(
873 "get_account returned a proof at block {proof_block_num}, expected chain tip {chain_tip}"
874 )));
875 }
876
877 let (witness, _) = proof.into_parts();
878 let witness_id = witness.id();
879 let proven_commitment = witness.state_commitment();
880 if witness.into_proof().compute_root() != chain_tip_header.account_root() {
883 return Err(ClientError::ChainValidationError(format!(
884 "account witness for {account_id} does not verify against the chain tip account root"
885 )));
886 }
887
888 if witness_id != account_id
891 || proven_commitment == Word::empty()
892 || proven_commitment == local_commitment
893 {
894 return Ok(None);
895 }
896
897 Ok(Some(proven_commitment))
898 }
899
900 async fn sync_public_accounts(
909 &self,
910 account_updates: &mut AccountUpdates,
911 commitment_updates: &[(AccountId, Word)],
912 current_public_accounts: &[&AccountHeader],
913 block_from: BlockNumber,
914 chain_tip_header: &BlockHeader,
915 ) -> Result<Vec<Word>, ClientError> {
916 let local_headers: BTreeMap<AccountId, &AccountHeader> =
917 current_public_accounts.iter().map(|header| (header.id(), *header)).collect();
918 let mut superseded_states = Vec::new();
920 for (id, commitment) in commitment_updates {
921 let Some(local_header) = local_headers.get(id).copied() else {
922 continue;
923 };
924
925 if local_header.to_commitment() == *commitment {
926 continue;
927 }
928
929 match self
930 .sync_public_account(*id, local_header, block_from, chain_tip_header)
931 .await?
932 {
933 PublicAccountSync::Apply(public_update) => {
934 account_updates.extend(AccountUpdates::new(vec![*public_update], Vec::new()));
935 },
936 PublicAccountSync::Superseded => {
937 superseded_states.push(local_header.to_commitment());
938 },
939 PublicAccountSync::Ignore => {},
940 }
941 }
942
943 Ok(superseded_states)
944 }
945
946 async fn sync_public_account(
960 &self,
961 account_id: AccountId,
962 local_header: &AccountHeader,
963 block_from: BlockNumber,
964 chain_tip_header: &BlockHeader,
965 ) -> Result<PublicAccountSync, ClientError> {
966 let target_block_num = chain_tip_header.block_num();
967
968 let (proof_block_num, proof) = self
971 .rpc_api
972 .get_account(
973 account_id,
974 GetAccountRequest::new()
975 .at(AccountStateAt::Block(target_block_num))
976 .with_storage(StorageMapFetch::All)
977 .with_vault(VaultFetch::Always),
978 )
979 .await
980 .map_err(ClientError::RpcError)?;
981
982 let details =
983 Self::validate_account_proof(proof, proof_block_num, account_id, chain_tip_header)?;
984
985 match details
986 .header
987 .nonce()
988 .as_canonical_u64()
989 .cmp(&local_header.nonce().as_canonical_u64())
990 {
991 Ordering::Less => return Ok(PublicAccountSync::Ignore),
994 Ordering::Equal => return Ok(PublicAccountSync::Superseded),
996 Ordering::Greater => {},
998 }
999
1000 let vault_oversized = details.vault_details.too_many_assets;
1001 let any_map_oversized =
1002 details.storage_details.map_details.iter().any(|m| m.too_many_entries);
1003
1004 let public_update = if vault_oversized || any_map_oversized {
1008 self.build_patch_update(account_id, &details, block_from, proof_block_num)
1010 .await?
1011 } else {
1012 let account = Account::try_from(&details).map_err(ClientError::RpcError)?;
1014 PublicAccountUpdate::Full(account)
1015 };
1016
1017 Ok(PublicAccountSync::Apply(Box::new(public_update)))
1018 }
1019
1020 fn validate_account_proof(
1036 proof: AccountProof,
1037 proof_block_num: BlockNumber,
1038 account_id: AccountId,
1039 chain_tip_header: &BlockHeader,
1040 ) -> Result<AccountDetails, ClientError> {
1041 let target_block_num = chain_tip_header.block_num();
1042
1043 if proof_block_num != target_block_num {
1044 return Err(ClientError::ChainValidationError(format!(
1045 "get_account returned block {proof_block_num} but {target_block_num} was requested"
1046 )));
1047 }
1048
1049 let (witness, details) = proof.into_parts();
1050
1051 if witness.id() != account_id {
1053 return Err(ClientError::ChainValidationError(format!(
1054 "get_account returned account {} but {account_id} was requested",
1055 witness.id()
1056 )));
1057 }
1058
1059 let account_key = AccountIdKey::from(account_id).as_word();
1060 let state_commitment = witness.state_commitment();
1061 witness
1062 .into_proof()
1063 .verify_presence(&account_key, &state_commitment, &chain_tip_header.account_root())
1064 .map_err(|err| {
1065 ClientError::ChainValidationError(format!(
1066 "get_account witness for account {account_id} does not open under block \
1067 {target_block_num} account root: {err}"
1068 ))
1069 })?;
1070
1071 Ok(details.expect("node returned no details for a public account"))
1072 }
1073
1074 async fn build_patch_update(
1077 &self,
1078 account_id: AccountId,
1079 details: &AccountDetails,
1080 block_from: BlockNumber,
1081 block_to: BlockNumber,
1082 ) -> Result<PublicAccountUpdate, ClientError> {
1083 let value_slot_updates: Vec<(_, Word)> = details
1084 .storage_details
1085 .header
1086 .slots()
1087 .filter(|slot| slot.slot_type() == StorageSlotType::Value)
1088 .map(|slot| (slot.name().clone(), slot.value()))
1089 .collect();
1090
1091 let map_info = self
1094 .rpc_api
1095 .sync_storage_maps(block_from + 1, block_to, account_id)
1096 .await
1097 .map_err(ClientError::RpcError)?;
1098 let vault_info = self
1099 .rpc_api
1100 .sync_account_vault(block_from + 1, block_to, account_id)
1101 .await
1102 .map_err(ClientError::RpcError)?;
1103
1104 let patch = build_account_patch(
1105 &details.header,
1106 value_slot_updates,
1107 map_info.map_entries,
1108 vault_info.vault_patch,
1109 details.code.clone(),
1110 )
1111 .map_err(StoreError::AccountPatchError)?;
1112
1113 Ok(PublicAccountUpdate::Patch {
1114 new_header: details.header.clone(),
1115 patch,
1116 })
1117 }
1118
1119 async fn note_state_sync(
1136 &self,
1137 note_updates: &mut NoteUpdateTracker,
1138 notes: BTreeMap<NoteId, SyncedNote>,
1139 block_header: &BlockHeader,
1140 ) -> Result<NoteBlockRelevance, ClientError> {
1141 let mut relevance = NoteBlockRelevance::default();
1142
1143 for (_, SyncedNote { committed, content }) in notes {
1144 let (details, attachments) = match content {
1148 Some(ResolvedNoteContent::Public { details, attachments }) => {
1149 (Some(details), Some(attachments))
1150 },
1151 Some(ResolvedNoteContent::Private { attachments }) => (None, Some(attachments)),
1152 None => (None, None),
1153 };
1154
1155 let public_note = details.map(|details| {
1158 let state = UnverifiedNoteState {
1159 metadata: *committed.metadata(),
1160 inclusion_proof: committed.inclusion_proof().clone(),
1161 }
1162 .into();
1163 InputNoteRecord::new(
1164 details,
1165 attachments.clone().unwrap_or_else(NoteAttachments::empty),
1166 None,
1167 state,
1168 )
1169 });
1170
1171 if !self.note_observers.is_empty() {
1175 for obs in &self.note_observers {
1176 match obs.observe(&committed, attachments.as_ref()).await {
1177 Ok(true) => relevance.observer_requires_block = true,
1178 Ok(false) => {},
1179 Err(err) => {
1180 tracing::warn!(
1181 observer = obs.name(),
1182 error = ?err,
1183 "note observer failed; sync continues",
1184 );
1185 },
1186 }
1187 }
1188 }
1189
1190 match self.note_screener.on_note_received(committed, public_note).await? {
1191 NoteUpdateAction::Commit(committed_note) => {
1192 relevance.has_client_note |= note_updates
1196 .apply_committed_note_state_transitions(
1197 &committed_note,
1198 block_header,
1199 attachments.as_ref(),
1200 )?;
1201 },
1202 NoteUpdateAction::Insert(public_note) => {
1203 relevance.has_client_note = true;
1204
1205 note_updates.apply_new_public_note(public_note, block_header)?;
1206 },
1207 NoteUpdateAction::Discard => {},
1208 }
1209 }
1210
1211 Ok(relevance)
1212 }
1213
1214 async fn nullifiers_state_sync(
1221 &self,
1222 note_updates: &mut NoteUpdateTracker,
1223 transaction_updates: &mut TransactionUpdateTracker,
1224 chain_tip: BlockNumber,
1225 current_block_num: BlockNumber,
1226 ) -> Result<(), ClientError> {
1227 let nullifiers_tags: Vec<u16> =
1233 note_updates.unspent_nullifiers().map(|nullifier| nullifier.prefix()).collect();
1234
1235 let mut new_nullifiers = self
1236 .rpc_api
1237 .sync_nullifiers(&nullifiers_tags, current_block_num + 1, chain_tip)
1238 .await?;
1239
1240 new_nullifiers.retain(|update| update.block_num <= chain_tip);
1243
1244 let consumptions: Vec<NoteConsumption> = new_nullifiers
1246 .into_iter()
1247 .map(|update| NoteConsumption {
1248 external_consumer: transaction_updates
1249 .external_nullifier_account(&update.nullifier),
1250 nullifier: update.nullifier,
1251 block_num: update.block_num,
1252 })
1253 .collect();
1254
1255 for consumption in consumptions {
1256 note_updates.apply_note_consumption(
1257 &consumption,
1258 transaction_updates.committed_transactions(),
1259 )?;
1260
1261 transaction_updates.apply_input_note_nullified(consumption.nullifier);
1265 }
1266
1267 Ok(())
1268 }
1269}
1270
1271fn group_txs_by_account_block(
1276 transaction_records: &[RpcTransactionRecord],
1277) -> BTreeMap<(AccountId, BlockNumber), Vec<&RpcTransactionRecord>> {
1278 let mut groups: BTreeMap<(AccountId, BlockNumber), Vec<&RpcTransactionRecord>> =
1279 BTreeMap::new();
1280 for record in transaction_records {
1281 let account_id = record.transaction_header.account_id();
1282 groups.entry((account_id, record.block_num)).or_default().push(record);
1283 }
1284 groups
1285}
1286
1287fn walk_execution_chain<'a>(
1293 txs: &'a [&'a RpcTransactionRecord],
1294) -> impl Iterator<Item = &'a RpcTransactionRecord> + 'a {
1295 let (self_loops, chained): (Vec<&RpcTransactionRecord>, Vec<&RpcTransactionRecord>) =
1296 txs.iter().copied().partition(|tx| {
1297 tx.transaction_header.initial_state_commitment()
1298 == tx.transaction_header.final_state_commitment()
1299 });
1300
1301 let final_states: BTreeSet<Word> = chained
1302 .iter()
1303 .map(|tx| tx.transaction_header.final_state_commitment())
1304 .collect();
1305
1306 let mut init_to_tx: BTreeMap<Word, &RpcTransactionRecord> = chained
1307 .iter()
1308 .map(|tx| (tx.transaction_header.initial_state_commitment(), *tx))
1309 .collect();
1310
1311 let start = chained
1312 .iter()
1313 .find(|tx| !final_states.contains(&tx.transaction_header.initial_state_commitment()))
1314 .copied();
1315
1316 assert!(start.is_some() || chained.is_empty(), "cannot walk cyclic execution chain");
1317
1318 let mut current =
1319 start.and_then(|tx| init_to_tx.remove(&tx.transaction_header.initial_state_commitment()));
1320 let mut self_loops_iter = self_loops.into_iter();
1321
1322 core::iter::from_fn(move || {
1323 if let Some(tx) = current {
1324 current = init_to_tx.remove(&tx.transaction_header.final_state_commitment());
1325 return Some(tx);
1326 }
1327 self_loops_iter.next()
1328 })
1329}
1330
1331fn derive_account_commitments(
1336 transaction_records: &[RpcTransactionRecord],
1337) -> Vec<(AccountId, Word)> {
1338 let mut latest_by_account: BTreeMap<AccountId, (BlockNumber, Word)> = BTreeMap::new();
1339
1340 for ((account_id, block_num), txs) in &group_txs_by_account_block(transaction_records) {
1341 let terminal_state = walk_execution_chain(txs)
1342 .last()
1343 .expect("account must have a final state")
1344 .transaction_header
1345 .final_state_commitment();
1346
1347 latest_by_account
1348 .entry(*account_id)
1349 .and_modify(|(existing_block, existing_state)| {
1350 if *block_num > *existing_block {
1351 *existing_block = *block_num;
1352 *existing_state = terminal_state;
1353 }
1354 })
1355 .or_insert((*block_num, terminal_state));
1356 }
1357
1358 latest_by_account
1359 .into_iter()
1360 .map(|(account_id, (_, state))| (account_id, state))
1361 .collect()
1362}
1363
1364fn compute_ordered_nullifiers(transaction_records: &[RpcTransactionRecord]) -> Vec<Nullifier> {
1371 let mut result = Vec::new();
1372
1373 for txs in group_txs_by_account_block(transaction_records).values() {
1374 for tx in walk_execution_chain(txs) {
1375 for commitment in tx.transaction_header.input_notes().iter() {
1376 result.push(commitment.nullifier());
1377 }
1378 }
1379 }
1380
1381 result
1382}
1383
1384#[cfg(all(test, feature = "testing"))]
1385mod tests {
1386 use alloc::collections::BTreeSet;
1387 use alloc::sync::Arc;
1388
1389 use async_trait::async_trait;
1390 use miden_protocol::account::Account;
1391 use miden_protocol::assembly::DefaultSourceManager;
1392 use miden_protocol::asset::{Asset, FungibleAsset};
1393 use miden_protocol::block::BlockNumber;
1394 use miden_protocol::crypto::merkle::MerklePath;
1395 use miden_protocol::crypto::merkle::mmr::{Forest, InOrderIndex, PartialMmr};
1396 use miden_protocol::note::{
1397 Note,
1398 NoteAssets,
1399 NoteAttachment,
1400 NoteAttachments,
1401 NoteDetails,
1402 NoteHeader,
1403 NoteMetadata,
1404 NoteRecipient,
1405 NoteStorage,
1406 NoteTag,
1407 NoteType,
1408 PartialNoteMetadata,
1409 };
1410 use miden_protocol::testing::account_id::{
1411 ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET,
1412 ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
1413 ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE,
1414 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
1415 ACCOUNT_ID_SENDER,
1416 };
1417 use miden_protocol::transaction::{InputNotes, TransactionArgs, TransactionHeader};
1418 use miden_protocol::vm::AdviceMap;
1419 use miden_protocol::{EMPTY_WORD, Felt, Word, ZERO};
1420 use miden_standards::code_builder::CodeBuilder;
1421 use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint};
1422 use miden_testing::{MockChainBuilder, MockTransactionInput};
1423
1424 use super::*;
1425 use crate::store::{OutputNoteRecord, OutputNoteState};
1426 use crate::test_utils::mock::MockRpcApi;
1427
1428 struct MockScreener;
1430
1431 #[async_trait(?Send)]
1432 impl OnNoteReceived for MockScreener {
1433 async fn on_note_received(
1434 &self,
1435 _committed_note: CommittedNote,
1436 _public_note: Option<InputNoteRecord>,
1437 ) -> Result<NoteUpdateAction, ClientError> {
1438 Ok(NoteUpdateAction::Discard)
1439 }
1440 }
1441
1442 struct AlwaysRelevantObserver;
1444
1445 #[async_trait(?Send)]
1446 impl NoteObserver for AlwaysRelevantObserver {
1447 fn name(&self) -> &'static str {
1448 "always-relevant"
1449 }
1450
1451 async fn observe(
1452 &self,
1453 _committed_note: &CommittedNote,
1454 _attachments: Option<&NoteAttachments>,
1455 ) -> Result<bool, ClientError> {
1456 Ok(true)
1457 }
1458 }
1459
1460 fn empty() -> StateSyncInput {
1461 StateSyncInput {
1462 accounts: vec![],
1463 note_tags: BTreeSet::new(),
1464 input_notes: vec![],
1465 output_notes: vec![],
1466 uncommitted_transactions: vec![],
1467 }
1468 }
1469
1470 fn word(n: u64) -> miden_protocol::Word {
1471 [
1472 Felt::new(n).expect("test value should fit into the base field"),
1473 ZERO,
1474 ZERO,
1475 ZERO,
1476 ]
1477 .into()
1478 }
1479
1480 fn header_with_account_root(header: &BlockHeader, account_root: Word) -> BlockHeader {
1481 BlockHeader::new(
1482 header.version(),
1483 header.prev_block_commitment(),
1484 header.block_num(),
1485 header.chain_commitment(),
1486 account_root,
1487 header.nullifier_root(),
1488 header.note_root(),
1489 header.tx_commitment(),
1490 header.tx_kernel_commitment(),
1491 header.validator_keys().clone(),
1492 header.fee_parameters().clone(),
1493 header.timestamp(),
1494 )
1495 }
1496
1497 #[tokio::test]
1498 async fn sync_public_accounts_ignores_older_node_snapshot() {
1499 let mut builder = MockChainBuilder::new();
1500 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1501 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1502 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1503 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1504
1505 let local_header =
1508 AccountHeader::new(account.id(), Felt::from(2u32), EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
1509 let current_public_accounts = vec![&local_header];
1510 let commitment_updates = vec![(account.id(), account.to_commitment())];
1511 let mut account_updates = AccountUpdates::default();
1512
1513 let superseded = state_sync
1514 .sync_public_accounts(
1515 &mut account_updates,
1516 &commitment_updates,
1517 ¤t_public_accounts,
1518 BlockNumber::GENESIS,
1519 &chain_tip_header,
1520 )
1521 .await
1522 .unwrap();
1523
1524 assert!(
1525 account_updates.updated_public_accounts().is_empty(),
1526 "public account sync should ignore node snapshots that are older than local"
1527 );
1528 assert!(
1529 superseded.is_empty(),
1530 "an older node snapshot must not supersede the local state"
1531 );
1532 }
1533
1534 #[tokio::test]
1535 async fn sync_public_accounts_marks_same_nonce_mismatch_as_superseded() {
1536 let mut builder = MockChainBuilder::new();
1537 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1538 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1539 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1540 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1541
1542 let local_header =
1545 AccountHeader::new(account.id(), account.nonce(), EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
1546 let current_public_accounts = vec![&local_header];
1547 let commitment_updates = vec![(account.id(), account.to_commitment())];
1548 let mut account_updates = AccountUpdates::default();
1549
1550 let superseded = state_sync
1551 .sync_public_accounts(
1552 &mut account_updates,
1553 &commitment_updates,
1554 ¤t_public_accounts,
1555 BlockNumber::GENESIS,
1556 &chain_tip_header,
1557 )
1558 .await
1559 .unwrap();
1560
1561 assert!(
1562 account_updates.updated_public_accounts().is_empty(),
1563 "a same-nonce fork must not overwrite the account while its tx is still pending"
1564 );
1565 assert_eq!(
1566 superseded,
1567 vec![local_header.to_commitment()],
1568 "the superseded local state should be reported so its transaction is discarded"
1569 );
1570 }
1571
1572 #[test]
1578 fn validate_transaction_records_range_rejects_out_of_range_blocks() {
1579 let account_id: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap();
1580 let current = BlockNumber::from(5u32);
1581 let chain_tip = BlockNumber::from(10u32);
1582
1583 StateSync::validate_transaction_records_range(
1584 &[make_tx_record(account_id, 7)],
1585 current,
1586 chain_tip,
1587 )
1588 .unwrap();
1589
1590 let result = StateSync::validate_transaction_records_range(
1591 &[make_tx_record(account_id, 11)],
1592 current,
1593 chain_tip,
1594 );
1595 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1596
1597 let result = StateSync::validate_transaction_records_range(
1598 &[make_tx_record(account_id, 5)],
1599 current,
1600 chain_tip,
1601 );
1602 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1603 }
1604
1605 #[tokio::test]
1608 async fn verify_private_account_mismatch_ignores_forged_commitment() {
1609 let mut builder = MockChainBuilder::new();
1610 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1611 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1612 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1613 let on_chain_commitment = account.to_commitment();
1614 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1615
1616 let result = state_sync
1617 .verify_private_account_mismatch(account.id(), on_chain_commitment, &chain_tip_header)
1618 .await
1619 .unwrap();
1620
1621 assert!(
1622 result.is_none(),
1623 "an unproven commitment must not lock an account whose on-chain state matches local"
1624 );
1625 }
1626
1627 #[tokio::test]
1630 async fn verify_private_account_mismatch_reports_proven_divergence() {
1631 let mut builder = MockChainBuilder::new();
1632 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1633 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1634 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1635 let on_chain_commitment = account.to_commitment();
1636 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1637 let stale_local_commitment = word(0xdead_beef);
1638
1639 let result = state_sync
1640 .verify_private_account_mismatch(
1641 account.id(),
1642 stale_local_commitment,
1643 &chain_tip_header,
1644 )
1645 .await
1646 .unwrap();
1647
1648 assert_eq!(
1649 result,
1650 Some(on_chain_commitment),
1651 "a proven divergence should return the proven commitment to lock with"
1652 );
1653 }
1654
1655 #[tokio::test]
1658 async fn verify_private_account_mismatch_rejects_unverifiable_proof() {
1659 let mut builder = MockChainBuilder::new();
1660 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1661 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1662 let real_header = rpc_api.mock_chain.read().latest_block_header();
1663 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1664
1665 let tampered_header = BlockHeader::new(
1668 real_header.version(),
1669 real_header.prev_block_commitment(),
1670 real_header.block_num(),
1671 real_header.chain_commitment(),
1672 word(0xbad0_bad0),
1673 real_header.nullifier_root(),
1674 real_header.note_root(),
1675 real_header.tx_commitment(),
1676 real_header.tx_kernel_commitment(),
1677 real_header.validator_keys().clone(),
1678 real_header.fee_parameters().clone(),
1679 real_header.timestamp(),
1680 );
1681
1682 let result = state_sync
1683 .verify_private_account_mismatch(
1684 account.id(),
1685 account.to_commitment(),
1686 &tampered_header,
1687 )
1688 .await;
1689 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1690 }
1691
1692 #[tokio::test]
1695 async fn sync_public_accounts_pins_account_fetch_to_sync_target() {
1696 let mut builder = MockChainBuilder::new();
1697 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1698 let mut chain = builder.build().unwrap();
1699
1700 let sync_target_header = chain.latest_block_header();
1702 let tx = Box::pin(
1703 chain
1704 .build_transaction(MockTransactionInput::AccountId(account.id()))
1705 .build()
1706 .unwrap()
1707 .execute(),
1708 )
1709 .await
1710 .unwrap();
1711 let local_header = tx.final_account().clone();
1712 assert_ne!(local_header.to_commitment(), account.to_commitment());
1713 chain.add_pending_executed_transaction(&tx).unwrap();
1714
1715 let rpc_api = MockRpcApi::new(chain);
1716 rpc_api.prove_block();
1718 assert_eq!(
1719 rpc_api
1720 .mock_chain
1721 .read()
1722 .committed_account(account.id())
1723 .unwrap()
1724 .to_commitment(),
1725 local_header.to_commitment()
1726 );
1727 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1728
1729 let current_public_accounts = vec![&local_header];
1730 let commitment_updates = vec![(account.id(), account.to_commitment())];
1731 let mut account_updates = AccountUpdates::default();
1732
1733 let superseded = state_sync
1734 .sync_public_accounts(
1735 &mut account_updates,
1736 &commitment_updates,
1737 ¤t_public_accounts,
1738 BlockNumber::GENESIS,
1739 &sync_target_header,
1740 )
1741 .await
1742 .unwrap();
1743
1744 assert!(superseded.is_empty(), "the transaction must not be superseded");
1745 assert!(
1746 account_updates.updated_public_accounts().is_empty(),
1747 "the target state must not overwrite the local account"
1748 );
1749 }
1750
1751 async fn get_account_proof(
1753 rpc_api: &MockRpcApi,
1754 account_id: AccountId,
1755 ) -> (BlockNumber, AccountProof) {
1756 rpc_api
1757 .get_account(
1758 account_id,
1759 GetAccountRequest::new()
1760 .with_storage(StorageMapFetch::All)
1761 .with_vault(VaultFetch::Always),
1762 )
1763 .await
1764 .unwrap()
1765 }
1766
1767 #[tokio::test]
1769 async fn validate_account_proof_rejects_mismatched_account() {
1770 let mut builder = MockChainBuilder::new();
1771 let account_a = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1772 let account_b = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1773 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1774 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1775
1776 let (proof_block_num, proof) = get_account_proof(&rpc_api, account_b.id()).await;
1778 let result = StateSync::validate_account_proof(
1779 proof,
1780 proof_block_num,
1781 account_a.id(),
1782 &chain_tip_header,
1783 );
1784
1785 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1786 }
1787
1788 #[tokio::test]
1790 async fn validate_account_proof_rejects_wrong_account_root() {
1791 let mut builder = MockChainBuilder::new();
1792 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1793 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1794 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1795 let wrong_header = header_with_account_root(&chain_tip_header, word(999));
1796
1797 let (proof_block_num, proof) = get_account_proof(&rpc_api, account.id()).await;
1799 let result =
1800 StateSync::validate_account_proof(proof, proof_block_num, account.id(), &wrong_header);
1801
1802 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1803 }
1804
1805 #[tokio::test]
1807 async fn validate_account_proof_rejects_wrong_block() {
1808 let mut builder = MockChainBuilder::new();
1809 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1810 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1811 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1812
1813 let (proof_block_num, proof) = get_account_proof(&rpc_api, account.id()).await;
1815 let result = StateSync::validate_account_proof(
1816 proof,
1817 proof_block_num + 1,
1818 account.id(),
1819 &chain_tip_header,
1820 );
1821
1822 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1823 }
1824
1825 mod compute_nullifiers_tests {
1829 use alloc::vec;
1830
1831 use miden_protocol::block::BlockNumber;
1832 use miden_protocol::note::Nullifier;
1833 use miden_protocol::transaction::{InputNoteCommitment, InputNotes, TransactionHeader};
1834
1835 use super::word;
1836 use crate::rpc::domain::transaction::TransactionRecord as RpcTransactionRecord;
1837
1838 fn make_rpc_tx(
1839 init_state: u64,
1840 final_state: u64,
1841 nullifier_vals: &[u64],
1842 block_number: u32,
1843 ) -> RpcTransactionRecord {
1844 let account_id = miden_protocol::account::AccountId::try_from(
1845 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE,
1846 )
1847 .unwrap();
1848
1849 let input_notes = InputNotes::new_unchecked(
1850 nullifier_vals
1851 .iter()
1852 .map(|v| InputNoteCommitment::from(Nullifier::from_raw(word(*v))))
1853 .collect(),
1854 );
1855
1856 RpcTransactionRecord {
1857 block_num: BlockNumber::from(block_number),
1858 transaction_header: TransactionHeader::new(
1859 account_id,
1860 word(init_state),
1861 word(final_state),
1862 input_notes,
1863 vec![],
1864 ),
1865 output_notes: vec![],
1866 erased_output_notes: vec![],
1867 consumed_note_refs: vec![],
1868 }
1869 }
1870
1871 #[test]
1872 fn chains_rpc_transactions_by_state_commitment() {
1873 let tx_a = make_rpc_tx(1, 2, &[10], 5);
1876 let tx_b = make_rpc_tx(2, 3, &[20], 5);
1877 let tx_c = make_rpc_tx(3, 4, &[30], 5);
1878
1879 let result = super::super::compute_ordered_nullifiers(&[tx_c, tx_a, tx_b]);
1880
1881 assert_eq!(result[0], Nullifier::from_raw(word(10)));
1882 assert_eq!(result[1], Nullifier::from_raw(word(20)));
1883 assert_eq!(result[2], Nullifier::from_raw(word(30)));
1884 }
1885
1886 #[test]
1887 fn groups_independently_by_account_and_block() {
1888 let tx_a1 = make_rpc_tx(1, 2, &[10], 5);
1890 let tx_a2 = make_rpc_tx(2, 3, &[20], 5);
1891
1892 let tx_a3 = make_rpc_tx(3, 4, &[30], 6);
1894
1895 let account_b = miden_protocol::account::AccountId::try_from(
1897 miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
1898 )
1899 .unwrap();
1900
1901 let tx_b1 = RpcTransactionRecord {
1902 block_num: BlockNumber::from(5u32),
1903 transaction_header: TransactionHeader::new(
1904 account_b,
1905 word(100),
1906 word(200),
1907 InputNotes::new_unchecked(vec![InputNoteCommitment::from(
1908 Nullifier::from_raw(word(40)),
1909 )]),
1910 vec![],
1911 ),
1912 output_notes: vec![],
1913 erased_output_notes: vec![],
1914 consumed_note_refs: vec![],
1915 };
1916
1917 let result = super::super::compute_ordered_nullifiers(&[tx_a2, tx_b1, tx_a3, tx_a1]);
1918
1919 let pos = |val: u64| -> usize {
1922 result.iter().position(|n| *n == Nullifier::from_raw(word(val))).unwrap()
1923 };
1924
1925 assert!(pos(10) < pos(20)); assert!(result.contains(&Nullifier::from_raw(word(30)))); assert!(result.contains(&Nullifier::from_raw(word(40)))); }
1931
1932 #[test]
1933 fn multiple_nullifiers_per_transaction_are_consecutive() {
1934 let tx = make_rpc_tx(1, 2, &[10, 20, 30], 5);
1936
1937 let result = super::super::compute_ordered_nullifiers(&[tx]);
1938
1939 assert_eq!(result.len(), 3);
1940 assert!(result.contains(&Nullifier::from_raw(word(10))));
1941 assert!(result.contains(&Nullifier::from_raw(word(20))));
1942 assert!(result.contains(&Nullifier::from_raw(word(30))));
1943 }
1944
1945 #[test]
1946 fn empty_input_returns_empty_vec() {
1947 let result = super::super::compute_ordered_nullifiers(&[]);
1948 assert!(result.is_empty());
1949 }
1950 }
1951
1952 #[test]
1963 fn derive_account_commitments_walks_chains_per_account() {
1964 let make_tx = |account: AccountId, init_state: u64, final_state: u64, block_num: u32| {
1965 RpcTransactionRecord {
1966 block_num: BlockNumber::from(block_num),
1967 transaction_header: TransactionHeader::new(
1968 account,
1969 word(init_state),
1970 word(final_state),
1971 InputNotes::new_unchecked(vec![]),
1972 vec![],
1973 ),
1974 output_notes: vec![],
1975 erased_output_notes: vec![],
1976 consumed_note_refs: vec![],
1977 }
1978 };
1979
1980 let account_a: AccountId =
1981 ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE.try_into().unwrap();
1982 let account_b: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap();
1983
1984 let tx_a_b5_1 = make_tx(account_a, 1, 2, 5);
1985 let tx_a_b5_2 = make_tx(account_a, 2, 3, 5);
1986 let tx_a_b6_1 = make_tx(account_a, 3, 4, 6);
1987 let tx_a_b6_2 = make_tx(account_a, 4, 5, 6);
1988 let tx_b_b6 = make_tx(account_b, 10, 20, 6);
1989
1990 let result = super::derive_account_commitments(&[
1992 tx_a_b6_1, tx_b_b6, tx_a_b5_2, tx_a_b6_2, tx_a_b5_1,
1993 ]);
1994
1995 assert_eq!(result.len(), 2, "one entry per account");
1996 assert!(
1997 result.contains(&(account_a, word(5))),
1998 "account A: must walk block 6's chain, not return block 5 or an intermediate",
1999 );
2000 assert!(
2001 result.contains(&(account_b, word(20))),
2002 "account B: must be resolved independently of account A",
2003 );
2004 }
2005
2006 struct CommitAllScreener;
2012
2013 #[async_trait(?Send)]
2014 impl OnNoteReceived for CommitAllScreener {
2015 async fn on_note_received(
2016 &self,
2017 committed_note: CommittedNote,
2018 _public_note: Option<InputNoteRecord>,
2019 ) -> Result<NoteUpdateAction, ClientError> {
2020 Ok(NoteUpdateAction::Commit(committed_note))
2021 }
2022 }
2023
2024 async fn build_chain_with_chained_consume_txs() -> (miden_testing::MockChain, Account, [Note; 3])
2028 {
2029 let sender_id: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
2030 let faucet_id: AccountId = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into().unwrap();
2031
2032 let mut builder = MockChainBuilder::new();
2033 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2034 let account_id = account.id();
2035
2036 let asset = Asset::Fungible(FungibleAsset::new(faucet_id, 100u64).unwrap());
2037 let note1 = builder
2038 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
2039 .unwrap();
2040 let note2 = builder
2041 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
2042 .unwrap();
2043 let note3 = builder
2044 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
2045 .unwrap();
2046
2047 let mut chain = builder.build().unwrap();
2048 chain.prove_next_block().unwrap(); let mut current_account = account.clone();
2052 for note in [¬e1, ¬e2, ¬e3] {
2053 let tx = Box::pin(
2054 chain
2055 .build_transaction(MockTransactionInput::Account(current_account.clone()))
2056 .unauthenticated_input_note(note.clone())
2057 .build()
2058 .unwrap()
2059 .execute(),
2060 )
2061 .await
2062 .unwrap();
2063 current_account.apply_patch(tx.account_patch()).unwrap();
2064 chain.add_pending_executed_transaction(&tx).unwrap();
2065 }
2066
2067 chain.prove_next_block().unwrap(); (chain, account, [note1, note2, note3])
2069 }
2070
2071 #[tokio::test]
2074 async fn sync_state_sets_consumed_tx_order_for_chained_transactions() {
2075 use miden_protocol::note::NoteMetadata;
2076
2077 let (chain, account, [note1, note2, note3]) = build_chain_with_chained_consume_txs().await;
2078
2079 let mock_rpc = MockRpcApi::new(chain);
2080 let state_sync =
2081 StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(CommitAllScreener), None);
2082
2083 let genesis_peaks =
2084 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2085 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2086
2087 let input_notes: Vec<InputNoteRecord> = [¬e1, ¬e2, ¬e3]
2088 .into_iter()
2089 .map(|n| InputNoteRecord::from(n.clone()))
2090 .collect();
2091
2092 let note_tags: BTreeSet<NoteTag> =
2093 input_notes.iter().filter_map(|n| n.metadata().map(NoteMetadata::tag)).collect();
2094
2095 let account_id = account.id();
2096 let sync_input = StateSyncInput {
2097 accounts: vec![AccountHeader::from(account)],
2098 note_tags,
2099 input_notes,
2100 output_notes: vec![],
2101 uncommitted_transactions: vec![],
2102 };
2103
2104 let update = state_sync.sync_state(&mut partial_mmr, sync_input).await.unwrap();
2105
2106 let updated_notes: Vec<_> = update.note_updates().updated_input_notes().collect();
2107
2108 let find_order = |details_commitment| -> Option<u32> {
2109 updated_notes
2110 .iter()
2111 .find(|n| n.inner().details_commitment() == details_commitment)
2112 .and_then(|n| n.consumed_tx_order())
2113 };
2114
2115 assert_eq!(find_order(note1.details_commitment()), Some(0), "note1 should have tx_order 0");
2116 assert_eq!(find_order(note2.details_commitment()), Some(1), "note2 should have tx_order 1");
2117 assert_eq!(find_order(note3.details_commitment()), Some(2), "note3 should have tx_order 2");
2118
2119 for note in &updated_notes {
2122 let record = note.inner();
2123 assert!(record.is_consumed(), "note should be in a consumed state");
2124 assert_eq!(
2125 record.consumer_account(),
2126 Some(account_id),
2127 "externally-consumed notes by a tracked account should have consumer_account set",
2128 );
2129 }
2130 }
2131
2132 #[tokio::test]
2133 async fn sync_state_across_multiple_iterations_with_same_mmr() {
2134 let mock_rpc = MockRpcApi::default();
2136 mock_rpc.advance_blocks(3);
2137 let chain_tip_1 = mock_rpc.get_chain_tip_block_num();
2138
2139 let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
2140
2141 let genesis_peaks =
2143 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2144 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2145 assert_eq!(partial_mmr.forest().num_leaves(), 1);
2146
2147 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
2149
2150 assert_eq!(update.block_num(), chain_tip_1);
2151 let forest_1 = partial_mmr.forest();
2152 assert_eq!(forest_1.num_leaves(), chain_tip_1.as_u32() as usize + 1);
2154
2155 mock_rpc.advance_blocks(2);
2157 let chain_tip_2 = mock_rpc.get_chain_tip_block_num();
2158
2159 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
2160
2161 assert_eq!(update.block_num(), chain_tip_2);
2162 let forest_2 = partial_mmr.forest();
2163 assert!(forest_2 > forest_1);
2164 assert_eq!(forest_2.num_leaves(), chain_tip_2.as_u32() as usize + 1);
2165
2166 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
2168
2169 assert_eq!(update.block_num(), chain_tip_2);
2170 assert_eq!(partial_mmr.forest(), forest_2);
2171 }
2172
2173 async fn build_chain_with_mint_notes(
2176 num_blocks: u64,
2177 ) -> (miden_testing::MockChain, BTreeSet<NoteTag>) {
2178 let mut builder = MockChainBuilder::new();
2179 let faucet = builder
2180 .add_existing_basic_faucet(
2181 miden_testing::Auth::BasicAuth {
2182 auth_scheme: miden_protocol::account::auth::AuthScheme::Falcon512Poseidon2,
2183 },
2184 "TST",
2185 10_000,
2186 None,
2187 )
2188 .unwrap();
2189 let _target = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2190 let mut chain = builder.build().unwrap();
2191
2192 let note_script = CodeBuilder::new()
2197 .compile_note_script("@note_script\npub proc main\n nop\nend")
2198 .unwrap();
2199 let note_recipient = NoteRecipient::new(
2200 Word::from([1u32, 2, 3, 4]),
2201 note_script,
2202 NoteStorage::new(vec![]).unwrap(),
2203 );
2204 let recipient = note_recipient.digest();
2205 let note_details = NoteDetails::new(NoteAssets::new(vec![]).unwrap(), note_recipient);
2208 let mut recipient_args = TransactionArgs::new(AdviceMap::default());
2209 recipient_args.add_output_note_recipient(¬e_details);
2210 let recipient_advice = recipient_args.advice_inputs().clone();
2211
2212 let tag = NoteTag::default();
2213 let mut faucet_account = faucet.clone();
2214 let mut note_tags = BTreeSet::new();
2215
2216 for i in 0..num_blocks {
2217 let amount = 100 + i;
2218 let source_manager = Arc::new(DefaultSourceManager::default());
2219 let mint_asset = FungibleAsset::new(faucet_account.id(), amount).unwrap();
2223 let asset_id_word = mint_asset.id().to_word();
2224 let asset_value_word = mint_asset.to_value_word();
2225 let tx_script_code = format!(
2226 "
2227 @transaction_script
2228 pub proc main
2229 push.{recipient}
2230 push.{note_type}
2231 push.{tag}
2232 push.{asset_value}
2233 push.{asset_id}
2234 call.::miden::standards::faucets::fungible::mint_and_send
2235 dropw dropw dropw dropw
2236 end
2237 ",
2238 recipient = recipient,
2239 note_type = NoteType::Private as u8,
2240 tag = u32::from(tag),
2241 asset_value = asset_value_word,
2242 asset_id = asset_id_word,
2243 );
2244 let tx_script = CodeBuilder::with_source_manager(source_manager.clone())
2245 .compile_tx_script(tx_script_code)
2246 .unwrap();
2247 let tx = Box::pin(
2248 chain
2249 .build_transaction(miden_testing::MockTransactionInput::Account(
2250 faucet_account.clone(),
2251 ))
2252 .extend_advice_inputs(recipient_advice.clone())
2253 .tx_script(tx_script)
2254 .with_source_manager(source_manager)
2255 .build()
2256 .unwrap()
2257 .execute(),
2258 )
2259 .await
2260 .unwrap();
2261
2262 for output_note in tx.output_notes().iter() {
2263 note_tags.insert(output_note.metadata().tag());
2264 }
2265
2266 faucet_account.apply_patch(tx.account_patch()).unwrap();
2267 chain.add_pending_executed_transaction(&tx).unwrap();
2268 chain.prove_next_block().unwrap();
2269 }
2270
2271 (chain, note_tags)
2272 }
2273
2274 #[tokio::test]
2277 async fn observer_relevance_persists_discarded_note_block() {
2278 let (chain, note_tags) = build_chain_with_mint_notes(2).await;
2279 let mock_rpc = MockRpcApi::new(chain);
2280 let chain_tip = mock_rpc.get_chain_tip_block_num();
2281
2282 let genesis_peaks =
2283 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2284 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2285
2286 let state_sync = StateSync::new(Arc::new(mock_rpc), Arc::new(MockScreener), None)
2287 .with_note_observer(Arc::new(AlwaysRelevantObserver));
2288 let mut input = empty();
2289 input.note_tags = note_tags;
2290
2291 let update = state_sync.sync_state(&mut partial_mmr, input).await.unwrap();
2292 let observed_non_tip_block = BlockNumber::from(1u32);
2293
2294 assert!(
2295 update.partial_blockchain_updates().block_headers_to_store(chain_tip).any(
2296 |(header, is_relevant)| {
2297 header.block_num() == observed_non_tip_block && *is_relevant
2298 }
2299 ),
2300 "an observer-relevant block must be staged as relevant"
2301 );
2302 assert!(
2303 partial_mmr.is_tracked(observed_non_tip_block.as_usize()),
2304 "an observer-relevant block must remain tracked in the partial MMR"
2305 );
2306 }
2307
2308 #[tokio::test]
2318 async fn sync_state_tracks_note_blocks_in_mmr() {
2319 let (chain, note_tags) = build_chain_with_mint_notes(3).await;
2320 let mock_rpc = MockRpcApi::new(chain);
2321 let chain_tip = mock_rpc.get_chain_tip_block_num();
2322
2323 let note_blocks = mock_rpc
2325 .sync_notes(BlockNumber::from(0u32), chain_tip, ¬e_tags)
2326 .await
2327 .unwrap();
2328 assert!(
2329 note_blocks.len() >= 2,
2330 "expected notes in multiple blocks, got {}",
2331 note_blocks.len()
2332 );
2333
2334 let note_block_nums: BTreeSet<BlockNumber> =
2336 note_blocks.iter().map(|b| b.block_header.block_num()).collect();
2337
2338 let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
2341
2342 let genesis_peaks =
2343 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2344 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2345
2346 let sync_data = state_sync
2347 .fetch_sync_data(BlockNumber::GENESIS, &[], &Arc::new(note_tags.clone()))
2348 .await
2349 .unwrap()
2350 .expect("should have progressed past genesis");
2351
2352 assert_eq!(sync_data.chain_tip_header.block_num(), chain_tip);
2354 assert!(!sync_data.note_blocks.is_empty(), "should have note blocks");
2355
2356 let _auth_nodes: Vec<(InOrderIndex, Word)> =
2358 partial_mmr.apply(sync_data.mmr_delta).map_err(StoreError::MmrError).unwrap();
2359 partial_mmr
2360 .add(sync_data.chain_tip_header.commitment(), false)
2361 .expect("chain tip should append to the partial MMR");
2362
2363 assert_eq!(partial_mmr.forest().num_leaves(), chain_tip.as_u32() as usize + 1);
2364
2365 for block in &sync_data.note_blocks {
2367 let bn = block.block_header.block_num();
2368 partial_mmr
2369 .track(bn.as_usize(), block.block_header.commitment(), &block.mmr_path)
2370 .map_err(StoreError::MmrError)
2371 .unwrap();
2372
2373 assert!(
2374 partial_mmr.is_tracked(bn.as_usize()),
2375 "block {bn} should be tracked after calling track()"
2376 );
2377 }
2378
2379 for &bn in ¬e_block_nums {
2381 assert!(
2382 partial_mmr.is_tracked(bn.as_usize()),
2383 "block {bn} with notes should be tracked in partial MMR"
2384 );
2385 }
2386 }
2387
2388 #[tokio::test]
2389 async fn sync_notes_with_content_fetches_inclusive_upper_bound_page() {
2390 let (chain, note_tags) = build_chain_with_mint_notes(10).await;
2391 let mock_rpc = MockRpcApi::new(chain);
2392
2393 let blocks = mock_rpc
2394 .sync_notes_with_content(
2395 4_u32.into(),
2396 10_u32.into(),
2397 ¬e_tags,
2398 NoteContentFetch::PublicDetailsAndAttachments,
2399 )
2400 .await
2401 .expect("sync notes should succeed");
2402
2403 assert_eq!(blocks.last().unwrap().block_header.block_num(), BlockNumber::from(10u32));
2404 assert!(
2405 blocks
2406 .iter()
2407 .any(|block| block.block_header.block_num() == BlockNumber::from(9u32))
2408 );
2409 }
2410
2411 #[tokio::test]
2417 async fn erased_notes_are_marked_as_consumed() {
2418 let sender_id: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
2420 let partial_metadata = PartialNoteMetadata::new(sender_id, NoteType::Public);
2421 let metadata = NoteMetadata::new(partial_metadata, &NoteAttachments::empty());
2422 let script = CodeBuilder::new()
2423 .compile_note_script("@note_script\npub proc main\n nop\nend")
2424 .unwrap();
2425 let recipient = NoteRecipient::new(
2426 Word::from([1u32, 2, 3, 4]),
2427 script,
2428 NoteStorage::new(vec![]).unwrap(),
2429 );
2430 let output_note = OutputNoteRecord::new(
2431 recipient.digest(),
2432 NoteAssets::new(vec![]).unwrap(),
2433 metadata,
2434 OutputNoteState::ExpectedFull { recipient },
2435 BlockNumber::from(1u32),
2436 NoteAttachments::default(),
2437 );
2438 let note_id = output_note.id();
2439 let note_header = NoteHeader::new(output_note.details_commitment(), metadata);
2440
2441 let mut note_updates = NoteUpdateTracker::new(vec![], vec![output_note]);
2443
2444 let block_num = BlockNumber::from(3u32);
2446 note_updates
2447 .mark_erased_note_as_consumed(¬e_header, block_num)
2448 .expect("marking erased note should succeed");
2449
2450 let updated = note_updates
2451 .updated_output_notes()
2452 .find(|n| n.id() == note_id)
2453 .expect("output note should be in the update");
2454
2455 assert!(
2456 updated.inner().is_consumed(),
2457 "output note should be consumed after erasure detection, but state is: {}",
2458 updated.inner().state()
2459 );
2460 }
2461
2462 #[allow(clippy::too_many_lines)]
2479 #[ignore = "consumer derivation removed; see comment above"]
2480 #[tokio::test]
2481 async fn erased_notes_are_marked_as_consumed_by_network_account() {
2482 let mut builder = MockChainBuilder::new();
2485 let p2id_sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
2486 let faucet_id: AccountId = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into().unwrap();
2487 let sender_account =
2488 builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2489 let sender_id = sender_account.id();
2490
2491 let asset = Asset::Fungible(FungibleAsset::new(faucet_id, 100u64).unwrap());
2492 let note = builder
2493 .add_p2id_note(p2id_sender, sender_id, &[asset], NoteType::Public)
2494 .unwrap();
2495
2496 let mut chain = builder.build().unwrap();
2497 chain.prove_next_block().unwrap();
2498
2499 let tx = Box::pin(
2500 chain
2501 .build_transaction(MockTransactionInput::Account(sender_account.clone()))
2502 .unauthenticated_input_note(note.clone())
2503 .build()
2504 .unwrap()
2505 .execute(),
2506 )
2507 .await
2508 .unwrap();
2509 chain.add_pending_executed_transaction(&tx).unwrap();
2510 chain.prove_next_block().unwrap();
2511
2512 let network_account_id: AccountId =
2514 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into().unwrap();
2515 let target =
2516 NetworkAccountTarget::new(network_account_id, NoteExecutionHint::Always).unwrap();
2517 let attachment: NoteAttachment = target.into();
2518 let attachments = NoteAttachments::new(vec![attachment]).unwrap();
2519 let partial_metadata = PartialNoteMetadata::new(sender_id, NoteType::Public);
2520 let metadata = NoteMetadata::new(partial_metadata, &attachments);
2521 let script = CodeBuilder::new()
2522 .compile_note_script("@note_script\npub proc main\n nop\nend")
2523 .unwrap();
2524 let recipient = NoteRecipient::new(
2525 Word::from([7u32, 8, 9, 10]),
2526 script,
2527 NoteStorage::new(vec![]).unwrap(),
2528 );
2529 let recipient_digest = recipient.digest();
2530 let assets = NoteAssets::new(vec![]).unwrap();
2531
2532 let output_note = OutputNoteRecord::new(
2535 recipient_digest,
2536 assets.clone(),
2537 metadata,
2538 OutputNoteState::ExpectedFull { recipient },
2539 BlockNumber::from(1u32),
2540 NoteAttachments::default(),
2541 );
2542 let erased_note_id = output_note.id();
2543 let erased_note_header = NoteHeader::new(output_note.details_commitment(), metadata);
2544
2545 let mock_rpc = MockRpcApi::new(chain);
2546 mock_rpc.mark_note_as_erased(erased_note_header);
2547
2548 let network_header =
2551 AccountHeader::new(network_account_id, ZERO, EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
2552
2553 let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
2554
2555 let genesis_peaks =
2556 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2557 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2558
2559 let sync_input = StateSyncInput {
2560 accounts: vec![AccountHeader::from(sender_account), network_header],
2561 note_tags: BTreeSet::new(),
2562 input_notes: vec![],
2563 output_notes: vec![output_note],
2564 uncommitted_transactions: vec![],
2565 };
2566
2567 let update = state_sync.sync_state(&mut partial_mmr, sync_input).await.unwrap();
2568
2569 let updated_output = update
2571 .note_updates()
2572 .updated_output_notes()
2573 .find(|n| n.id() == erased_note_id)
2574 .expect("output note should be in the update");
2575 assert!(
2576 updated_output.inner().is_consumed(),
2577 "output note should be consumed, got: {}",
2578 updated_output.inner().state()
2579 );
2580
2581 let input_note_update = update
2583 .note_updates()
2584 .updated_input_notes()
2585 .find(|n| n.id() == Some(erased_note_id))
2586 .expect("input note should be created from the erased output note");
2587
2588 let inner = input_note_update.inner();
2589 assert!(
2590 inner.is_consumed(),
2591 "input note should be in a consumed state, got: {}",
2592 inner.state()
2593 );
2594 assert_eq!(
2595 inner.consumer_account(),
2596 Some(network_account_id),
2597 "consumer should be the tracked network account"
2598 );
2599 }
2600
2601 #[tokio::test]
2604 async fn validate_chain_mmr_response_rejects_tampered_responses() {
2605 let mock_rpc = MockRpcApi::default();
2606 mock_rpc.advance_blocks(3);
2607 let chain_tip = mock_rpc.get_chain_tip_block_num();
2608 let current = BlockNumber::GENESIS;
2609
2610 let header_of =
2611 |block_num: u32| mock_rpc.mock_chain.read().block_header(block_num as usize);
2612 let chain_mmr_response = || async {
2613 mock_rpc.sync_chain_mmr(current, SyncTarget::CommittedChainTip).await.unwrap()
2614 };
2615
2616 let response = chain_mmr_response().await;
2618 StateSync::validate_chain_mmr_response(&response, current).unwrap();
2619
2620 let mut response = chain_mmr_response().await;
2622 response.block_header = header_of(chain_tip.as_u32() - 1);
2623 let result = StateSync::validate_chain_mmr_response(&response, current);
2624 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2625
2626 let mut response = chain_mmr_response().await;
2628 response.block_from = current + 1;
2629 let result = StateSync::validate_chain_mmr_response(&response, current);
2630 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2631
2632 let mut response = chain_mmr_response().await;
2634 response.block_from = chain_tip;
2635 response.block_to = BlockNumber::GENESIS;
2636 response.block_header = header_of(0);
2637 let result = StateSync::validate_chain_mmr_response(&response, chain_tip);
2638 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2639 }
2640
2641 #[test]
2644 fn validate_note_blocks_range_rejects_out_of_range_blocks() {
2645 let mock_rpc = MockRpcApi::default();
2646 mock_rpc.advance_blocks(3);
2647 let chain_tip = mock_rpc.get_chain_tip_block_num();
2648 let current = BlockNumber::GENESIS;
2649
2650 StateSync::validate_note_blocks_range(&[], current, chain_tip).unwrap();
2652
2653 let genesis_note_block = ResolvedSyncNotesBlock {
2655 block_header: mock_rpc.mock_chain.read().block_header(0),
2656 mmr_path: MerklePath::new(Vec::new()),
2657 notes: BTreeMap::new(),
2658 };
2659 let result =
2660 StateSync::validate_note_blocks_range(&[genesis_note_block], current, chain_tip);
2661 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2662 }
2663
2664 #[test]
2667 fn advance_mmr_rejects_delta_inconsistent_with_chain_commitment() {
2668 let mock_rpc = MockRpcApi::default();
2669 mock_rpc.advance_blocks(3);
2670 let chain_tip = mock_rpc.get_chain_tip_block_num();
2671
2672 let chain_tip_header = mock_rpc.mock_chain.read().block_header(chain_tip.as_usize());
2673 let genesis_partial_mmr = || {
2674 let peaks = mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2675 PartialMmr::from_peaks(peaks)
2676 };
2677
2678 let full_delta = mock_rpc
2680 .get_mmr()
2681 .get_delta(Forest::new(1).unwrap(), Forest::new(chain_tip.as_usize()).unwrap())
2682 .unwrap();
2683 StateSync::advance_mmr(
2684 full_delta,
2685 &chain_tip_header,
2686 &mut genesis_partial_mmr(),
2687 &mut PartialBlockchainUpdates::default(),
2688 )
2689 .unwrap();
2690
2691 let truncated_delta = mock_rpc
2693 .get_mmr()
2694 .get_delta(Forest::new(1).unwrap(), Forest::new(chain_tip.as_usize() - 1).unwrap())
2695 .unwrap();
2696 let result = StateSync::advance_mmr(
2697 truncated_delta,
2698 &chain_tip_header,
2699 &mut genesis_partial_mmr(),
2700 &mut PartialBlockchainUpdates::default(),
2701 );
2702 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2703 }
2704
2705 fn make_tx_record(account_id: AccountId, block_num: u32) -> RpcTransactionRecord {
2707 RpcTransactionRecord {
2708 block_num: BlockNumber::from(block_num),
2709 transaction_header: TransactionHeader::new(
2710 account_id,
2711 word(1),
2712 word(2),
2713 InputNotes::new_unchecked(vec![]),
2714 vec![],
2715 ),
2716 output_notes: vec![],
2717 erased_output_notes: vec![],
2718 consumed_note_refs: vec![],
2719 }
2720 }
2721}