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::{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 AccountStorageMapDetails,
31 GetAccountRequest,
32 StorageMapFetch,
33 VaultFetch,
34};
35use crate::rpc::domain::note::{CommittedNote, FetchedNote, ResolvedSyncNotesBlock, SyncedNote};
36use crate::rpc::domain::sync::{ChainMmrInfo, SyncTarget};
37use crate::rpc::domain::transaction::TransactionRecord as RpcTransactionRecord;
38use crate::rpc::{AccountStateAt, NodeRpcClient, NoteContentFetch, RpcError};
39use crate::store::input_note_states::UnverifiedNoteState;
40use crate::store::{InputNoteRecord, OutputNoteRecord, StoreError};
41use crate::transaction::TransactionRecord;
42
43enum PublicAccountSync {
48 Apply(Box<PublicAccountUpdate>),
50 Superseded,
52 Ignore,
54}
55
56struct FetchedSyncData {
62 mmr_delta: MmrDelta,
64 chain_tip_header: BlockHeader,
66 note_blocks: Vec<ResolvedSyncNotesBlock>,
69 transactions: Vec<RpcTransactionRecord>,
71}
72
73struct RecoverableConsumedNote {
78 nullifier: Nullifier,
79 consumer: AccountId,
80 block_num: BlockNumber,
81}
82
83struct RelevantNoteBlock {
88 block_header: BlockHeader,
89 mmr_path: MerklePath,
90 observer_requires_block: bool,
91}
92
93#[derive(Default)]
95struct NoteBlockRelevance {
96 has_client_note: bool,
97 observer_requires_block: bool,
98}
99
100impl NoteBlockRelevance {
101 fn is_relevant(&self) -> bool {
102 self.has_client_note || self.observer_requires_block
103 }
104}
105
106pub struct StateSyncInput {
122 pub accounts: Vec<AccountHeader>,
124 pub note_tags: BTreeSet<NoteTag>,
126 pub input_notes: Vec<InputNoteRecord>,
128 pub output_notes: Vec<OutputNoteRecord>,
134 pub uncommitted_transactions: Vec<TransactionRecord>,
136}
137
138#[allow(clippy::large_enum_variant)]
143pub enum NoteUpdateAction {
144 Commit(CommittedNote),
147 Insert(InputNoteRecord),
149 Discard,
151}
152
153#[async_trait(?Send)]
154pub trait OnNoteReceived {
155 async fn on_note_received(
166 &self,
167 committed_note: CommittedNote,
168 public_note: Option<InputNoteRecord>,
169 ) -> Result<NoteUpdateAction, ClientError>;
170}
171#[derive(Clone)]
178pub struct StateSync {
179 rpc_api: Arc<dyn NodeRpcClient>,
181 note_screener: Arc<dyn OnNoteReceived>,
184 note_observers: Vec<Arc<dyn NoteObserver>>,
187 tx_discard_delta: Option<u32>,
190 sync_nullifiers: bool,
193}
194
195impl StateSync {
196 pub fn new(
207 rpc_api: Arc<dyn NodeRpcClient>,
208 note_screener: Arc<dyn OnNoteReceived>,
209 tx_discard_delta: Option<u32>,
210 ) -> Self {
211 Self {
212 rpc_api,
213 note_screener,
214 note_observers: Vec::new(),
215 tx_discard_delta,
216 sync_nullifiers: true,
217 }
218 }
219
220 #[must_use]
224 pub fn with_note_observer(mut self, observer: Arc<dyn NoteObserver>) -> Self {
225 self.note_observers.push(observer);
226 self
227 }
228
229 pub fn disable_nullifier_sync(&mut self) {
235 self.sync_nullifiers = false;
236 }
237
238 pub fn enable_nullifier_sync(&mut self) {
240 self.sync_nullifiers = true;
241 }
242
243 pub(crate) async fn run_apply_hooks(
250 &self,
251 state_sync_update: &StateSyncUpdate,
252 ) -> Result<(), ClientError> {
253 for observer in &self.note_observers {
254 crate::errors::log_observer_failure(
255 observer.name(),
256 "NoteObserver::apply",
257 observer.apply(state_sync_update).await,
258 );
259 }
260 Ok(())
261 }
262
263 pub async fn sync_state(
281 &self,
282 current_partial_mmr: &mut PartialMmr,
283 input: StateSyncInput,
284 ) -> Result<StateSyncUpdate, ClientError> {
285 let StateSyncInput {
286 accounts,
287 note_tags,
288 input_notes,
289 output_notes,
290 uncommitted_transactions,
291 } = input;
292 let block_num = u32::try_from(current_partial_mmr.forest().num_leaves().saturating_sub(1))
293 .map_err(|_| ClientError::InvalidPartialMmrForest)?
294 .into();
295
296 let note_tags = Arc::new(note_tags);
297 let account_ids: Vec<AccountId> = accounts.iter().map(AccountHeader::id).collect();
298
299 let mut note_updates = NoteUpdateTracker::new(input_notes, output_notes);
300 let mut transaction_updates = TransactionUpdateTracker::new(uncommitted_transactions);
301 let mut partial_blockchain_updates = PartialBlockchainUpdates::default();
302 let mut account_updates = AccountUpdates::default();
303
304 let Some(sync_data) = self.fetch_sync_data(block_num, &account_ids, ¬e_tags).await?
305 else {
306 return Ok(StateSyncUpdate::from_parts(
308 block_num,
309 partial_blockchain_updates,
310 note_updates,
311 transaction_updates,
312 account_updates,
313 ));
314 };
315
316 let FetchedSyncData {
317 mmr_delta,
318 chain_tip_header,
319 note_blocks,
320 transactions,
321 } = sync_data;
322 let chain_tip = chain_tip_header.block_num();
323
324 let new_commitments = derive_account_commitments(&transactions);
325 let superseded_states = self
326 .account_state_sync(
327 &mut account_updates,
328 &accounts,
329 &new_commitments,
330 block_num,
331 &chain_tip_header,
332 )
333 .await?;
334
335 for superseded_state in superseded_states {
337 transaction_updates.apply_superseded_account_state(superseded_state);
338 }
339
340 let mut working_mmr = current_partial_mmr.clone();
342
343 Self::advance_mmr(
344 mmr_delta,
345 &chain_tip_header,
346 &mut working_mmr,
347 &mut partial_blockchain_updates,
348 )?;
349 let relevant_note_blocks = self.screen_note_blocks(note_blocks, &mut note_updates).await?;
350 self.apply_transactions_and_nullifiers(
351 &chain_tip_header,
352 &transactions,
353 &mut note_updates,
354 &mut transaction_updates,
355 )?;
356
357 if self.sync_nullifiers {
358 self.nullifiers_state_sync(
359 &mut note_updates,
360 &mut transaction_updates,
361 chain_tip,
362 block_num,
363 )
364 .await?;
365 }
366
367 self.recover_consumed_public_notes(&mut note_updates, &transactions).await?;
368
369 let blocks_with_unspent_notes: BTreeSet<BlockNumber> =
370 note_updates.unspent_input_note_block_numbers().collect();
371
372 Self::validate_and_track_note_blocks(
373 relevant_note_blocks,
374 &blocks_with_unspent_notes,
375 &mut working_mmr,
376 &mut partial_blockchain_updates,
377 )?;
378
379 *current_partial_mmr = working_mmr;
380
381 Ok(StateSyncUpdate::from_parts(
382 chain_tip,
383 partial_blockchain_updates,
384 note_updates,
385 transaction_updates,
386 account_updates,
387 ))
388 }
389
390 async fn recover_consumed_public_notes(
395 &self,
396 note_updates: &mut NoteUpdateTracker,
397 transactions: &[RpcTransactionRecord],
398 ) -> Result<(), ClientError> {
399 let mut recoverable_consumed_notes: BTreeMap<NoteId, RecoverableConsumedNote> =
400 BTreeMap::new();
401 for tx in transactions {
402 for (nullifier, note_id) in tx.trusted_consumed_note_refs() {
403 recoverable_consumed_notes.insert(
404 note_id,
405 RecoverableConsumedNote {
406 nullifier,
407 consumer: tx.transaction_header.account_id(),
408 block_num: tx.block_num,
409 },
410 );
411 }
412 }
413 recoverable_consumed_notes.retain(|note_id, _| !note_updates.tracks_note(*note_id));
416
417 let note_ids: Vec<NoteId> = recoverable_consumed_notes.keys().copied().collect();
418 if note_ids.is_empty() {
419 return Ok(());
420 }
421
422 for fetched in self.rpc_api.get_notes_by_id(¬e_ids).await? {
423 match fetched {
424 FetchedNote::Public(note, _) => {
425 let Some(reference) = recoverable_consumed_notes.get(¬e.id()) else {
426 continue;
427 };
428 if note.nullifier() != reference.nullifier {
431 return Err(RpcError::InvalidResponse(format!(
432 "node returned note {} whose nullifier doesn't match the consumed reference",
433 note.id()
434 ))
435 .into());
436 }
437 note_updates.insert_consumed_public_note(
438 note,
439 reference.consumer,
440 reference.block_num,
441 )?;
442 },
443 FetchedNote::Private(note_id, ..) => {
444 return Err(RpcError::InvalidResponse(format!(
445 "node returned private note {note_id} for a public consumed-note reference"
446 ))
447 .into());
448 },
449 }
450 }
451
452 Ok(())
453 }
454
455 async fn fetch_sync_data(
465 &self,
466 current_block_num: BlockNumber,
467 account_ids: &[AccountId],
468 note_tags: &Arc<BTreeSet<NoteTag>>,
469 ) -> Result<Option<FetchedSyncData>, ClientError> {
470 let chain_mmr_info = self
472 .rpc_api
473 .sync_chain_mmr(current_block_num, SyncTarget::CommittedChainTip)
474 .await?;
475 let chain_tip = chain_mmr_info.block_to;
476
477 Self::validate_chain_mmr_response(&chain_mmr_info, current_block_num)?;
479
480 if chain_tip == current_block_num {
482 info!(block_num = %current_block_num, "Already at chain tip, nothing to sync.");
483 return Ok(None);
484 }
485
486 info!(
487 block_from = %current_block_num,
488 block_to = %chain_tip,
489 "Syncing state.",
490 );
491
492 let note_blocks = if note_tags.is_empty() {
497 Vec::new()
498 } else {
499 self.rpc_api
500 .sync_notes_with_content(
501 current_block_num + 1,
502 chain_tip,
503 note_tags.as_ref(),
504 NoteContentFetch::PublicDetailsAndAttachments,
505 )
506 .await?
507 };
508
509 Self::validate_note_blocks_range(¬e_blocks, current_block_num, chain_tip)?;
511
512 let note_count: usize = note_blocks.iter().map(|b| b.notes.len()).sum();
513 info!(
514 blocks_with_notes = note_blocks.len(),
515 notes = note_count,
516 "Fetched note sync data.",
517 );
518
519 let transaction_records = if account_ids.is_empty() {
522 Vec::new()
523 } else {
524 self.rpc_api
525 .sync_transactions(current_block_num + 1, chain_tip, account_ids.to_vec())
526 .await?
527 };
528
529 Self::validate_transaction_records_range(
530 &transaction_records,
531 current_block_num,
532 chain_tip,
533 )?;
534
535 Ok(Some(FetchedSyncData {
536 mmr_delta: chain_mmr_info.mmr_delta,
537 chain_tip_header: chain_mmr_info.block_header,
538 note_blocks,
539 transactions: transaction_records,
540 }))
541 }
542
543 fn validate_chain_mmr_response(
548 chain_mmr_info: &ChainMmrInfo,
549 current_block_num: BlockNumber,
550 ) -> Result<(), ClientError> {
551 if chain_mmr_info.block_header.block_num() != chain_mmr_info.block_to {
552 return Err(ClientError::ChainValidationError(format!(
553 "sync_chain_mmr block_header.block_num ({}) does not match block_to ({})",
554 chain_mmr_info.block_header.block_num(),
555 chain_mmr_info.block_to
556 )));
557 }
558 if chain_mmr_info.block_from != current_block_num {
559 return Err(ClientError::ChainValidationError(format!(
560 "sync_chain_mmr block_from mismatch: expected {current_block_num}, got {}",
561 chain_mmr_info.block_from
562 )));
563 }
564 if chain_mmr_info.block_to < current_block_num {
565 return Err(ClientError::ChainValidationError(format!(
566 "sync_chain_mmr block_to ({}) is behind current block {current_block_num}",
567 chain_mmr_info.block_to
568 )));
569 }
570 Ok(())
571 }
572
573 fn validate_note_blocks_range(
576 note_blocks: &[ResolvedSyncNotesBlock],
577 current_block_num: BlockNumber,
578 chain_tip: BlockNumber,
579 ) -> Result<(), ClientError> {
580 for block in note_blocks {
581 let block_num = block.block_header.block_num();
582 if block_num <= current_block_num || block_num > chain_tip {
583 return Err(ClientError::ChainValidationError(format!(
584 "sync_notes returned block {block_num} outside requested range ({current_block_num}, {chain_tip}]"
585 )));
586 }
587 }
588 Ok(())
589 }
590
591 fn validate_transaction_records_range(
594 records: &[RpcTransactionRecord],
595 current_block_num: BlockNumber,
596 chain_tip: BlockNumber,
597 ) -> Result<(), ClientError> {
598 for record in records {
599 let block_num = record.block_num;
600 if block_num <= current_block_num || block_num > chain_tip {
601 return Err(ClientError::ChainValidationError(format!(
602 "sync_transactions returned block {block_num} outside requested range ({current_block_num}, {chain_tip}]"
603 )));
604 }
605 }
606 Ok(())
607 }
608
609 fn advance_mmr(
616 mmr_delta: MmrDelta,
617 chain_tip_header: &BlockHeader,
618 current_partial_mmr: &mut PartialMmr,
619 partial_blockchain_updates: &mut PartialBlockchainUpdates,
620 ) -> Result<(), ClientError> {
621 let mut new_authentication_nodes =
622 current_partial_mmr.apply(mmr_delta).map_err(StoreError::MmrError)?;
623 let new_peaks = current_partial_mmr.peaks();
624
625 let peaks_commitment = new_peaks.hash_peaks();
629 if peaks_commitment != chain_tip_header.chain_commitment() {
630 return Err(ClientError::ChainValidationError(format!(
631 "MMR peaks commitment is {} and does not match block header chain commitment {}",
632 peaks_commitment.to_hex(),
633 chain_tip_header.chain_commitment().to_hex()
634 )));
635 }
636
637 partial_blockchain_updates.new_peaks = new_peaks;
638
639 new_authentication_nodes.append(
644 &mut current_partial_mmr
645 .add(chain_tip_header.commitment(), false)
646 .map_err(StoreError::MmrError)?,
647 );
648
649 partial_blockchain_updates.insert(chain_tip_header.clone(), false);
650 partial_blockchain_updates.extend_authentication_nodes(new_authentication_nodes);
651
652 Ok(())
653 }
654
655 async fn screen_note_blocks(
663 &self,
664 note_blocks: Vec<ResolvedSyncNotesBlock>,
665 note_updates: &mut NoteUpdateTracker,
666 ) -> Result<Vec<RelevantNoteBlock>, ClientError> {
667 let mut relevant_blocks = Vec::new();
668
669 for block in note_blocks {
670 let relevance =
671 self.note_state_sync(note_updates, block.notes, &block.block_header).await?;
672
673 if relevance.is_relevant() {
674 relevant_blocks.push(RelevantNoteBlock {
675 block_header: block.block_header,
676 mmr_path: block.mmr_path,
677 observer_requires_block: relevance.observer_requires_block,
678 });
679 }
680 }
681
682 Ok(relevant_blocks)
683 }
684
685 fn validate_and_track_note_blocks(
695 relevant_blocks: Vec<RelevantNoteBlock>,
696 blocks_with_unspent_notes: &BTreeSet<BlockNumber>,
697 partial_mmr: &mut PartialMmr,
698 partial_blockchain_updates: &mut PartialBlockchainUpdates,
699 ) -> Result<(), ClientError> {
700 let nodes_before: BTreeSet<InOrderIndex> = partial_mmr.nodes().map(|(k, _)| *k).collect();
701
702 for RelevantNoteBlock {
703 block_header,
704 mmr_path,
705 observer_requires_block,
706 } in relevant_blocks
707 {
708 let block_pos = block_header.block_num().as_usize();
709 let was_tracked = partial_mmr.is_tracked(block_pos);
710
711 partial_mmr
714 .track(block_pos, block_header.commitment(), &mmr_path)
715 .map_err(StoreError::MmrError)?;
716
717 if observer_requires_block
718 || blocks_with_unspent_notes.contains(&block_header.block_num())
719 {
720 partial_blockchain_updates.insert(block_header, true);
721 } else if !was_tracked {
722 partial_mmr.untrack(block_pos);
723 }
724 }
725
726 partial_blockchain_updates.extend_authentication_nodes(
728 partial_mmr
729 .nodes()
730 .filter(|(index, _)| !nodes_before.contains(index))
731 .map(|(index, value)| (*index, *value)),
732 );
733
734 Ok(())
735 }
736
737 fn apply_transactions_and_nullifiers(
741 &self,
742 chain_tip_header: &BlockHeader,
743 transactions: &[RpcTransactionRecord],
744 note_updates: &mut NoteUpdateTracker,
745 transaction_updates: &mut TransactionUpdateTracker,
746 ) -> Result<(), ClientError> {
747 note_updates.extend_nullifiers(compute_ordered_nullifiers(transactions));
748
749 for record in transactions {
750 transaction_updates
751 .apply_transaction_inclusion(record, u64::from(chain_tip_header.timestamp())); }
753 transaction_updates
754 .apply_sync_height_update(chain_tip_header.block_num(), self.tx_discard_delta);
755
756 for transaction in transactions {
757 note_updates.apply_output_note_inclusion_proofs(&transaction.output_notes)?;
761
762 Self::mark_erased_notes_as_consumed(note_updates, transaction);
764 }
765
766 Ok(())
767 }
768
769 fn mark_erased_notes_as_consumed(
775 note_updates: &mut NoteUpdateTracker,
776 transaction: &RpcTransactionRecord,
777 ) {
778 for note_header in &transaction.erased_output_notes {
779 let _ = note_updates.mark_erased_note_as_consumed(note_header, transaction.block_num);
781 }
782 }
783
784 async fn account_state_sync(
797 &self,
798 account_updates: &mut AccountUpdates,
799 accounts: &[AccountHeader],
800 account_commitment_updates: &[(AccountId, Word)],
801 block_from: BlockNumber,
802 chain_tip_header: &BlockHeader,
803 ) -> Result<Vec<Word>, ClientError> {
804 let (public_accounts, private_accounts): (Vec<_>, Vec<_>) =
807 accounts.iter().partition(|header| !header.id().is_private());
808
809 let superseded_states = self
810 .sync_public_accounts(
811 account_updates,
812 account_commitment_updates,
813 &public_accounts,
814 block_from,
815 chain_tip_header,
816 )
817 .await?;
818
819 let mut mismatched_private_accounts = Vec::new();
822 for header in &private_accounts {
823 let account_id = header.id();
824 let local_commitment = header.to_commitment();
825 let record_diverges = account_commitment_updates
826 .iter()
827 .any(|(id, digest)| *id == account_id && *digest != local_commitment);
828 if !record_diverges {
829 continue;
830 }
831
832 if let Some(proven_commitment) = self
833 .verify_private_account_mismatch(account_id, local_commitment, chain_tip_header)
834 .await?
835 {
836 mismatched_private_accounts.push((account_id, proven_commitment));
837 }
838 }
839
840 account_updates.extend(AccountUpdates::new(Vec::new(), mismatched_private_accounts));
841
842 Ok(superseded_states)
843 }
844
845 async fn verify_private_account_mismatch(
856 &self,
857 account_id: AccountId,
858 local_commitment: Word,
859 chain_tip_header: &BlockHeader,
860 ) -> Result<Option<Word>, ClientError> {
861 let chain_tip = chain_tip_header.block_num();
862 let (proof_block_num, proof) = self
863 .rpc_api
864 .get_account(account_id, GetAccountRequest::new().at(AccountStateAt::Block(chain_tip)))
865 .await?;
866
867 if proof_block_num != chain_tip {
868 return Err(ClientError::ChainValidationError(format!(
869 "get_account returned a proof at block {proof_block_num}, expected chain tip {chain_tip}"
870 )));
871 }
872
873 let (witness, _) = proof.into_parts();
874 let witness_id = witness.id();
875 let proven_commitment = witness.state_commitment();
876 if witness.into_proof().compute_root() != chain_tip_header.account_root() {
879 return Err(ClientError::ChainValidationError(format!(
880 "account witness for {account_id} does not verify against the chain tip account root"
881 )));
882 }
883
884 if witness_id != account_id
887 || proven_commitment == Word::empty()
888 || proven_commitment == local_commitment
889 {
890 return Ok(None);
891 }
892
893 Ok(Some(proven_commitment))
894 }
895
896 async fn sync_public_accounts(
905 &self,
906 account_updates: &mut AccountUpdates,
907 commitment_updates: &[(AccountId, Word)],
908 current_public_accounts: &[&AccountHeader],
909 block_from: BlockNumber,
910 chain_tip_header: &BlockHeader,
911 ) -> Result<Vec<Word>, ClientError> {
912 let local_headers: BTreeMap<AccountId, &AccountHeader> =
913 current_public_accounts.iter().map(|header| (header.id(), *header)).collect();
914 let mut superseded_states = Vec::new();
916 for (id, commitment) in commitment_updates {
917 let Some(local_header) = local_headers.get(id).copied() else {
918 continue;
919 };
920
921 if local_header.to_commitment() == *commitment {
922 continue;
923 }
924
925 match self
926 .sync_public_account(*id, local_header, block_from, chain_tip_header)
927 .await?
928 {
929 PublicAccountSync::Apply(public_update) => {
930 account_updates.extend(AccountUpdates::new(vec![*public_update], Vec::new()));
931 },
932 PublicAccountSync::Superseded => {
933 superseded_states.push(local_header.to_commitment());
934 },
935 PublicAccountSync::Ignore => {},
936 }
937 }
938
939 Ok(superseded_states)
940 }
941
942 async fn sync_public_account(
956 &self,
957 account_id: AccountId,
958 local_header: &AccountHeader,
959 block_from: BlockNumber,
960 chain_tip_header: &BlockHeader,
961 ) -> Result<PublicAccountSync, ClientError> {
962 let target_block_num = chain_tip_header.block_num();
963
964 let (proof_block_num, proof) = self
967 .rpc_api
968 .get_account(
969 account_id,
970 GetAccountRequest::new()
971 .at(AccountStateAt::Block(target_block_num))
972 .with_storage(StorageMapFetch::All)
973 .with_vault(VaultFetch::Always),
974 )
975 .await
976 .map_err(ClientError::RpcError)?;
977
978 let details =
979 Self::validate_account_proof(proof, proof_block_num, account_id, chain_tip_header)?;
980
981 match details
982 .header
983 .nonce()
984 .as_canonical_u64()
985 .cmp(&local_header.nonce().as_canonical_u64())
986 {
987 Ordering::Less => return Ok(PublicAccountSync::Ignore),
990 Ordering::Equal => return Ok(PublicAccountSync::Superseded),
992 Ordering::Greater => {},
994 }
995
996 let vault_oversized = details.vault_details.too_many_assets;
997 let any_map_oversized = details
998 .storage_details
999 .map_details
1000 .iter()
1001 .any(AccountStorageMapDetails::is_limit_exceeded);
1002
1003 let public_update = if vault_oversized || any_map_oversized {
1007 self.build_patch_update(account_id, &details, block_from, proof_block_num)
1009 .await?
1010 } else {
1011 let account = Account::try_from(&details).map_err(ClientError::RpcError)?;
1013 PublicAccountUpdate::Full(account)
1014 };
1015
1016 Ok(PublicAccountSync::Apply(Box::new(public_update)))
1017 }
1018
1019 fn validate_account_proof(
1035 proof: AccountProof,
1036 proof_block_num: BlockNumber,
1037 account_id: AccountId,
1038 chain_tip_header: &BlockHeader,
1039 ) -> Result<AccountDetails, ClientError> {
1040 let target_block_num = chain_tip_header.block_num();
1041
1042 if proof_block_num != target_block_num {
1043 return Err(ClientError::ChainValidationError(format!(
1044 "get_account returned block {proof_block_num} but {target_block_num} was requested"
1045 )));
1046 }
1047
1048 let (witness, details) = proof.into_parts();
1049
1050 if witness.id() != account_id {
1052 return Err(ClientError::ChainValidationError(format!(
1053 "get_account returned account {} but {account_id} was requested",
1054 witness.id()
1055 )));
1056 }
1057
1058 let account_key = AccountIdKey::from(account_id).as_word();
1059 let state_commitment = witness.state_commitment();
1060 witness
1061 .into_proof()
1062 .verify_presence(&account_key, &state_commitment, &chain_tip_header.account_root())
1063 .map_err(|err| {
1064 ClientError::ChainValidationError(format!(
1065 "get_account witness for account {account_id} does not open under block \
1066 {target_block_num} account root: {err}"
1067 ))
1068 })?;
1069
1070 Ok(details.expect("node returned no details for a public account"))
1071 }
1072
1073 async fn build_patch_update(
1076 &self,
1077 account_id: AccountId,
1078 details: &AccountDetails,
1079 block_from: BlockNumber,
1080 block_to: BlockNumber,
1081 ) -> Result<PublicAccountUpdate, ClientError> {
1082 let value_slot_updates: Vec<(_, Word)> = details
1083 .storage_details
1084 .header
1085 .slots()
1086 .filter(|slot| slot.slot_type() == StorageSlotType::Value)
1087 .map(|slot| (slot.name().clone(), slot.value()))
1088 .collect();
1089
1090 let map_info = self
1093 .rpc_api
1094 .sync_storage_maps(block_from + 1, block_to, account_id)
1095 .await
1096 .map_err(ClientError::RpcError)?;
1097 let vault_info = self
1098 .rpc_api
1099 .sync_account_vault(block_from + 1, block_to, account_id)
1100 .await
1101 .map_err(ClientError::RpcError)?;
1102
1103 let patch = build_account_patch(
1104 &details.header,
1105 value_slot_updates,
1106 map_info.map_entries,
1107 vault_info.vault_patch,
1108 details.code.clone(),
1109 )
1110 .map_err(StoreError::AccountPatchError)?;
1111
1112 Ok(PublicAccountUpdate::Patch {
1113 new_header: details.header.clone(),
1114 patch,
1115 })
1116 }
1117
1118 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, details, attachments }) in notes {
1144 let public_note = details.map(|details| {
1147 let state = UnverifiedNoteState {
1148 metadata: *committed.metadata(),
1149 inclusion_proof: committed.inclusion_proof().clone(),
1150 }
1151 .into();
1152 InputNoteRecord::new(details, attachments.clone(), None, state)
1153 });
1154
1155 if !self.note_observers.is_empty() {
1159 for obs in &self.note_observers {
1160 match obs.observe(&committed, &attachments).await {
1161 Ok(true) => relevance.observer_requires_block = true,
1162 Ok(false) => {},
1163 Err(err) => {
1164 tracing::warn!(
1165 observer = obs.name(),
1166 error = ?err,
1167 "note observer failed; sync continues",
1168 );
1169 },
1170 }
1171 }
1172 }
1173
1174 match self.note_screener.on_note_received(committed, public_note).await? {
1175 NoteUpdateAction::Commit(committed_note) => {
1176 relevance.has_client_note |= note_updates
1180 .apply_committed_note_state_transitions(
1181 &committed_note,
1182 block_header,
1183 &attachments,
1184 )?;
1185 },
1186 NoteUpdateAction::Insert(public_note) => {
1187 relevance.has_client_note = true;
1188
1189 note_updates.apply_new_public_note(public_note, block_header)?;
1190 },
1191 NoteUpdateAction::Discard => {},
1192 }
1193 }
1194
1195 Ok(relevance)
1196 }
1197
1198 async fn nullifiers_state_sync(
1205 &self,
1206 note_updates: &mut NoteUpdateTracker,
1207 transaction_updates: &mut TransactionUpdateTracker,
1208 chain_tip: BlockNumber,
1209 current_block_num: BlockNumber,
1210 ) -> Result<(), ClientError> {
1211 let nullifiers_tags: Vec<u16> =
1217 note_updates.unspent_nullifiers().map(|nullifier| nullifier.prefix()).collect();
1218
1219 let mut new_nullifiers = self
1220 .rpc_api
1221 .sync_nullifiers(&nullifiers_tags, current_block_num + 1, chain_tip)
1222 .await?;
1223
1224 new_nullifiers.retain(|update| update.block_num <= chain_tip);
1227
1228 let consumptions: Vec<NoteConsumption> = new_nullifiers
1230 .into_iter()
1231 .map(|update| NoteConsumption {
1232 external_consumer: transaction_updates
1233 .external_nullifier_account(&update.nullifier),
1234 nullifier: update.nullifier,
1235 block_num: update.block_num,
1236 })
1237 .collect();
1238
1239 for consumption in consumptions {
1240 note_updates.apply_note_consumption(
1241 &consumption,
1242 transaction_updates.committed_transactions(),
1243 )?;
1244
1245 transaction_updates.apply_input_note_nullified(consumption.nullifier);
1249 }
1250
1251 Ok(())
1252 }
1253}
1254
1255fn group_txs_by_account_block(
1260 transaction_records: &[RpcTransactionRecord],
1261) -> BTreeMap<(AccountId, BlockNumber), Vec<&RpcTransactionRecord>> {
1262 let mut groups: BTreeMap<(AccountId, BlockNumber), Vec<&RpcTransactionRecord>> =
1263 BTreeMap::new();
1264 for record in transaction_records {
1265 let account_id = record.transaction_header.account_id();
1266 groups.entry((account_id, record.block_num)).or_default().push(record);
1267 }
1268 groups
1269}
1270
1271fn walk_execution_chain<'a>(
1277 txs: &'a [&'a RpcTransactionRecord],
1278) -> impl Iterator<Item = &'a RpcTransactionRecord> + 'a {
1279 let (self_loops, chained): (Vec<&RpcTransactionRecord>, Vec<&RpcTransactionRecord>) =
1280 txs.iter().copied().partition(|tx| {
1281 tx.transaction_header.initial_state_commitment()
1282 == tx.transaction_header.final_state_commitment()
1283 });
1284
1285 let final_states: BTreeSet<Word> = chained
1286 .iter()
1287 .map(|tx| tx.transaction_header.final_state_commitment())
1288 .collect();
1289
1290 let mut init_to_tx: BTreeMap<Word, &RpcTransactionRecord> = chained
1291 .iter()
1292 .map(|tx| (tx.transaction_header.initial_state_commitment(), *tx))
1293 .collect();
1294
1295 let start = chained
1296 .iter()
1297 .find(|tx| !final_states.contains(&tx.transaction_header.initial_state_commitment()))
1298 .copied();
1299
1300 assert!(start.is_some() || chained.is_empty(), "cannot walk cyclic execution chain");
1301
1302 let mut current =
1303 start.and_then(|tx| init_to_tx.remove(&tx.transaction_header.initial_state_commitment()));
1304 let mut self_loops_iter = self_loops.into_iter();
1305
1306 core::iter::from_fn(move || {
1307 if let Some(tx) = current {
1308 current = init_to_tx.remove(&tx.transaction_header.final_state_commitment());
1309 return Some(tx);
1310 }
1311 self_loops_iter.next()
1312 })
1313}
1314
1315fn derive_account_commitments(
1320 transaction_records: &[RpcTransactionRecord],
1321) -> Vec<(AccountId, Word)> {
1322 let mut latest_by_account: BTreeMap<AccountId, (BlockNumber, Word)> = BTreeMap::new();
1323
1324 for ((account_id, block_num), txs) in &group_txs_by_account_block(transaction_records) {
1325 let terminal_state = walk_execution_chain(txs)
1326 .last()
1327 .expect("account must have a final state")
1328 .transaction_header
1329 .final_state_commitment();
1330
1331 latest_by_account
1332 .entry(*account_id)
1333 .and_modify(|(existing_block, existing_state)| {
1334 if *block_num > *existing_block {
1335 *existing_block = *block_num;
1336 *existing_state = terminal_state;
1337 }
1338 })
1339 .or_insert((*block_num, terminal_state));
1340 }
1341
1342 latest_by_account
1343 .into_iter()
1344 .map(|(account_id, (_, state))| (account_id, state))
1345 .collect()
1346}
1347
1348fn compute_ordered_nullifiers(transaction_records: &[RpcTransactionRecord]) -> Vec<Nullifier> {
1355 let mut result = Vec::new();
1356
1357 for txs in group_txs_by_account_block(transaction_records).values() {
1358 for tx in walk_execution_chain(txs) {
1359 for commitment in tx.transaction_header.input_notes().iter() {
1360 result.push(commitment.nullifier());
1361 }
1362 }
1363 }
1364
1365 result
1366}
1367
1368#[cfg(all(test, feature = "testing"))]
1369mod tests {
1370 use alloc::collections::BTreeSet;
1371 use alloc::sync::Arc;
1372
1373 use async_trait::async_trait;
1374 use miden_protocol::account::Account;
1375 use miden_protocol::assembly::DefaultSourceManager;
1376 use miden_protocol::asset::{Asset, FungibleAsset};
1377 use miden_protocol::block::BlockNumber;
1378 use miden_protocol::crypto::merkle::MerklePath;
1379 use miden_protocol::crypto::merkle::mmr::{Forest, InOrderIndex, PartialMmr};
1380 use miden_protocol::note::{
1381 Note,
1382 NoteAssets,
1383 NoteAttachment,
1384 NoteAttachments,
1385 NoteDetails,
1386 NoteHeader,
1387 NoteMetadata,
1388 NoteRecipient,
1389 NoteStorage,
1390 NoteTag,
1391 NoteType,
1392 PartialNoteMetadata,
1393 };
1394 use miden_protocol::testing::account_id::{
1395 ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET,
1396 ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
1397 ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE,
1398 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
1399 ACCOUNT_ID_SENDER,
1400 };
1401 use miden_protocol::transaction::{InputNotes, TransactionArgs, TransactionHeader};
1402 use miden_protocol::vm::AdviceMap;
1403 use miden_protocol::{EMPTY_WORD, Felt, Word, ZERO};
1404 use miden_standards::code_builder::CodeBuilder;
1405 use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint};
1406 use miden_testing::{MockChainBuilder, MockTransactionInput};
1407
1408 use super::*;
1409 use crate::store::{OutputNoteRecord, OutputNoteState};
1410 use crate::test_utils::mock::MockRpcApi;
1411
1412 struct MockScreener;
1414
1415 #[async_trait(?Send)]
1416 impl OnNoteReceived for MockScreener {
1417 async fn on_note_received(
1418 &self,
1419 _committed_note: CommittedNote,
1420 _public_note: Option<InputNoteRecord>,
1421 ) -> Result<NoteUpdateAction, ClientError> {
1422 Ok(NoteUpdateAction::Discard)
1423 }
1424 }
1425
1426 struct AlwaysRelevantObserver;
1428
1429 #[async_trait(?Send)]
1430 impl NoteObserver for AlwaysRelevantObserver {
1431 fn name(&self) -> &'static str {
1432 "always-relevant"
1433 }
1434
1435 async fn observe(
1436 &self,
1437 _committed_note: &CommittedNote,
1438 _attachments: &NoteAttachments,
1439 ) -> Result<bool, ClientError> {
1440 Ok(true)
1441 }
1442 }
1443
1444 fn empty() -> StateSyncInput {
1445 StateSyncInput {
1446 accounts: vec![],
1447 note_tags: BTreeSet::new(),
1448 input_notes: vec![],
1449 output_notes: vec![],
1450 uncommitted_transactions: vec![],
1451 }
1452 }
1453
1454 fn word(n: u64) -> miden_protocol::Word {
1455 [
1456 Felt::new(n).expect("test value should fit into the base field"),
1457 ZERO,
1458 ZERO,
1459 ZERO,
1460 ]
1461 .into()
1462 }
1463
1464 fn header_with_account_root(header: &BlockHeader, account_root: Word) -> BlockHeader {
1465 BlockHeader::new(
1466 header.version(),
1467 header.prev_block_commitment(),
1468 header.block_num(),
1469 header.chain_commitment(),
1470 account_root,
1471 header.nullifier_root(),
1472 header.note_root(),
1473 header.tx_commitment(),
1474 header.tx_kernel_commitment(),
1475 header.validator_keys().clone(),
1476 header.fee_parameters().clone(),
1477 header.timestamp(),
1478 )
1479 }
1480
1481 #[tokio::test]
1482 async fn sync_public_accounts_ignores_older_node_snapshot() {
1483 let mut builder = MockChainBuilder::new();
1484 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1485 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1486 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1487 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1488
1489 let local_header =
1492 AccountHeader::new(account.id(), Felt::from(2u32), EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
1493 let current_public_accounts = vec![&local_header];
1494 let commitment_updates = vec![(account.id(), account.to_commitment())];
1495 let mut account_updates = AccountUpdates::default();
1496
1497 let superseded = state_sync
1498 .sync_public_accounts(
1499 &mut account_updates,
1500 &commitment_updates,
1501 ¤t_public_accounts,
1502 BlockNumber::GENESIS,
1503 &chain_tip_header,
1504 )
1505 .await
1506 .unwrap();
1507
1508 assert!(
1509 account_updates.updated_public_accounts().is_empty(),
1510 "public account sync should ignore node snapshots that are older than local"
1511 );
1512 assert!(
1513 superseded.is_empty(),
1514 "an older node snapshot must not supersede the local state"
1515 );
1516 }
1517
1518 #[tokio::test]
1519 async fn sync_public_accounts_marks_same_nonce_mismatch_as_superseded() {
1520 let mut builder = MockChainBuilder::new();
1521 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1522 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1523 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1524 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1525
1526 let local_header =
1529 AccountHeader::new(account.id(), account.nonce(), EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
1530 let current_public_accounts = vec![&local_header];
1531 let commitment_updates = vec![(account.id(), account.to_commitment())];
1532 let mut account_updates = AccountUpdates::default();
1533
1534 let superseded = state_sync
1535 .sync_public_accounts(
1536 &mut account_updates,
1537 &commitment_updates,
1538 ¤t_public_accounts,
1539 BlockNumber::GENESIS,
1540 &chain_tip_header,
1541 )
1542 .await
1543 .unwrap();
1544
1545 assert!(
1546 account_updates.updated_public_accounts().is_empty(),
1547 "a same-nonce fork must not overwrite the account while its tx is still pending"
1548 );
1549 assert_eq!(
1550 superseded,
1551 vec![local_header.to_commitment()],
1552 "the superseded local state should be reported so its transaction is discarded"
1553 );
1554 }
1555
1556 #[test]
1562 fn validate_transaction_records_range_rejects_out_of_range_blocks() {
1563 let account_id: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap();
1564 let current = BlockNumber::from(5u32);
1565 let chain_tip = BlockNumber::from(10u32);
1566
1567 StateSync::validate_transaction_records_range(
1568 &[make_tx_record(account_id, 7)],
1569 current,
1570 chain_tip,
1571 )
1572 .unwrap();
1573
1574 let result = StateSync::validate_transaction_records_range(
1575 &[make_tx_record(account_id, 11)],
1576 current,
1577 chain_tip,
1578 );
1579 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1580
1581 let result = StateSync::validate_transaction_records_range(
1582 &[make_tx_record(account_id, 5)],
1583 current,
1584 chain_tip,
1585 );
1586 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1587 }
1588
1589 #[tokio::test]
1592 async fn verify_private_account_mismatch_ignores_forged_commitment() {
1593 let mut builder = MockChainBuilder::new();
1594 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1595 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1596 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1597 let on_chain_commitment = account.to_commitment();
1598 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1599
1600 let result = state_sync
1601 .verify_private_account_mismatch(account.id(), on_chain_commitment, &chain_tip_header)
1602 .await
1603 .unwrap();
1604
1605 assert!(
1606 result.is_none(),
1607 "an unproven commitment must not lock an account whose on-chain state matches local"
1608 );
1609 }
1610
1611 #[tokio::test]
1614 async fn verify_private_account_mismatch_reports_proven_divergence() {
1615 let mut builder = MockChainBuilder::new();
1616 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1617 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1618 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1619 let on_chain_commitment = account.to_commitment();
1620 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1621 let stale_local_commitment = word(0xdead_beef);
1622
1623 let result = state_sync
1624 .verify_private_account_mismatch(
1625 account.id(),
1626 stale_local_commitment,
1627 &chain_tip_header,
1628 )
1629 .await
1630 .unwrap();
1631
1632 assert_eq!(
1633 result,
1634 Some(on_chain_commitment),
1635 "a proven divergence should return the proven commitment to lock with"
1636 );
1637 }
1638
1639 #[tokio::test]
1642 async fn verify_private_account_mismatch_rejects_unverifiable_proof() {
1643 let mut builder = MockChainBuilder::new();
1644 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1645 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1646 let real_header = rpc_api.mock_chain.read().latest_block_header();
1647 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1648
1649 let tampered_header = BlockHeader::new(
1652 real_header.version(),
1653 real_header.prev_block_commitment(),
1654 real_header.block_num(),
1655 real_header.chain_commitment(),
1656 word(0xbad0_bad0),
1657 real_header.nullifier_root(),
1658 real_header.note_root(),
1659 real_header.tx_commitment(),
1660 real_header.tx_kernel_commitment(),
1661 real_header.validator_keys().clone(),
1662 real_header.fee_parameters().clone(),
1663 real_header.timestamp(),
1664 );
1665
1666 let result = state_sync
1667 .verify_private_account_mismatch(
1668 account.id(),
1669 account.to_commitment(),
1670 &tampered_header,
1671 )
1672 .await;
1673 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1674 }
1675
1676 #[tokio::test]
1679 async fn sync_public_accounts_pins_account_fetch_to_sync_target() {
1680 let mut builder = MockChainBuilder::new();
1681 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1682 let mut chain = builder.build().unwrap();
1683
1684 let sync_target_header = chain.latest_block_header();
1686 let tx = Box::pin(
1687 chain
1688 .build_transaction(MockTransactionInput::AccountId(account.id()))
1689 .build()
1690 .unwrap()
1691 .execute(),
1692 )
1693 .await
1694 .unwrap();
1695 let local_header = tx.final_account().clone();
1696 assert_ne!(local_header.to_commitment(), account.to_commitment());
1697 chain.add_pending_executed_transaction(&tx).unwrap();
1698
1699 let rpc_api = MockRpcApi::new(chain);
1700 rpc_api.prove_block();
1702 assert_eq!(
1703 rpc_api
1704 .mock_chain
1705 .read()
1706 .committed_account(account.id())
1707 .unwrap()
1708 .to_commitment(),
1709 local_header.to_commitment()
1710 );
1711 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1712
1713 let current_public_accounts = vec![&local_header];
1714 let commitment_updates = vec![(account.id(), account.to_commitment())];
1715 let mut account_updates = AccountUpdates::default();
1716
1717 let superseded = state_sync
1718 .sync_public_accounts(
1719 &mut account_updates,
1720 &commitment_updates,
1721 ¤t_public_accounts,
1722 BlockNumber::GENESIS,
1723 &sync_target_header,
1724 )
1725 .await
1726 .unwrap();
1727
1728 assert!(superseded.is_empty(), "the transaction must not be superseded");
1729 assert!(
1730 account_updates.updated_public_accounts().is_empty(),
1731 "the target state must not overwrite the local account"
1732 );
1733 }
1734
1735 async fn get_account_proof(
1737 rpc_api: &MockRpcApi,
1738 account_id: AccountId,
1739 ) -> (BlockNumber, AccountProof) {
1740 rpc_api
1741 .get_account(
1742 account_id,
1743 GetAccountRequest::new()
1744 .with_storage(StorageMapFetch::All)
1745 .with_vault(VaultFetch::Always),
1746 )
1747 .await
1748 .unwrap()
1749 }
1750
1751 #[tokio::test]
1753 async fn validate_account_proof_rejects_mismatched_account() {
1754 let mut builder = MockChainBuilder::new();
1755 let account_a = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1756 let account_b = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1757 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1758 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1759
1760 let (proof_block_num, proof) = get_account_proof(&rpc_api, account_b.id()).await;
1762 let result = StateSync::validate_account_proof(
1763 proof,
1764 proof_block_num,
1765 account_a.id(),
1766 &chain_tip_header,
1767 );
1768
1769 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1770 }
1771
1772 #[tokio::test]
1774 async fn validate_account_proof_rejects_wrong_account_root() {
1775 let mut builder = MockChainBuilder::new();
1776 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1777 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1778 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1779 let wrong_header = header_with_account_root(&chain_tip_header, word(999));
1780
1781 let (proof_block_num, proof) = get_account_proof(&rpc_api, account.id()).await;
1783 let result =
1784 StateSync::validate_account_proof(proof, proof_block_num, account.id(), &wrong_header);
1785
1786 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1787 }
1788
1789 #[tokio::test]
1791 async fn validate_account_proof_rejects_wrong_block() {
1792 let mut builder = MockChainBuilder::new();
1793 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1794 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1795 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1796
1797 let (proof_block_num, proof) = get_account_proof(&rpc_api, account.id()).await;
1799 let result = StateSync::validate_account_proof(
1800 proof,
1801 proof_block_num + 1,
1802 account.id(),
1803 &chain_tip_header,
1804 );
1805
1806 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1807 }
1808
1809 mod compute_nullifiers_tests {
1813 use alloc::vec;
1814
1815 use miden_protocol::block::BlockNumber;
1816 use miden_protocol::note::Nullifier;
1817 use miden_protocol::transaction::{InputNoteCommitment, InputNotes, TransactionHeader};
1818
1819 use super::word;
1820 use crate::rpc::domain::transaction::TransactionRecord as RpcTransactionRecord;
1821
1822 fn make_rpc_tx(
1823 init_state: u64,
1824 final_state: u64,
1825 nullifier_vals: &[u64],
1826 block_number: u32,
1827 ) -> RpcTransactionRecord {
1828 let account_id = miden_protocol::account::AccountId::try_from(
1829 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE,
1830 )
1831 .unwrap();
1832
1833 let input_notes = InputNotes::new_unchecked(
1834 nullifier_vals
1835 .iter()
1836 .map(|v| InputNoteCommitment::from(Nullifier::from_raw(word(*v))))
1837 .collect(),
1838 );
1839
1840 RpcTransactionRecord {
1841 block_num: BlockNumber::from(block_number),
1842 transaction_header: TransactionHeader::new(
1843 account_id,
1844 word(init_state),
1845 word(final_state),
1846 input_notes,
1847 vec![],
1848 ),
1849 output_notes: vec![],
1850 erased_output_notes: vec![],
1851 consumed_note_refs: vec![],
1852 }
1853 }
1854
1855 #[test]
1856 fn chains_rpc_transactions_by_state_commitment() {
1857 let tx_a = make_rpc_tx(1, 2, &[10], 5);
1860 let tx_b = make_rpc_tx(2, 3, &[20], 5);
1861 let tx_c = make_rpc_tx(3, 4, &[30], 5);
1862
1863 let result = super::super::compute_ordered_nullifiers(&[tx_c, tx_a, tx_b]);
1864
1865 assert_eq!(result[0], Nullifier::from_raw(word(10)));
1866 assert_eq!(result[1], Nullifier::from_raw(word(20)));
1867 assert_eq!(result[2], Nullifier::from_raw(word(30)));
1868 }
1869
1870 #[test]
1871 fn groups_independently_by_account_and_block() {
1872 let tx_a1 = make_rpc_tx(1, 2, &[10], 5);
1874 let tx_a2 = make_rpc_tx(2, 3, &[20], 5);
1875
1876 let tx_a3 = make_rpc_tx(3, 4, &[30], 6);
1878
1879 let account_b = miden_protocol::account::AccountId::try_from(
1881 miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
1882 )
1883 .unwrap();
1884
1885 let tx_b1 = RpcTransactionRecord {
1886 block_num: BlockNumber::from(5u32),
1887 transaction_header: TransactionHeader::new(
1888 account_b,
1889 word(100),
1890 word(200),
1891 InputNotes::new_unchecked(vec![InputNoteCommitment::from(
1892 Nullifier::from_raw(word(40)),
1893 )]),
1894 vec![],
1895 ),
1896 output_notes: vec![],
1897 erased_output_notes: vec![],
1898 consumed_note_refs: vec![],
1899 };
1900
1901 let result = super::super::compute_ordered_nullifiers(&[tx_a2, tx_b1, tx_a3, tx_a1]);
1902
1903 let pos = |val: u64| -> usize {
1906 result.iter().position(|n| *n == Nullifier::from_raw(word(val))).unwrap()
1907 };
1908
1909 assert!(pos(10) < pos(20)); assert!(result.contains(&Nullifier::from_raw(word(30)))); assert!(result.contains(&Nullifier::from_raw(word(40)))); }
1915
1916 #[test]
1917 fn multiple_nullifiers_per_transaction_are_consecutive() {
1918 let tx = make_rpc_tx(1, 2, &[10, 20, 30], 5);
1920
1921 let result = super::super::compute_ordered_nullifiers(&[tx]);
1922
1923 assert_eq!(result.len(), 3);
1924 assert!(result.contains(&Nullifier::from_raw(word(10))));
1925 assert!(result.contains(&Nullifier::from_raw(word(20))));
1926 assert!(result.contains(&Nullifier::from_raw(word(30))));
1927 }
1928
1929 #[test]
1930 fn empty_input_returns_empty_vec() {
1931 let result = super::super::compute_ordered_nullifiers(&[]);
1932 assert!(result.is_empty());
1933 }
1934 }
1935
1936 #[test]
1947 fn derive_account_commitments_walks_chains_per_account() {
1948 let make_tx = |account: AccountId, init_state: u64, final_state: u64, block_num: u32| {
1949 RpcTransactionRecord {
1950 block_num: BlockNumber::from(block_num),
1951 transaction_header: TransactionHeader::new(
1952 account,
1953 word(init_state),
1954 word(final_state),
1955 InputNotes::new_unchecked(vec![]),
1956 vec![],
1957 ),
1958 output_notes: vec![],
1959 erased_output_notes: vec![],
1960 consumed_note_refs: vec![],
1961 }
1962 };
1963
1964 let account_a: AccountId =
1965 ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE.try_into().unwrap();
1966 let account_b: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap();
1967
1968 let tx_a_b5_1 = make_tx(account_a, 1, 2, 5);
1969 let tx_a_b5_2 = make_tx(account_a, 2, 3, 5);
1970 let tx_a_b6_1 = make_tx(account_a, 3, 4, 6);
1971 let tx_a_b6_2 = make_tx(account_a, 4, 5, 6);
1972 let tx_b_b6 = make_tx(account_b, 10, 20, 6);
1973
1974 let result = super::derive_account_commitments(&[
1976 tx_a_b6_1, tx_b_b6, tx_a_b5_2, tx_a_b6_2, tx_a_b5_1,
1977 ]);
1978
1979 assert_eq!(result.len(), 2, "one entry per account");
1980 assert!(
1981 result.contains(&(account_a, word(5))),
1982 "account A: must walk block 6's chain, not return block 5 or an intermediate",
1983 );
1984 assert!(
1985 result.contains(&(account_b, word(20))),
1986 "account B: must be resolved independently of account A",
1987 );
1988 }
1989
1990 struct CommitAllScreener;
1996
1997 #[async_trait(?Send)]
1998 impl OnNoteReceived for CommitAllScreener {
1999 async fn on_note_received(
2000 &self,
2001 committed_note: CommittedNote,
2002 _public_note: Option<InputNoteRecord>,
2003 ) -> Result<NoteUpdateAction, ClientError> {
2004 Ok(NoteUpdateAction::Commit(committed_note))
2005 }
2006 }
2007
2008 async fn build_chain_with_chained_consume_txs() -> (miden_testing::MockChain, Account, [Note; 3])
2012 {
2013 let sender_id: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
2014 let faucet_id: AccountId = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into().unwrap();
2015
2016 let mut builder = MockChainBuilder::new();
2017 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2018 let account_id = account.id();
2019
2020 let asset = Asset::Fungible(FungibleAsset::new(faucet_id, 100u64).unwrap());
2021 let note1 = builder
2022 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
2023 .unwrap();
2024 let note2 = builder
2025 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
2026 .unwrap();
2027 let note3 = builder
2028 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
2029 .unwrap();
2030
2031 let mut chain = builder.build().unwrap();
2032 chain.prove_next_block().unwrap(); let mut current_account = account.clone();
2036 for note in [¬e1, ¬e2, ¬e3] {
2037 let tx = Box::pin(
2038 chain
2039 .build_transaction(MockTransactionInput::Account(current_account.clone()))
2040 .unauthenticated_input_note(note.clone())
2041 .build()
2042 .unwrap()
2043 .execute(),
2044 )
2045 .await
2046 .unwrap();
2047 current_account.apply_patch(tx.account_patch()).unwrap();
2048 chain.add_pending_executed_transaction(&tx).unwrap();
2049 }
2050
2051 chain.prove_next_block().unwrap(); (chain, account, [note1, note2, note3])
2053 }
2054
2055 #[tokio::test]
2058 async fn sync_state_sets_consumed_tx_order_for_chained_transactions() {
2059 use miden_protocol::note::NoteMetadata;
2060
2061 let (chain, account, [note1, note2, note3]) = build_chain_with_chained_consume_txs().await;
2062
2063 let mock_rpc = MockRpcApi::new(chain);
2064 let state_sync =
2065 StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(CommitAllScreener), None);
2066
2067 let genesis_peaks =
2068 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2069 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2070
2071 let input_notes: Vec<InputNoteRecord> = [¬e1, ¬e2, ¬e3]
2072 .into_iter()
2073 .map(|n| InputNoteRecord::from(n.clone()))
2074 .collect();
2075
2076 let note_tags: BTreeSet<NoteTag> =
2077 input_notes.iter().filter_map(|n| n.metadata().map(NoteMetadata::tag)).collect();
2078
2079 let account_id = account.id();
2080 let sync_input = StateSyncInput {
2081 accounts: vec![AccountHeader::from(account)],
2082 note_tags,
2083 input_notes,
2084 output_notes: vec![],
2085 uncommitted_transactions: vec![],
2086 };
2087
2088 let update = state_sync.sync_state(&mut partial_mmr, sync_input).await.unwrap();
2089
2090 let updated_notes: Vec<_> = update.note_updates().updated_input_notes().collect();
2091
2092 let find_order = |details_commitment| -> Option<u32> {
2093 updated_notes
2094 .iter()
2095 .find(|n| n.inner().details_commitment() == details_commitment)
2096 .and_then(|n| n.consumed_tx_order())
2097 };
2098
2099 assert_eq!(find_order(note1.details_commitment()), Some(0), "note1 should have tx_order 0");
2100 assert_eq!(find_order(note2.details_commitment()), Some(1), "note2 should have tx_order 1");
2101 assert_eq!(find_order(note3.details_commitment()), Some(2), "note3 should have tx_order 2");
2102
2103 for note in &updated_notes {
2106 let record = note.inner();
2107 assert!(record.is_consumed(), "note should be in a consumed state");
2108 assert_eq!(
2109 record.consumer_account(),
2110 Some(account_id),
2111 "externally-consumed notes by a tracked account should have consumer_account set",
2112 );
2113 }
2114 }
2115
2116 #[tokio::test]
2117 async fn sync_state_across_multiple_iterations_with_same_mmr() {
2118 let mock_rpc = MockRpcApi::default();
2120 mock_rpc.advance_blocks(3);
2121 let chain_tip_1 = mock_rpc.get_chain_tip_block_num();
2122
2123 let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
2124
2125 let genesis_peaks =
2127 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2128 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2129 assert_eq!(partial_mmr.forest().num_leaves(), 1);
2130
2131 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
2133
2134 assert_eq!(update.block_num(), chain_tip_1);
2135 let forest_1 = partial_mmr.forest();
2136 assert_eq!(forest_1.num_leaves(), chain_tip_1.as_u32() as usize + 1);
2138
2139 mock_rpc.advance_blocks(2);
2141 let chain_tip_2 = mock_rpc.get_chain_tip_block_num();
2142
2143 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
2144
2145 assert_eq!(update.block_num(), chain_tip_2);
2146 let forest_2 = partial_mmr.forest();
2147 assert!(forest_2 > forest_1);
2148 assert_eq!(forest_2.num_leaves(), chain_tip_2.as_u32() as usize + 1);
2149
2150 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
2152
2153 assert_eq!(update.block_num(), chain_tip_2);
2154 assert_eq!(partial_mmr.forest(), forest_2);
2155 }
2156
2157 async fn build_chain_with_mint_notes(
2160 num_blocks: u64,
2161 ) -> (miden_testing::MockChain, BTreeSet<NoteTag>) {
2162 let mut builder = MockChainBuilder::new();
2163 let faucet = builder
2164 .add_existing_basic_faucet(
2165 miden_testing::Auth::BasicAuth {
2166 auth_scheme: miden_protocol::account::auth::AuthScheme::Falcon512Poseidon2,
2167 },
2168 "TST",
2169 10_000,
2170 None,
2171 )
2172 .unwrap();
2173 let _target = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2174 let mut chain = builder.build().unwrap();
2175
2176 let note_script = CodeBuilder::new()
2181 .compile_note_script("@note_script\npub proc main\n nop\nend")
2182 .unwrap();
2183 let note_recipient = NoteRecipient::new(
2184 Word::from([1u32, 2, 3, 4]),
2185 note_script,
2186 NoteStorage::new(vec![]).unwrap(),
2187 );
2188 let recipient = note_recipient.digest();
2189 let note_details = NoteDetails::new(NoteAssets::new(vec![]).unwrap(), note_recipient);
2192 let mut recipient_args = TransactionArgs::new(AdviceMap::default());
2193 recipient_args.add_output_note_recipient(¬e_details);
2194 let recipient_advice = recipient_args.advice_inputs().clone();
2195
2196 let tag = NoteTag::default();
2197 let mut faucet_account = faucet.clone();
2198 let mut note_tags = BTreeSet::new();
2199
2200 for i in 0..num_blocks {
2201 let amount = 100 + i;
2202 let source_manager = Arc::new(DefaultSourceManager::default());
2203 let mint_asset = FungibleAsset::new(faucet_account.id(), amount).unwrap();
2207 let asset_id_word = mint_asset.id().to_word();
2208 let asset_value_word = mint_asset.to_value_word();
2209 let tx_script_code = format!(
2210 "
2211 @transaction_script
2212 pub proc main
2213 push.{recipient}
2214 push.{note_type}
2215 push.{tag}
2216 push.{asset_value}
2217 push.{asset_id}
2218 call.::miden::standards::faucets::fungible::mint_and_send
2219 dropw dropw dropw dropw
2220 end
2221 ",
2222 recipient = recipient,
2223 note_type = NoteType::Private as u8,
2224 tag = u32::from(tag),
2225 asset_value = asset_value_word,
2226 asset_id = asset_id_word,
2227 );
2228 let tx_script = CodeBuilder::with_source_manager(source_manager.clone())
2229 .compile_tx_script(tx_script_code)
2230 .unwrap();
2231 let tx = Box::pin(
2232 chain
2233 .build_transaction(miden_testing::MockTransactionInput::Account(
2234 faucet_account.clone(),
2235 ))
2236 .extend_advice_inputs(recipient_advice.clone())
2237 .tx_script(tx_script)
2238 .with_source_manager(source_manager)
2239 .build()
2240 .unwrap()
2241 .execute(),
2242 )
2243 .await
2244 .unwrap();
2245
2246 for output_note in tx.output_notes().iter() {
2247 note_tags.insert(output_note.metadata().tag());
2248 }
2249
2250 faucet_account.apply_patch(tx.account_patch()).unwrap();
2251 chain.add_pending_executed_transaction(&tx).unwrap();
2252 chain.prove_next_block().unwrap();
2253 }
2254
2255 (chain, note_tags)
2256 }
2257
2258 #[tokio::test]
2261 async fn observer_relevance_persists_discarded_note_block() {
2262 let (chain, note_tags) = build_chain_with_mint_notes(2).await;
2263 let mock_rpc = MockRpcApi::new(chain);
2264 let chain_tip = mock_rpc.get_chain_tip_block_num();
2265
2266 let genesis_peaks =
2267 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2268 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2269
2270 let state_sync = StateSync::new(Arc::new(mock_rpc), Arc::new(MockScreener), None)
2271 .with_note_observer(Arc::new(AlwaysRelevantObserver));
2272 let mut input = empty();
2273 input.note_tags = note_tags;
2274
2275 let update = state_sync.sync_state(&mut partial_mmr, input).await.unwrap();
2276 let observed_non_tip_block = BlockNumber::from(1u32);
2277
2278 assert!(
2279 update.partial_blockchain_updates().block_headers_to_store(chain_tip).any(
2280 |(header, is_relevant)| {
2281 header.block_num() == observed_non_tip_block && *is_relevant
2282 }
2283 ),
2284 "an observer-relevant block must be staged as relevant"
2285 );
2286 assert!(
2287 partial_mmr.is_tracked(observed_non_tip_block.as_usize()),
2288 "an observer-relevant block must remain tracked in the partial MMR"
2289 );
2290 }
2291
2292 #[tokio::test]
2302 async fn sync_state_tracks_note_blocks_in_mmr() {
2303 let (chain, note_tags) = build_chain_with_mint_notes(3).await;
2304 let mock_rpc = MockRpcApi::new(chain);
2305 let chain_tip = mock_rpc.get_chain_tip_block_num();
2306
2307 let note_blocks = mock_rpc
2309 .sync_notes(BlockNumber::from(0u32), chain_tip, ¬e_tags)
2310 .await
2311 .unwrap();
2312 assert!(
2313 note_blocks.len() >= 2,
2314 "expected notes in multiple blocks, got {}",
2315 note_blocks.len()
2316 );
2317
2318 let note_block_nums: BTreeSet<BlockNumber> =
2320 note_blocks.iter().map(|b| b.block_header.block_num()).collect();
2321
2322 let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
2325
2326 let genesis_peaks =
2327 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2328 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2329
2330 let sync_data = state_sync
2331 .fetch_sync_data(BlockNumber::GENESIS, &[], &Arc::new(note_tags.clone()))
2332 .await
2333 .unwrap()
2334 .expect("should have progressed past genesis");
2335
2336 assert_eq!(sync_data.chain_tip_header.block_num(), chain_tip);
2338 assert!(!sync_data.note_blocks.is_empty(), "should have note blocks");
2339
2340 let _auth_nodes: Vec<(InOrderIndex, Word)> =
2342 partial_mmr.apply(sync_data.mmr_delta).map_err(StoreError::MmrError).unwrap();
2343 partial_mmr
2344 .add(sync_data.chain_tip_header.commitment(), false)
2345 .expect("chain tip should append to the partial MMR");
2346
2347 assert_eq!(partial_mmr.forest().num_leaves(), chain_tip.as_u32() as usize + 1);
2348
2349 for block in &sync_data.note_blocks {
2351 let bn = block.block_header.block_num();
2352 partial_mmr
2353 .track(bn.as_usize(), block.block_header.commitment(), &block.mmr_path)
2354 .map_err(StoreError::MmrError)
2355 .unwrap();
2356
2357 assert!(
2358 partial_mmr.is_tracked(bn.as_usize()),
2359 "block {bn} should be tracked after calling track()"
2360 );
2361 }
2362
2363 for &bn in ¬e_block_nums {
2365 assert!(
2366 partial_mmr.is_tracked(bn.as_usize()),
2367 "block {bn} with notes should be tracked in partial MMR"
2368 );
2369 }
2370 }
2371
2372 #[tokio::test]
2373 async fn sync_notes_with_content_fetches_inclusive_upper_bound_page() {
2374 let (chain, note_tags) = build_chain_with_mint_notes(10).await;
2375 let mock_rpc = MockRpcApi::new(chain);
2376
2377 let blocks = mock_rpc
2378 .sync_notes_with_content(
2379 4_u32.into(),
2380 10_u32.into(),
2381 ¬e_tags,
2382 NoteContentFetch::PublicDetailsAndAttachments,
2383 )
2384 .await
2385 .expect("sync notes should succeed");
2386
2387 assert_eq!(blocks.last().unwrap().block_header.block_num(), BlockNumber::from(10u32));
2388 assert!(
2389 blocks
2390 .iter()
2391 .any(|block| block.block_header.block_num() == BlockNumber::from(9u32))
2392 );
2393 }
2394
2395 #[tokio::test]
2401 async fn erased_notes_are_marked_as_consumed() {
2402 let sender_id: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
2404 let partial_metadata = PartialNoteMetadata::new(sender_id, NoteType::Public);
2405 let metadata = NoteMetadata::new(partial_metadata, &NoteAttachments::empty());
2406 let script = CodeBuilder::new()
2407 .compile_note_script("@note_script\npub proc main\n nop\nend")
2408 .unwrap();
2409 let recipient = NoteRecipient::new(
2410 Word::from([1u32, 2, 3, 4]),
2411 script,
2412 NoteStorage::new(vec![]).unwrap(),
2413 );
2414 let output_note = OutputNoteRecord::new(
2415 recipient.digest(),
2416 NoteAssets::new(vec![]).unwrap(),
2417 metadata,
2418 OutputNoteState::ExpectedFull { recipient },
2419 BlockNumber::from(1u32),
2420 NoteAttachments::default(),
2421 );
2422 let note_id = output_note.id();
2423 let note_header = NoteHeader::new(output_note.details_commitment(), metadata);
2424
2425 let mut note_updates = NoteUpdateTracker::new(vec![], vec![output_note]);
2427
2428 let block_num = BlockNumber::from(3u32);
2430 note_updates
2431 .mark_erased_note_as_consumed(¬e_header, block_num)
2432 .expect("marking erased note should succeed");
2433
2434 let updated = note_updates
2435 .updated_output_notes()
2436 .find(|n| n.id() == note_id)
2437 .expect("output note should be in the update");
2438
2439 assert!(
2440 updated.inner().is_consumed(),
2441 "output note should be consumed after erasure detection, but state is: {}",
2442 updated.inner().state()
2443 );
2444 }
2445
2446 #[allow(clippy::too_many_lines)]
2463 #[ignore = "consumer derivation removed; see comment above"]
2464 #[tokio::test]
2465 async fn erased_notes_are_marked_as_consumed_by_network_account() {
2466 let mut builder = MockChainBuilder::new();
2469 let p2id_sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
2470 let faucet_id: AccountId = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into().unwrap();
2471 let sender_account =
2472 builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2473 let sender_id = sender_account.id();
2474
2475 let asset = Asset::Fungible(FungibleAsset::new(faucet_id, 100u64).unwrap());
2476 let note = builder
2477 .add_p2id_note(p2id_sender, sender_id, &[asset], NoteType::Public)
2478 .unwrap();
2479
2480 let mut chain = builder.build().unwrap();
2481 chain.prove_next_block().unwrap();
2482
2483 let tx = Box::pin(
2484 chain
2485 .build_transaction(MockTransactionInput::Account(sender_account.clone()))
2486 .unauthenticated_input_note(note.clone())
2487 .build()
2488 .unwrap()
2489 .execute(),
2490 )
2491 .await
2492 .unwrap();
2493 chain.add_pending_executed_transaction(&tx).unwrap();
2494 chain.prove_next_block().unwrap();
2495
2496 let network_account_id: AccountId =
2498 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into().unwrap();
2499 let target =
2500 NetworkAccountTarget::new(network_account_id, NoteExecutionHint::Always).unwrap();
2501 let attachment: NoteAttachment = target.into();
2502 let attachments = NoteAttachments::new(vec![attachment]).unwrap();
2503 let partial_metadata = PartialNoteMetadata::new(sender_id, NoteType::Public);
2504 let metadata = NoteMetadata::new(partial_metadata, &attachments);
2505 let script = CodeBuilder::new()
2506 .compile_note_script("@note_script\npub proc main\n nop\nend")
2507 .unwrap();
2508 let recipient = NoteRecipient::new(
2509 Word::from([7u32, 8, 9, 10]),
2510 script,
2511 NoteStorage::new(vec![]).unwrap(),
2512 );
2513 let recipient_digest = recipient.digest();
2514 let assets = NoteAssets::new(vec![]).unwrap();
2515
2516 let output_note = OutputNoteRecord::new(
2519 recipient_digest,
2520 assets.clone(),
2521 metadata,
2522 OutputNoteState::ExpectedFull { recipient },
2523 BlockNumber::from(1u32),
2524 NoteAttachments::default(),
2525 );
2526 let erased_note_id = output_note.id();
2527 let erased_note_header = NoteHeader::new(output_note.details_commitment(), metadata);
2528
2529 let mock_rpc = MockRpcApi::new(chain);
2530 mock_rpc.mark_note_as_erased(erased_note_header);
2531
2532 let network_header =
2535 AccountHeader::new(network_account_id, ZERO, EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
2536
2537 let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
2538
2539 let genesis_peaks =
2540 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2541 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2542
2543 let sync_input = StateSyncInput {
2544 accounts: vec![AccountHeader::from(sender_account), network_header],
2545 note_tags: BTreeSet::new(),
2546 input_notes: vec![],
2547 output_notes: vec![output_note],
2548 uncommitted_transactions: vec![],
2549 };
2550
2551 let update = state_sync.sync_state(&mut partial_mmr, sync_input).await.unwrap();
2552
2553 let updated_output = update
2555 .note_updates()
2556 .updated_output_notes()
2557 .find(|n| n.id() == erased_note_id)
2558 .expect("output note should be in the update");
2559 assert!(
2560 updated_output.inner().is_consumed(),
2561 "output note should be consumed, got: {}",
2562 updated_output.inner().state()
2563 );
2564
2565 let input_note_update = update
2567 .note_updates()
2568 .updated_input_notes()
2569 .find(|n| n.id() == Some(erased_note_id))
2570 .expect("input note should be created from the erased output note");
2571
2572 let inner = input_note_update.inner();
2573 assert!(
2574 inner.is_consumed(),
2575 "input note should be in a consumed state, got: {}",
2576 inner.state()
2577 );
2578 assert_eq!(
2579 inner.consumer_account(),
2580 Some(network_account_id),
2581 "consumer should be the tracked network account"
2582 );
2583 }
2584
2585 #[tokio::test]
2588 async fn validate_chain_mmr_response_rejects_tampered_responses() {
2589 let mock_rpc = MockRpcApi::default();
2590 mock_rpc.advance_blocks(3);
2591 let chain_tip = mock_rpc.get_chain_tip_block_num();
2592 let current = BlockNumber::GENESIS;
2593
2594 let header_of =
2595 |block_num: u32| mock_rpc.mock_chain.read().block_header(block_num as usize);
2596 let chain_mmr_response = || async {
2597 mock_rpc.sync_chain_mmr(current, SyncTarget::CommittedChainTip).await.unwrap()
2598 };
2599
2600 let response = chain_mmr_response().await;
2602 StateSync::validate_chain_mmr_response(&response, current).unwrap();
2603
2604 let mut response = chain_mmr_response().await;
2606 response.block_header = header_of(chain_tip.as_u32() - 1);
2607 let result = StateSync::validate_chain_mmr_response(&response, current);
2608 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2609
2610 let mut response = chain_mmr_response().await;
2612 response.block_from = current + 1;
2613 let result = StateSync::validate_chain_mmr_response(&response, current);
2614 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2615
2616 let mut response = chain_mmr_response().await;
2618 response.block_from = chain_tip;
2619 response.block_to = BlockNumber::GENESIS;
2620 response.block_header = header_of(0);
2621 let result = StateSync::validate_chain_mmr_response(&response, chain_tip);
2622 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2623 }
2624
2625 #[test]
2628 fn validate_note_blocks_range_rejects_out_of_range_blocks() {
2629 let mock_rpc = MockRpcApi::default();
2630 mock_rpc.advance_blocks(3);
2631 let chain_tip = mock_rpc.get_chain_tip_block_num();
2632 let current = BlockNumber::GENESIS;
2633
2634 StateSync::validate_note_blocks_range(&[], current, chain_tip).unwrap();
2636
2637 let genesis_note_block = ResolvedSyncNotesBlock {
2639 block_header: mock_rpc.mock_chain.read().block_header(0),
2640 mmr_path: MerklePath::new(Vec::new()),
2641 notes: BTreeMap::new(),
2642 };
2643 let result =
2644 StateSync::validate_note_blocks_range(&[genesis_note_block], current, chain_tip);
2645 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2646 }
2647
2648 #[test]
2651 fn advance_mmr_rejects_delta_inconsistent_with_chain_commitment() {
2652 let mock_rpc = MockRpcApi::default();
2653 mock_rpc.advance_blocks(3);
2654 let chain_tip = mock_rpc.get_chain_tip_block_num();
2655
2656 let chain_tip_header = mock_rpc.mock_chain.read().block_header(chain_tip.as_usize());
2657 let genesis_partial_mmr = || {
2658 let peaks = mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2659 PartialMmr::from_peaks(peaks)
2660 };
2661
2662 let full_delta = mock_rpc
2664 .get_mmr()
2665 .get_delta(Forest::new(1).unwrap(), Forest::new(chain_tip.as_usize()).unwrap())
2666 .unwrap();
2667 StateSync::advance_mmr(
2668 full_delta,
2669 &chain_tip_header,
2670 &mut genesis_partial_mmr(),
2671 &mut PartialBlockchainUpdates::default(),
2672 )
2673 .unwrap();
2674
2675 let truncated_delta = mock_rpc
2677 .get_mmr()
2678 .get_delta(Forest::new(1).unwrap(), Forest::new(chain_tip.as_usize() - 1).unwrap())
2679 .unwrap();
2680 let result = StateSync::advance_mmr(
2681 truncated_delta,
2682 &chain_tip_header,
2683 &mut genesis_partial_mmr(),
2684 &mut PartialBlockchainUpdates::default(),
2685 );
2686 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2687 }
2688
2689 fn make_tx_record(account_id: AccountId, block_num: u32) -> RpcTransactionRecord {
2691 RpcTransactionRecord {
2692 block_num: BlockNumber::from(block_num),
2693 transaction_header: TransactionHeader::new(
2694 account_id,
2695 word(1),
2696 word(2),
2697 InputNotes::new_unchecked(vec![]),
2698 vec![],
2699 ),
2700 output_notes: vec![],
2701 erased_output_notes: vec![],
2702 consumed_note_refs: vec![],
2703 }
2704 }
2705}