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::{BlockHeader, BlockNumber};
11use miden_protocol::crypto::merkle::mmr::{MmrDelta, PartialMmr};
12use miden_protocol::note::{NoteAttachments, NoteId, NoteTag, NoteType, Nullifier};
13use tracing::info;
14
15use super::state_sync_update::TransactionUpdateTracker;
16use super::{
17 AccountUpdates,
18 NoteObserver,
19 PartialBlockchainUpdates,
20 PublicAccountDelta,
21 PublicAccountUpdate,
22 StateSyncUpdate,
23};
24use crate::ClientError;
25use crate::note::{NoteConsumption, NoteUpdateTracker};
26use crate::rpc::NodeRpcClient;
27use crate::rpc::domain::account::{AccountDetails, GetAccountRequest, StorageMapFetch, VaultFetch};
28use crate::rpc::domain::note::{CommittedNote, NoteSyncBlock, SyncedNoteDetails};
29use crate::rpc::domain::sync::{ChainMmrInfo, SyncTarget};
30use crate::rpc::domain::transaction::TransactionRecord as RpcTransactionRecord;
31use crate::store::{InputNoteRecord, OutputNoteRecord, StoreError};
32use crate::transaction::TransactionRecord;
33
34enum PublicAccountSync {
39 Apply(Box<PublicAccountUpdate>),
41 Superseded,
43 Ignore,
45}
46
47struct FetchedSyncData {
53 mmr_delta: MmrDelta,
55 chain_tip_header: BlockHeader,
57 note_blocks: Vec<NoteSyncBlock>,
59 synced_notes: BTreeMap<NoteId, SyncedNoteDetails>,
62 transactions: Vec<RpcTransactionRecord>,
64}
65
66pub struct StateSyncInput {
82 pub accounts: Vec<AccountHeader>,
84 pub note_tags: BTreeSet<NoteTag>,
86 pub input_notes: Vec<InputNoteRecord>,
88 pub output_notes: Vec<OutputNoteRecord>,
90 pub uncommitted_transactions: Vec<TransactionRecord>,
92}
93
94#[allow(clippy::large_enum_variant)]
99pub enum NoteUpdateAction {
100 Commit(CommittedNote),
103 Insert(InputNoteRecord),
105 Discard,
107}
108
109#[async_trait(?Send)]
110pub trait OnNoteReceived {
111 async fn on_note_received(
122 &self,
123 committed_note: CommittedNote,
124 public_note: Option<InputNoteRecord>,
125 ) -> Result<NoteUpdateAction, ClientError>;
126}
127#[derive(Clone)]
134pub struct StateSync {
135 rpc_api: Arc<dyn NodeRpcClient>,
137 note_screener: Arc<dyn OnNoteReceived>,
140 note_observers: Vec<Arc<dyn NoteObserver>>,
143 tx_discard_delta: Option<u32>,
146 sync_nullifiers: bool,
149}
150
151impl StateSync {
152 pub fn new(
163 rpc_api: Arc<dyn NodeRpcClient>,
164 note_screener: Arc<dyn OnNoteReceived>,
165 tx_discard_delta: Option<u32>,
166 ) -> Self {
167 Self {
168 rpc_api,
169 note_screener,
170 note_observers: Vec::new(),
171 tx_discard_delta,
172 sync_nullifiers: true,
173 }
174 }
175
176 #[must_use]
180 pub fn with_note_observer(mut self, observer: Arc<dyn NoteObserver>) -> Self {
181 self.note_observers.push(observer);
182 self
183 }
184
185 pub fn disable_nullifier_sync(&mut self) {
191 self.sync_nullifiers = false;
192 }
193
194 pub fn enable_nullifier_sync(&mut self) {
196 self.sync_nullifiers = true;
197 }
198
199 pub(crate) async fn run_apply_hooks(
206 &self,
207 state_sync_update: &StateSyncUpdate,
208 ) -> Result<(), ClientError> {
209 for observer in &self.note_observers {
210 crate::errors::log_observer_failure(
211 observer.name(),
212 "NoteObserver::apply",
213 observer.apply(state_sync_update).await,
214 );
215 }
216 Ok(())
217 }
218
219 pub async fn sync_state(
237 &self,
238 current_partial_mmr: &mut PartialMmr,
239 input: StateSyncInput,
240 ) -> Result<StateSyncUpdate, ClientError> {
241 let StateSyncInput {
242 accounts,
243 note_tags,
244 input_notes,
245 output_notes,
246 uncommitted_transactions,
247 } = input;
248 let block_num = u32::try_from(current_partial_mmr.forest().num_leaves().saturating_sub(1))
249 .map_err(|_| ClientError::InvalidPartialMmrForest)?
250 .into();
251
252 let note_tags = Arc::new(note_tags);
253 let account_ids: Vec<AccountId> = accounts.iter().map(AccountHeader::id).collect();
254
255 let mut state_sync_update = StateSyncUpdate {
256 block_num,
257 note_updates: NoteUpdateTracker::new(input_notes, output_notes),
258 transaction_updates: TransactionUpdateTracker::new(uncommitted_transactions),
259 ..Default::default()
260 };
261 let Some(sync_data) = self
262 .fetch_sync_data(state_sync_update.block_num, &account_ids, ¬e_tags)
263 .await?
264 else {
265 return Ok(state_sync_update);
267 };
268
269 state_sync_update.block_num = sync_data.chain_tip_header.block_num();
270
271 let new_commitments = derive_account_commitments(&sync_data.transactions);
272 let superseded_states = self
273 .account_state_sync(
274 &mut state_sync_update.account_updates,
275 &accounts,
276 &new_commitments,
277 block_num,
278 )
279 .await?;
280
281 for superseded_state in superseded_states {
283 state_sync_update
284 .transaction_updates
285 .apply_superseded_account_state(superseded_state);
286 }
287
288 self.apply_sync_result(sync_data, &mut state_sync_update, current_partial_mmr)
290 .await?;
291
292 if self.sync_nullifiers {
293 self.nullifiers_state_sync(&mut state_sync_update, block_num).await?;
294 }
295
296 Ok(state_sync_update)
297 }
298
299 async fn fetch_sync_data(
308 &self,
309 current_block_num: BlockNumber,
310 account_ids: &[AccountId],
311 note_tags: &Arc<BTreeSet<NoteTag>>,
312 ) -> Result<Option<FetchedSyncData>, ClientError> {
313 let chain_mmr_info = self
315 .rpc_api
316 .sync_chain_mmr(current_block_num, SyncTarget::CommittedChainTip)
317 .await?;
318 let chain_tip = chain_mmr_info.block_to;
319
320 Self::validate_chain_mmr_response(&chain_mmr_info, current_block_num)?;
322
323 if chain_tip == current_block_num {
325 info!(block_num = %current_block_num, "Already at chain tip, nothing to sync.");
326 return Ok(None);
327 }
328
329 info!(
330 block_from = %current_block_num,
331 block_to = %chain_tip,
332 "Syncing state.",
333 );
334
335 let (note_blocks, synced_notes) = if note_tags.is_empty() {
340 (Vec::new(), BTreeMap::new())
341 } else {
342 self.rpc_api
343 .sync_notes_with_details(current_block_num + 1, chain_tip, note_tags.as_ref())
344 .await?
345 };
346
347 Self::validate_note_blocks_range(¬e_blocks, current_block_num, chain_tip)?;
349
350 let note_count: usize = note_blocks.iter().map(|b| b.notes.len()).sum();
351 info!(
352 blocks_with_notes = note_blocks.len(),
353 notes = note_count,
354 synced_notes = synced_notes.len(),
355 "Fetched note sync data.",
356 );
357
358 let transaction_records = if account_ids.is_empty() {
361 Vec::new()
362 } else {
363 self.rpc_api
364 .sync_transactions(current_block_num + 1, chain_tip, account_ids.to_vec())
365 .await?
366 };
367
368 Ok(Some(FetchedSyncData {
369 mmr_delta: chain_mmr_info.mmr_delta,
370 chain_tip_header: chain_mmr_info.block_header,
371 note_blocks,
372 synced_notes,
373 transactions: transaction_records,
374 }))
375 }
376
377 async fn apply_sync_result(
387 &self,
388 sync_data: FetchedSyncData,
389 state_sync_update: &mut StateSyncUpdate,
390 current_partial_mmr: &mut PartialMmr,
391 ) -> Result<(), ClientError> {
392 let FetchedSyncData {
393 mmr_delta,
394 chain_tip_header,
395 note_blocks,
396 synced_notes,
397 transactions,
398 } = sync_data;
399
400 let mut working_mmr = current_partial_mmr.clone();
403
404 Self::advance_mmr(
405 mmr_delta,
406 &chain_tip_header,
407 &mut working_mmr,
408 &mut state_sync_update.partial_blockchain_updates,
409 )?;
410
411 self.screen_note_blocks(note_blocks, synced_notes, state_sync_update, &mut working_mmr)
412 .await?;
413
414 self.apply_transactions_and_nullifiers(
415 &chain_tip_header,
416 &transactions,
417 state_sync_update,
418 )?;
419
420 *current_partial_mmr = working_mmr;
422
423 Ok(())
424 }
425
426 fn validate_chain_mmr_response(
428 chain_mmr_info: &ChainMmrInfo,
429 current_block_num: BlockNumber,
430 ) -> Result<(), ClientError> {
431 if chain_mmr_info.block_header.block_num() != chain_mmr_info.block_to {
432 return Err(ClientError::ChainValidationError(format!(
433 "sync_chain_mmr block_header.block_num ({}) does not match block_to ({})",
434 chain_mmr_info.block_header.block_num(),
435 chain_mmr_info.block_to
436 )));
437 }
438 if chain_mmr_info.block_from != current_block_num {
439 return Err(ClientError::ChainValidationError(format!(
440 "sync_chain_mmr block_from mismatch: expected {current_block_num}, got {}",
441 chain_mmr_info.block_from
442 )));
443 }
444 if chain_mmr_info.block_to < current_block_num {
445 return Err(ClientError::ChainValidationError(format!(
446 "sync_chain_mmr block_to ({}) is behind current block {current_block_num}",
447 chain_mmr_info.block_to
448 )));
449 }
450 Ok(())
451 }
452
453 fn validate_note_blocks_range(
456 note_blocks: &[NoteSyncBlock],
457 current_block_num: BlockNumber,
458 chain_tip: BlockNumber,
459 ) -> Result<(), ClientError> {
460 for block in note_blocks {
461 let block_num = block.block_header.block_num();
462 if block_num <= current_block_num || block_num > chain_tip {
463 return Err(ClientError::ChainValidationError(format!(
464 "sync_notes returned block {block_num} outside requested range ({current_block_num}, {chain_tip}]"
465 )));
466 }
467 }
468 Ok(())
469 }
470
471 fn advance_mmr(
478 mmr_delta: MmrDelta,
479 chain_tip_header: &BlockHeader,
480 current_partial_mmr: &mut PartialMmr,
481 partial_blockchain_updates: &mut PartialBlockchainUpdates,
482 ) -> Result<(), ClientError> {
483 let mut new_authentication_nodes =
484 current_partial_mmr.apply(mmr_delta).map_err(StoreError::MmrError)?;
485 let new_peaks = current_partial_mmr.peaks();
486
487 let peaks_commitment = new_peaks.hash_peaks();
491 if peaks_commitment != chain_tip_header.chain_commitment() {
492 return Err(ClientError::ChainValidationError(format!(
493 "MMR peaks commitment is {} and does not match block header chain commitment {}",
494 peaks_commitment.to_hex(),
495 chain_tip_header.chain_commitment().to_hex()
496 )));
497 }
498
499 partial_blockchain_updates.new_peaks = new_peaks;
500
501 new_authentication_nodes.append(
506 &mut current_partial_mmr
507 .add(chain_tip_header.commitment(), false)
508 .map_err(StoreError::MmrError)?,
509 );
510
511 partial_blockchain_updates.insert(
512 chain_tip_header.clone(),
513 false,
514 new_authentication_nodes,
515 );
516
517 Ok(())
518 }
519
520 async fn screen_note_blocks(
524 &self,
525 note_blocks: Vec<NoteSyncBlock>,
526 synced_notes: BTreeMap<NoteId, SyncedNoteDetails>,
527 state_sync_update: &mut StateSyncUpdate,
528 current_partial_mmr: &mut PartialMmr,
529 ) -> Result<(), ClientError> {
530 let private_attachments: BTreeMap<NoteId, NoteAttachments> = synced_notes
533 .iter()
534 .filter_map(|(id, synced)| match synced {
535 SyncedNoteDetails::Private(Some(attachments)) => Some((*id, attachments.clone())),
536 _ => None,
537 })
538 .collect();
539 let public_note_records = Self::build_public_note_records(synced_notes, ¬e_blocks);
540
541 for block in note_blocks {
542 let found_relevant_note = self
543 .note_state_sync(
544 &mut state_sync_update.note_updates,
545 block.notes,
546 &block.block_header,
547 &public_note_records,
548 &private_attachments,
549 )
550 .await?;
551
552 if found_relevant_note {
553 let block_pos = block.block_header.block_num().as_usize();
554
555 let nodes_before: BTreeMap<_, _> =
556 current_partial_mmr.nodes().map(|(k, v)| (*k, *v)).collect();
557
558 if !current_partial_mmr.is_tracked(block_pos) {
559 current_partial_mmr
560 .track(block_pos, block.block_header.commitment(), &block.mmr_path)
561 .map_err(StoreError::MmrError)?;
562 }
563
564 let track_auth_nodes: Vec<_> = current_partial_mmr
569 .nodes()
570 .filter(|(k, _)| !nodes_before.contains_key(k))
571 .map(|(k, v)| (*k, *v))
572 .collect();
573
574 state_sync_update.partial_blockchain_updates.insert(
575 block.block_header,
576 true,
577 track_auth_nodes,
578 );
579 }
580 }
581
582 Ok(())
583 }
584
585 fn apply_transactions_and_nullifiers(
589 &self,
590 chain_tip_header: &BlockHeader,
591 transactions: &[RpcTransactionRecord],
592 state_sync_update: &mut StateSyncUpdate,
593 ) -> Result<(), ClientError> {
594 state_sync_update
595 .note_updates
596 .extend_nullifiers(compute_ordered_nullifiers(transactions));
597
598 for record in transactions {
599 state_sync_update
600 .transaction_updates
601 .apply_transaction_inclusion(record, u64::from(chain_tip_header.timestamp())); }
603 state_sync_update
604 .transaction_updates
605 .apply_sync_height_update(chain_tip_header.block_num(), self.tx_discard_delta);
606
607 for transaction in transactions {
608 state_sync_update
612 .note_updates
613 .apply_output_note_inclusion_proofs(&transaction.output_notes)?;
614
615 Self::mark_erased_notes_as_consumed(state_sync_update, transaction);
617 }
618
619 Ok(())
620 }
621
622 fn mark_erased_notes_as_consumed(
628 state_sync_update: &mut StateSyncUpdate,
629 transaction: &RpcTransactionRecord,
630 ) {
631 for note_header in &transaction.erased_output_notes {
632 let _ = state_sync_update
634 .note_updates
635 .mark_erased_note_as_consumed(note_header, transaction.block_num);
636 }
637 }
638
639 async fn account_state_sync(
652 &self,
653 account_updates: &mut AccountUpdates,
654 accounts: &[AccountHeader],
655 account_commitment_updates: &[(AccountId, Word)],
656 block_from: BlockNumber,
657 ) -> Result<Vec<Word>, ClientError> {
658 let (public_accounts, private_accounts): (Vec<_>, Vec<_>) =
661 accounts.iter().partition(|header| !header.id().is_private());
662
663 let superseded_states = self
664 .sync_public_accounts(
665 account_updates,
666 account_commitment_updates,
667 &public_accounts,
668 block_from,
669 )
670 .await?;
671
672 let mismatched_private_accounts = account_commitment_updates
673 .iter()
674 .filter(|(account_id, digest)| {
675 private_accounts
676 .iter()
677 .any(|header| header.id() == *account_id && &header.to_commitment() != digest)
678 })
679 .copied()
680 .collect::<Vec<_>>();
681
682 account_updates.extend(AccountUpdates::new(Vec::new(), mismatched_private_accounts));
683
684 Ok(superseded_states)
685 }
686
687 async fn sync_public_accounts(
696 &self,
697 account_updates: &mut AccountUpdates,
698 commitment_updates: &[(AccountId, Word)],
699 current_public_accounts: &[&AccountHeader],
700 block_from: BlockNumber,
701 ) -> Result<Vec<Word>, ClientError> {
702 let local_headers: BTreeMap<AccountId, &AccountHeader> =
703 current_public_accounts.iter().map(|header| (header.id(), *header)).collect();
704 let mut superseded_states = Vec::new();
706 for (id, commitment) in commitment_updates {
707 let Some(local_header) = local_headers.get(id).copied() else {
708 continue;
709 };
710
711 if local_header.to_commitment() == *commitment {
712 continue;
713 }
714
715 match self.sync_public_account(*id, local_header, block_from).await? {
716 PublicAccountSync::Apply(public_update) => {
717 account_updates.extend(AccountUpdates::new(vec![*public_update], Vec::new()));
718 },
719 PublicAccountSync::Superseded => {
720 superseded_states.push(local_header.to_commitment());
721 },
722 PublicAccountSync::Ignore => {},
723 }
724 }
725
726 Ok(superseded_states)
727 }
728
729 async fn sync_public_account(
743 &self,
744 account_id: AccountId,
745 local_header: &AccountHeader,
746 block_from: BlockNumber,
747 ) -> Result<PublicAccountSync, ClientError> {
748 let (proof_block_num, proof) = self
751 .rpc_api
752 .get_account(
753 account_id,
754 GetAccountRequest::new()
755 .with_storage(StorageMapFetch::All)
756 .with_vault(VaultFetch::Always),
757 )
758 .await
759 .map_err(ClientError::RpcError)?;
760
761 let details = proof.into_details().expect("node returned no details for a public account");
762 match details
763 .header
764 .nonce()
765 .as_canonical_u64()
766 .cmp(&local_header.nonce().as_canonical_u64())
767 {
768 Ordering::Less => return Ok(PublicAccountSync::Ignore),
771 Ordering::Equal => return Ok(PublicAccountSync::Superseded),
773 Ordering::Greater => {},
775 }
776
777 let vault_oversized = details.vault_details.too_many_assets;
778 let any_map_oversized =
779 details.storage_details.map_details.iter().any(|m| m.too_many_entries);
780
781 let public_update = if vault_oversized || any_map_oversized {
785 self.build_delta_update(account_id, &details, block_from, proof_block_num)
787 .await?
788 } else {
789 let account = Account::try_from(&details).map_err(ClientError::RpcError)?;
791 PublicAccountUpdate::Full(account)
792 };
793
794 Ok(PublicAccountSync::Apply(Box::new(public_update)))
795 }
796
797 async fn build_delta_update(
800 &self,
801 account_id: AccountId,
802 details: &AccountDetails,
803 block_from: BlockNumber,
804 block_to: BlockNumber,
805 ) -> Result<PublicAccountUpdate, ClientError> {
806 let value_slot_updates: Vec<(_, Word)> = details
807 .storage_details
808 .header
809 .slots()
810 .filter(|slot| slot.slot_type() == StorageSlotType::Value)
811 .map(|slot| (slot.name().clone(), slot.value()))
812 .collect();
813
814 let map_info = self
817 .rpc_api
818 .sync_storage_maps(block_from + 1, block_to, account_id)
819 .await
820 .map_err(ClientError::RpcError)?;
821 let vault_info = self
822 .rpc_api
823 .sync_account_vault(block_from + 1, block_to, account_id)
824 .await
825 .map_err(ClientError::RpcError)?;
826
827 Ok(PublicAccountUpdate::Delta(PublicAccountDelta::new(
828 details.header.clone(),
829 block_from,
830 block_to,
831 value_slot_updates,
832 map_info.updates,
833 vault_info.updates,
834 )))
835 }
836
837 async fn note_state_sync(
854 &self,
855 note_updates: &mut NoteUpdateTracker,
856 note_inclusions: BTreeMap<NoteId, CommittedNote>,
857 block_header: &BlockHeader,
858 public_notes: &BTreeMap<NoteId, InputNoteRecord>,
859 private_attachments: &BTreeMap<NoteId, NoteAttachments>,
860 ) -> Result<bool, ClientError> {
861 let mut found_relevant_note = false;
863
864 for (_, committed_note) in note_inclusions {
865 let public_note = (committed_note.note_type() != NoteType::Private)
866 .then(|| public_notes.get(committed_note.note_id()))
867 .flatten()
868 .cloned();
869
870 if !self.note_observers.is_empty() {
875 let note_attachments = if committed_note.note_type() == NoteType::Private {
879 private_attachments.get(committed_note.note_id())
880 } else {
881 public_note.as_ref().map(InputNoteRecord::attachments)
882 };
883 for obs in &self.note_observers {
884 match obs.observe(&committed_note, note_attachments).await {
885 Ok(true) => found_relevant_note = true,
886 Ok(false) => {},
887 Err(err) => {
888 tracing::warn!(
889 observer = obs.name(),
890 error = ?err,
891 "note observer failed; sync continues",
892 );
893 },
894 }
895 }
896 }
897
898 match self.note_screener.on_note_received(committed_note, public_note).await? {
899 NoteUpdateAction::Commit(committed_note) => {
900 let attachments = private_attachments.get(committed_note.note_id());
904 found_relevant_note |= note_updates.apply_committed_note_state_transitions(
905 &committed_note,
906 block_header,
907 attachments,
908 )?;
909 },
910 NoteUpdateAction::Insert(public_note) => {
911 found_relevant_note = true;
912
913 note_updates.apply_new_public_note(public_note, block_header)?;
914 },
915 NoteUpdateAction::Discard => {},
916 }
917 }
918
919 Ok(found_relevant_note)
920 }
921
922 async fn nullifiers_state_sync(
928 &self,
929 state_sync_update: &mut StateSyncUpdate,
930 current_block_num: BlockNumber,
931 ) -> Result<(), ClientError> {
932 let nullifiers_tags: Vec<u16> = state_sync_update
938 .note_updates
939 .unspent_nullifiers()
940 .map(|nullifier| nullifier.prefix())
941 .collect();
942
943 let mut new_nullifiers = self
944 .rpc_api
945 .sync_nullifiers(&nullifiers_tags, current_block_num + 1, state_sync_update.block_num)
946 .await?;
947
948 new_nullifiers.retain(|update| update.block_num <= state_sync_update.block_num);
951
952 let consumptions: Vec<NoteConsumption> = new_nullifiers
954 .into_iter()
955 .map(|update| NoteConsumption {
956 external_consumer: state_sync_update
957 .transaction_updates
958 .external_nullifier_account(&update.nullifier),
959 nullifier: update.nullifier,
960 block_num: update.block_num,
961 })
962 .collect();
963
964 for consumption in consumptions {
965 state_sync_update.note_updates.apply_note_consumption(
966 &consumption,
967 state_sync_update.transaction_updates.committed_transactions(),
968 )?;
969
970 state_sync_update
974 .transaction_updates
975 .apply_input_note_nullified(consumption.nullifier);
976 }
977
978 Ok(())
979 }
980
981 fn build_public_note_records(
984 synced_notes: BTreeMap<NoteId, SyncedNoteDetails>,
985 note_blocks: &[NoteSyncBlock],
986 ) -> BTreeMap<NoteId, InputNoteRecord> {
987 let mut records = BTreeMap::new();
988 for (note_id, synced) in synced_notes {
989 let SyncedNoteDetails::Public(note) = synced else {
990 continue;
991 };
992 let inclusion_proof = note_blocks
993 .iter()
994 .find_map(|b| b.notes.get(¬e_id))
995 .map(|committed| committed.inclusion_proof().clone());
996
997 if let Some(inclusion_proof) = inclusion_proof {
998 let state = crate::store::input_note_states::UnverifiedNoteState {
999 metadata: *note.metadata(),
1000 inclusion_proof,
1001 }
1002 .into();
1003 let attachments = note.attachments().clone();
1004 let record = InputNoteRecord::new(note.into(), attachments, None, state);
1005 let id = record.id().expect("CommittedNoteState carries metadata, so id() is Some");
1006 records.insert(id, record);
1007 }
1008 }
1009 records
1010 }
1011}
1012
1013fn group_txs_by_account_block(
1018 transaction_records: &[RpcTransactionRecord],
1019) -> BTreeMap<(AccountId, BlockNumber), Vec<&RpcTransactionRecord>> {
1020 let mut groups: BTreeMap<(AccountId, BlockNumber), Vec<&RpcTransactionRecord>> =
1021 BTreeMap::new();
1022 for record in transaction_records {
1023 let account_id = record.transaction_header.account_id();
1024 groups.entry((account_id, record.block_num)).or_default().push(record);
1025 }
1026 groups
1027}
1028
1029fn walk_execution_chain<'a>(
1035 txs: &'a [&'a RpcTransactionRecord],
1036) -> impl Iterator<Item = &'a RpcTransactionRecord> + 'a {
1037 let (self_loops, chained): (Vec<&RpcTransactionRecord>, Vec<&RpcTransactionRecord>) =
1038 txs.iter().copied().partition(|tx| {
1039 tx.transaction_header.initial_state_commitment()
1040 == tx.transaction_header.final_state_commitment()
1041 });
1042
1043 let final_states: BTreeSet<Word> = chained
1044 .iter()
1045 .map(|tx| tx.transaction_header.final_state_commitment())
1046 .collect();
1047
1048 let mut init_to_tx: BTreeMap<Word, &RpcTransactionRecord> = chained
1049 .iter()
1050 .map(|tx| (tx.transaction_header.initial_state_commitment(), *tx))
1051 .collect();
1052
1053 let start = chained
1054 .iter()
1055 .find(|tx| !final_states.contains(&tx.transaction_header.initial_state_commitment()))
1056 .copied();
1057
1058 assert!(start.is_some() || chained.is_empty(), "cannot walk cyclic execution chain");
1059
1060 let mut current =
1061 start.and_then(|tx| init_to_tx.remove(&tx.transaction_header.initial_state_commitment()));
1062 let mut self_loops_iter = self_loops.into_iter();
1063
1064 core::iter::from_fn(move || {
1065 if let Some(tx) = current {
1066 current = init_to_tx.remove(&tx.transaction_header.final_state_commitment());
1067 return Some(tx);
1068 }
1069 self_loops_iter.next()
1070 })
1071}
1072
1073fn derive_account_commitments(
1078 transaction_records: &[RpcTransactionRecord],
1079) -> Vec<(AccountId, Word)> {
1080 let mut latest_by_account: BTreeMap<AccountId, (BlockNumber, Word)> = BTreeMap::new();
1081
1082 for ((account_id, block_num), txs) in &group_txs_by_account_block(transaction_records) {
1083 let terminal_state = walk_execution_chain(txs)
1084 .last()
1085 .expect("account must have a final state")
1086 .transaction_header
1087 .final_state_commitment();
1088
1089 latest_by_account
1090 .entry(*account_id)
1091 .and_modify(|(existing_block, existing_state)| {
1092 if *block_num > *existing_block {
1093 *existing_block = *block_num;
1094 *existing_state = terminal_state;
1095 }
1096 })
1097 .or_insert((*block_num, terminal_state));
1098 }
1099
1100 latest_by_account
1101 .into_iter()
1102 .map(|(account_id, (_, state))| (account_id, state))
1103 .collect()
1104}
1105
1106fn compute_ordered_nullifiers(transaction_records: &[RpcTransactionRecord]) -> Vec<Nullifier> {
1113 let mut result = Vec::new();
1114
1115 for txs in group_txs_by_account_block(transaction_records).values() {
1116 for tx in walk_execution_chain(txs) {
1117 for commitment in tx.transaction_header.input_notes().iter() {
1118 result.push(commitment.nullifier());
1119 }
1120 }
1121 }
1122
1123 result
1124}
1125
1126#[cfg(all(test, feature = "testing"))]
1127mod tests {
1128 use alloc::collections::BTreeSet;
1129 use alloc::sync::Arc;
1130
1131 use async_trait::async_trait;
1132 use miden_protocol::account::Account;
1133 use miden_protocol::assembly::DefaultSourceManager;
1134 use miden_protocol::asset::{Asset, FungibleAsset};
1135 use miden_protocol::block::BlockNumber;
1136 use miden_protocol::crypto::merkle::MerklePath;
1137 use miden_protocol::crypto::merkle::mmr::{Forest, InOrderIndex, PartialMmr};
1138 use miden_protocol::note::{
1139 Note,
1140 NoteAssets,
1141 NoteAttachment,
1142 NoteAttachments,
1143 NoteDetails,
1144 NoteHeader,
1145 NoteMetadata,
1146 NoteRecipient,
1147 NoteStorage,
1148 NoteTag,
1149 NoteType,
1150 PartialNoteMetadata,
1151 };
1152 use miden_protocol::testing::account_id::{
1153 ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET,
1154 ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
1155 ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE,
1156 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
1157 ACCOUNT_ID_SENDER,
1158 };
1159 use miden_protocol::transaction::{InputNotes, TransactionArgs, TransactionHeader};
1160 use miden_protocol::vm::AdviceMap;
1161 use miden_protocol::{EMPTY_WORD, Felt, Word, ZERO};
1162 use miden_standards::code_builder::CodeBuilder;
1163 use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint};
1164 use miden_testing::{MockChainBuilder, TxContextInput};
1165
1166 use super::*;
1167 use crate::rpc::domain::transaction::ACCOUNT_ID_NATIVE_ASSET_FAUCET;
1168 use crate::store::{OutputNoteRecord, OutputNoteState};
1169 use crate::test_utils::mock::MockRpcApi;
1170
1171 struct MockScreener;
1173
1174 #[async_trait(?Send)]
1175 impl OnNoteReceived for MockScreener {
1176 async fn on_note_received(
1177 &self,
1178 _committed_note: CommittedNote,
1179 _public_note: Option<InputNoteRecord>,
1180 ) -> Result<NoteUpdateAction, ClientError> {
1181 Ok(NoteUpdateAction::Discard)
1182 }
1183 }
1184
1185 fn empty() -> StateSyncInput {
1186 StateSyncInput {
1187 accounts: vec![],
1188 note_tags: BTreeSet::new(),
1189 input_notes: vec![],
1190 output_notes: vec![],
1191 uncommitted_transactions: vec![],
1192 }
1193 }
1194
1195 fn word(n: u64) -> miden_protocol::Word {
1196 [
1197 Felt::new(n).expect("test value should fit into the base field"),
1198 ZERO,
1199 ZERO,
1200 ZERO,
1201 ]
1202 .into()
1203 }
1204
1205 #[tokio::test]
1206 async fn sync_public_accounts_ignores_older_node_snapshot() {
1207 let mut builder = MockChainBuilder::new();
1208 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1209 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1210 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1211
1212 let local_header =
1215 AccountHeader::new(account.id(), Felt::from(2u32), EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
1216 let current_public_accounts = vec![&local_header];
1217 let commitment_updates = vec![(account.id(), account.to_commitment())];
1218 let mut account_updates = AccountUpdates::default();
1219
1220 let superseded = state_sync
1221 .sync_public_accounts(
1222 &mut account_updates,
1223 &commitment_updates,
1224 ¤t_public_accounts,
1225 BlockNumber::GENESIS,
1226 )
1227 .await
1228 .unwrap();
1229
1230 assert!(
1231 account_updates.updated_public_accounts().is_empty(),
1232 "public account sync should ignore node snapshots that are older than local"
1233 );
1234 assert!(
1235 superseded.is_empty(),
1236 "an older node snapshot must not supersede the local state"
1237 );
1238 }
1239
1240 #[tokio::test]
1241 async fn sync_public_accounts_marks_same_nonce_mismatch_as_superseded() {
1242 let mut builder = MockChainBuilder::new();
1243 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1244 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1245 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1246
1247 let local_header =
1250 AccountHeader::new(account.id(), account.nonce(), EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
1251 let current_public_accounts = vec![&local_header];
1252 let commitment_updates = vec![(account.id(), account.to_commitment())];
1253 let mut account_updates = AccountUpdates::default();
1254
1255 let superseded = state_sync
1256 .sync_public_accounts(
1257 &mut account_updates,
1258 &commitment_updates,
1259 ¤t_public_accounts,
1260 BlockNumber::GENESIS,
1261 )
1262 .await
1263 .unwrap();
1264
1265 assert!(
1266 account_updates.updated_public_accounts().is_empty(),
1267 "a same-nonce fork must not overwrite the account while its tx is still pending"
1268 );
1269 assert_eq!(
1270 superseded,
1271 vec![local_header.to_commitment()],
1272 "the superseded local state should be reported so its transaction is discarded"
1273 );
1274 }
1275
1276 mod compute_nullifiers_tests {
1280 use alloc::vec;
1281
1282 use miden_protocol::asset::FungibleAsset;
1283 use miden_protocol::block::BlockNumber;
1284 use miden_protocol::note::Nullifier;
1285 use miden_protocol::transaction::{InputNoteCommitment, InputNotes, TransactionHeader};
1286
1287 use super::word;
1288 use crate::rpc::domain::transaction::{
1289 ACCOUNT_ID_NATIVE_ASSET_FAUCET,
1290 TransactionRecord as RpcTransactionRecord,
1291 };
1292
1293 fn make_rpc_tx(
1294 init_state: u64,
1295 final_state: u64,
1296 nullifier_vals: &[u64],
1297 block_number: u32,
1298 ) -> RpcTransactionRecord {
1299 let account_id = miden_protocol::account::AccountId::try_from(
1300 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE,
1301 )
1302 .unwrap();
1303
1304 let input_notes = InputNotes::new_unchecked(
1305 nullifier_vals
1306 .iter()
1307 .map(|v| InputNoteCommitment::from(Nullifier::from_raw(word(*v))))
1308 .collect(),
1309 );
1310
1311 let fee =
1312 FungibleAsset::new(ACCOUNT_ID_NATIVE_ASSET_FAUCET.try_into().expect("valid"), 0u64)
1313 .unwrap();
1314
1315 RpcTransactionRecord {
1316 block_num: BlockNumber::from(block_number),
1317 transaction_header: TransactionHeader::new(
1318 account_id,
1319 word(init_state),
1320 word(final_state),
1321 input_notes,
1322 vec![],
1323 fee,
1324 ),
1325 output_notes: vec![],
1326 erased_output_notes: vec![],
1327 }
1328 }
1329
1330 #[test]
1331 fn chains_rpc_transactions_by_state_commitment() {
1332 let tx_a = make_rpc_tx(1, 2, &[10], 5);
1335 let tx_b = make_rpc_tx(2, 3, &[20], 5);
1336 let tx_c = make_rpc_tx(3, 4, &[30], 5);
1337
1338 let result = super::super::compute_ordered_nullifiers(&[tx_c, tx_a, tx_b]);
1339
1340 assert_eq!(result[0], Nullifier::from_raw(word(10)));
1341 assert_eq!(result[1], Nullifier::from_raw(word(20)));
1342 assert_eq!(result[2], Nullifier::from_raw(word(30)));
1343 }
1344
1345 #[test]
1346 fn groups_independently_by_account_and_block() {
1347 let tx_a1 = make_rpc_tx(1, 2, &[10], 5);
1349 let tx_a2 = make_rpc_tx(2, 3, &[20], 5);
1350
1351 let tx_a3 = make_rpc_tx(3, 4, &[30], 6);
1353
1354 let account_b = miden_protocol::account::AccountId::try_from(
1356 miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
1357 )
1358 .unwrap();
1359
1360 let fee =
1361 FungibleAsset::new(ACCOUNT_ID_NATIVE_ASSET_FAUCET.try_into().expect("valid"), 0u64)
1362 .unwrap();
1363
1364 let tx_b1 = RpcTransactionRecord {
1365 block_num: BlockNumber::from(5u32),
1366 transaction_header: TransactionHeader::new(
1367 account_b,
1368 word(100),
1369 word(200),
1370 InputNotes::new_unchecked(vec![InputNoteCommitment::from(
1371 Nullifier::from_raw(word(40)),
1372 )]),
1373 vec![],
1374 fee,
1375 ),
1376 output_notes: vec![],
1377 erased_output_notes: vec![],
1378 };
1379
1380 let result = super::super::compute_ordered_nullifiers(&[tx_a2, tx_b1, tx_a3, tx_a1]);
1381
1382 let pos = |val: u64| -> usize {
1385 result.iter().position(|n| *n == Nullifier::from_raw(word(val))).unwrap()
1386 };
1387
1388 assert!(pos(10) < pos(20)); assert!(result.contains(&Nullifier::from_raw(word(30)))); assert!(result.contains(&Nullifier::from_raw(word(40)))); }
1394
1395 #[test]
1396 fn multiple_nullifiers_per_transaction_are_consecutive() {
1397 let tx = make_rpc_tx(1, 2, &[10, 20, 30], 5);
1399
1400 let result = super::super::compute_ordered_nullifiers(&[tx]);
1401
1402 assert_eq!(result.len(), 3);
1403 assert!(result.contains(&Nullifier::from_raw(word(10))));
1404 assert!(result.contains(&Nullifier::from_raw(word(20))));
1405 assert!(result.contains(&Nullifier::from_raw(word(30))));
1406 }
1407
1408 #[test]
1409 fn empty_input_returns_empty_vec() {
1410 let result = super::super::compute_ordered_nullifiers(&[]);
1411 assert!(result.is_empty());
1412 }
1413 }
1414
1415 #[test]
1426 fn derive_account_commitments_walks_chains_per_account() {
1427 let fee =
1428 FungibleAsset::new(ACCOUNT_ID_NATIVE_ASSET_FAUCET.try_into().expect("valid"), 0u64)
1429 .unwrap();
1430 let make_tx = |account: AccountId, init_state: u64, final_state: u64, block_num: u32| {
1431 RpcTransactionRecord {
1432 block_num: BlockNumber::from(block_num),
1433 transaction_header: TransactionHeader::new(
1434 account,
1435 word(init_state),
1436 word(final_state),
1437 InputNotes::new_unchecked(vec![]),
1438 vec![],
1439 fee,
1440 ),
1441 output_notes: vec![],
1442 erased_output_notes: vec![],
1443 }
1444 };
1445
1446 let account_a: AccountId =
1447 ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE.try_into().unwrap();
1448 let account_b: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap();
1449
1450 let tx_a_b5_1 = make_tx(account_a, 1, 2, 5);
1451 let tx_a_b5_2 = make_tx(account_a, 2, 3, 5);
1452 let tx_a_b6_1 = make_tx(account_a, 3, 4, 6);
1453 let tx_a_b6_2 = make_tx(account_a, 4, 5, 6);
1454 let tx_b_b6 = make_tx(account_b, 10, 20, 6);
1455
1456 let result = super::derive_account_commitments(&[
1458 tx_a_b6_1, tx_b_b6, tx_a_b5_2, tx_a_b6_2, tx_a_b5_1,
1459 ]);
1460
1461 assert_eq!(result.len(), 2, "one entry per account");
1462 assert!(
1463 result.contains(&(account_a, word(5))),
1464 "account A: must walk block 6's chain, not return block 5 or an intermediate",
1465 );
1466 assert!(
1467 result.contains(&(account_b, word(20))),
1468 "account B: must be resolved independently of account A",
1469 );
1470 }
1471
1472 struct CommitAllScreener;
1478
1479 #[async_trait(?Send)]
1480 impl OnNoteReceived for CommitAllScreener {
1481 async fn on_note_received(
1482 &self,
1483 committed_note: CommittedNote,
1484 _public_note: Option<InputNoteRecord>,
1485 ) -> Result<NoteUpdateAction, ClientError> {
1486 Ok(NoteUpdateAction::Commit(committed_note))
1487 }
1488 }
1489
1490 async fn build_chain_with_chained_consume_txs() -> (miden_testing::MockChain, Account, [Note; 3])
1494 {
1495 let sender_id: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
1496 let faucet_id: AccountId = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into().unwrap();
1497
1498 let mut builder = MockChainBuilder::new();
1499 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1500 let account_id = account.id();
1501
1502 let asset = Asset::Fungible(FungibleAsset::new(faucet_id, 100u64).unwrap());
1503 let note1 = builder
1504 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
1505 .unwrap();
1506 let note2 = builder
1507 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
1508 .unwrap();
1509 let note3 = builder
1510 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
1511 .unwrap();
1512
1513 let mut chain = builder.build().unwrap();
1514 chain.prove_next_block().unwrap(); let mut current_account = account.clone();
1518 for note in [¬e1, ¬e2, ¬e3] {
1519 let tx = Box::pin(
1520 chain
1521 .build_tx_context(
1522 TxContextInput::Account(current_account.clone()),
1523 &[],
1524 core::slice::from_ref(note),
1525 )
1526 .unwrap()
1527 .build()
1528 .unwrap()
1529 .execute(),
1530 )
1531 .await
1532 .unwrap();
1533 current_account.apply_delta(tx.account_delta()).unwrap();
1534 chain.add_pending_executed_transaction(&tx).unwrap();
1535 }
1536
1537 chain.prove_next_block().unwrap(); (chain, account, [note1, note2, note3])
1539 }
1540
1541 #[tokio::test]
1544 async fn sync_state_sets_consumed_tx_order_for_chained_transactions() {
1545 use miden_protocol::note::NoteMetadata;
1546
1547 let (chain, account, [note1, note2, note3]) = build_chain_with_chained_consume_txs().await;
1548
1549 let mock_rpc = MockRpcApi::new(chain);
1550 let state_sync =
1551 StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(CommitAllScreener), None);
1552
1553 let genesis_peaks =
1554 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
1555 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
1556
1557 let input_notes: Vec<InputNoteRecord> = [¬e1, ¬e2, ¬e3]
1558 .into_iter()
1559 .map(|n| InputNoteRecord::from(n.clone()))
1560 .collect();
1561
1562 let note_tags: BTreeSet<NoteTag> =
1563 input_notes.iter().filter_map(|n| n.metadata().map(NoteMetadata::tag)).collect();
1564
1565 let account_id = account.id();
1566 let sync_input = StateSyncInput {
1567 accounts: vec![AccountHeader::from(account)],
1568 note_tags,
1569 input_notes,
1570 output_notes: vec![],
1571 uncommitted_transactions: vec![],
1572 };
1573
1574 let update = state_sync.sync_state(&mut partial_mmr, sync_input).await.unwrap();
1575
1576 let updated_notes: Vec<_> = update.note_updates.updated_input_notes().collect();
1577
1578 let find_order = |details_commitment| -> Option<u32> {
1579 updated_notes
1580 .iter()
1581 .find(|n| n.inner().details_commitment() == details_commitment)
1582 .and_then(|n| n.consumed_tx_order())
1583 };
1584
1585 assert_eq!(find_order(note1.details_commitment()), Some(0), "note1 should have tx_order 0");
1586 assert_eq!(find_order(note2.details_commitment()), Some(1), "note2 should have tx_order 1");
1587 assert_eq!(find_order(note3.details_commitment()), Some(2), "note3 should have tx_order 2");
1588
1589 for note in &updated_notes {
1592 let record = note.inner();
1593 assert!(record.is_consumed(), "note should be in a consumed state");
1594 assert_eq!(
1595 record.consumer_account(),
1596 Some(account_id),
1597 "externally-consumed notes by a tracked account should have consumer_account set",
1598 );
1599 }
1600 }
1601
1602 #[tokio::test]
1603 async fn sync_state_across_multiple_iterations_with_same_mmr() {
1604 let mock_rpc = MockRpcApi::default();
1606 mock_rpc.advance_blocks(3);
1607 let chain_tip_1 = mock_rpc.get_chain_tip_block_num();
1608
1609 let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
1610
1611 let genesis_peaks =
1613 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
1614 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
1615 assert_eq!(partial_mmr.forest().num_leaves(), 1);
1616
1617 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
1619
1620 assert_eq!(update.block_num, chain_tip_1);
1621 let forest_1 = partial_mmr.forest();
1622 assert_eq!(forest_1.num_leaves(), chain_tip_1.as_u32() as usize + 1);
1624
1625 mock_rpc.advance_blocks(2);
1627 let chain_tip_2 = mock_rpc.get_chain_tip_block_num();
1628
1629 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
1630
1631 assert_eq!(update.block_num, chain_tip_2);
1632 let forest_2 = partial_mmr.forest();
1633 assert!(forest_2 > forest_1);
1634 assert_eq!(forest_2.num_leaves(), chain_tip_2.as_u32() as usize + 1);
1635
1636 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
1638
1639 assert_eq!(update.block_num, chain_tip_2);
1640 assert_eq!(partial_mmr.forest(), forest_2);
1641 }
1642
1643 async fn build_chain_with_mint_notes(
1646 num_blocks: u64,
1647 ) -> (miden_testing::MockChain, BTreeSet<NoteTag>) {
1648 let mut builder = MockChainBuilder::new();
1649 let faucet = builder
1650 .add_existing_basic_faucet(
1651 miden_testing::Auth::BasicAuth {
1652 auth_scheme: miden_protocol::account::auth::AuthScheme::Falcon512Poseidon2,
1653 },
1654 "TST",
1655 10_000,
1656 None,
1657 )
1658 .unwrap();
1659 let _target = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1660 let mut chain = builder.build().unwrap();
1661
1662 let note_script = CodeBuilder::new()
1667 .compile_note_script("@note_script\npub proc main\n nop\nend")
1668 .unwrap();
1669 let note_recipient = NoteRecipient::new(
1670 Word::from([1u32, 2, 3, 4]),
1671 note_script,
1672 NoteStorage::new(vec![]).unwrap(),
1673 );
1674 let recipient = note_recipient.digest();
1675 let note_details = NoteDetails::new(NoteAssets::new(vec![]).unwrap(), note_recipient);
1678 let mut recipient_args = TransactionArgs::new(AdviceMap::default());
1679 recipient_args.add_output_note_recipient(¬e_details);
1680 let recipient_advice = recipient_args.advice_inputs().clone();
1681
1682 let tag = NoteTag::default();
1683 let mut faucet_account = faucet.clone();
1684 let mut note_tags = BTreeSet::new();
1685
1686 for i in 0..num_blocks {
1687 let amount = 100 + i;
1688 let source_manager = Arc::new(DefaultSourceManager::default());
1689 let tx_script_code = format!(
1695 "
1696 begin
1697 push.{recipient}
1698 push.{note_type}
1699 push.{tag}
1700 push.{amount}
1701 push.{faucet_id_prefix}
1702 push.{faucet_id_suffix}
1703 push.1
1704 exec.::miden::protocol::asset::create_fungible_asset
1705 call.::miden::standards::faucets::fungible::mint_and_send
1706 dropw dropw dropw dropw
1707 end
1708 ",
1709 recipient = recipient,
1710 note_type = NoteType::Private as u8,
1711 tag = u32::from(tag),
1712 amount = amount,
1713 faucet_id_prefix = faucet_account.id().prefix().as_felt(),
1714 faucet_id_suffix = faucet_account.id().suffix(),
1715 );
1716 let tx_script = CodeBuilder::with_source_manager(source_manager.clone())
1717 .compile_tx_script(tx_script_code)
1718 .unwrap();
1719 let tx = Box::pin(
1720 chain
1721 .build_tx_context(
1722 miden_testing::TxContextInput::Account(faucet_account.clone()),
1723 &[],
1724 &[],
1725 )
1726 .unwrap()
1727 .extend_advice_inputs(recipient_advice.clone())
1728 .tx_script(tx_script)
1729 .with_source_manager(source_manager)
1730 .build()
1731 .unwrap()
1732 .execute(),
1733 )
1734 .await
1735 .unwrap();
1736
1737 for output_note in tx.output_notes().iter() {
1738 note_tags.insert(output_note.metadata().tag());
1739 }
1740
1741 faucet_account.apply_delta(tx.account_delta()).unwrap();
1742 chain.add_pending_executed_transaction(&tx).unwrap();
1743 chain.prove_next_block().unwrap();
1744 }
1745
1746 (chain, note_tags)
1747 }
1748
1749 #[tokio::test]
1759 async fn sync_state_tracks_note_blocks_in_mmr() {
1760 let (chain, note_tags) = build_chain_with_mint_notes(3).await;
1761 let mock_rpc = MockRpcApi::new(chain);
1762 let chain_tip = mock_rpc.get_chain_tip_block_num();
1763
1764 let note_blocks = mock_rpc
1766 .sync_notes(BlockNumber::from(0u32), chain_tip, ¬e_tags)
1767 .await
1768 .unwrap();
1769 assert!(
1770 note_blocks.len() >= 2,
1771 "expected notes in multiple blocks, got {}",
1772 note_blocks.len()
1773 );
1774
1775 let note_block_nums: BTreeSet<BlockNumber> =
1777 note_blocks.iter().map(|b| b.block_header.block_num()).collect();
1778
1779 let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
1782
1783 let genesis_peaks =
1784 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
1785 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
1786
1787 let sync_data = state_sync
1788 .fetch_sync_data(BlockNumber::GENESIS, &[], &Arc::new(note_tags.clone()))
1789 .await
1790 .unwrap()
1791 .expect("should have progressed past genesis");
1792
1793 assert_eq!(sync_data.chain_tip_header.block_num(), chain_tip);
1795 assert!(!sync_data.note_blocks.is_empty(), "should have note blocks");
1796
1797 let _auth_nodes: Vec<(InOrderIndex, Word)> =
1799 partial_mmr.apply(sync_data.mmr_delta).map_err(StoreError::MmrError).unwrap();
1800 partial_mmr
1801 .add(sync_data.chain_tip_header.commitment(), false)
1802 .expect("chain tip should append to the partial MMR");
1803
1804 assert_eq!(partial_mmr.forest().num_leaves(), chain_tip.as_u32() as usize + 1);
1805
1806 for block in &sync_data.note_blocks {
1808 let bn = block.block_header.block_num();
1809 partial_mmr
1810 .track(bn.as_usize(), block.block_header.commitment(), &block.mmr_path)
1811 .map_err(StoreError::MmrError)
1812 .unwrap();
1813
1814 assert!(
1815 partial_mmr.is_tracked(bn.as_usize()),
1816 "block {bn} should be tracked after calling track()"
1817 );
1818 }
1819
1820 for &bn in ¬e_block_nums {
1822 assert!(
1823 partial_mmr.is_tracked(bn.as_usize()),
1824 "block {bn} with notes should be tracked in partial MMR"
1825 );
1826 }
1827 }
1828
1829 #[tokio::test]
1830 async fn sync_notes_with_details_fetches_inclusive_upper_bound_page() {
1831 let (chain, note_tags) = build_chain_with_mint_notes(10).await;
1832 let mock_rpc = MockRpcApi::new(chain);
1833
1834 let (blocks, _synced_notes) = mock_rpc
1835 .sync_notes_with_details(4_u32.into(), 10_u32.into(), ¬e_tags)
1836 .await
1837 .expect("sync notes should succeed");
1838
1839 assert_eq!(blocks.last().unwrap().block_header.block_num(), BlockNumber::from(10u32));
1840 assert!(
1841 blocks
1842 .iter()
1843 .any(|block| block.block_header.block_num() == BlockNumber::from(9u32))
1844 );
1845 }
1846
1847 #[tokio::test]
1853 async fn erased_notes_are_marked_as_consumed() {
1854 let sender_id: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
1856 let partial_metadata = PartialNoteMetadata::new(sender_id, NoteType::Public);
1857 let metadata = NoteMetadata::new(partial_metadata, &NoteAttachments::empty());
1858 let script = CodeBuilder::new()
1859 .compile_note_script("@note_script\npub proc main\n nop\nend")
1860 .unwrap();
1861 let recipient = NoteRecipient::new(
1862 Word::from([1u32, 2, 3, 4]),
1863 script,
1864 NoteStorage::new(vec![]).unwrap(),
1865 );
1866 let output_note = OutputNoteRecord::new(
1867 recipient.digest(),
1868 NoteAssets::new(vec![]).unwrap(),
1869 metadata,
1870 OutputNoteState::ExpectedFull { recipient },
1871 BlockNumber::from(1u32),
1872 NoteAttachments::default(),
1873 );
1874 let note_id = output_note.id();
1875 let note_header = NoteHeader::new(output_note.details_commitment(), metadata);
1876
1877 let mut note_updates = NoteUpdateTracker::new(vec![], vec![output_note]);
1879
1880 let block_num = BlockNumber::from(3u32);
1882 note_updates
1883 .mark_erased_note_as_consumed(¬e_header, block_num)
1884 .expect("marking erased note should succeed");
1885
1886 let updated = note_updates
1887 .updated_output_notes()
1888 .find(|n| n.id() == note_id)
1889 .expect("output note should be in the update");
1890
1891 assert!(
1892 updated.inner().is_consumed(),
1893 "output note should be consumed after erasure detection, but state is: {}",
1894 updated.inner().state()
1895 );
1896 }
1897
1898 #[allow(clippy::too_many_lines)]
1915 #[ignore = "consumer derivation removed; see comment above"]
1916 #[tokio::test]
1917 async fn erased_notes_are_marked_as_consumed_by_network_account() {
1918 let mut builder = MockChainBuilder::new();
1921 let p2id_sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
1922 let faucet_id: AccountId = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into().unwrap();
1923 let sender_account =
1924 builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1925 let sender_id = sender_account.id();
1926
1927 let asset = Asset::Fungible(FungibleAsset::new(faucet_id, 100u64).unwrap());
1928 let note = builder
1929 .add_p2id_note(p2id_sender, sender_id, &[asset], NoteType::Public)
1930 .unwrap();
1931
1932 let mut chain = builder.build().unwrap();
1933 chain.prove_next_block().unwrap();
1934
1935 let tx = Box::pin(
1936 chain
1937 .build_tx_context(
1938 TxContextInput::Account(sender_account.clone()),
1939 &[],
1940 core::slice::from_ref(¬e),
1941 )
1942 .unwrap()
1943 .build()
1944 .unwrap()
1945 .execute(),
1946 )
1947 .await
1948 .unwrap();
1949 chain.add_pending_executed_transaction(&tx).unwrap();
1950 chain.prove_next_block().unwrap();
1951
1952 let network_account_id: AccountId =
1954 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into().unwrap();
1955 let target =
1956 NetworkAccountTarget::new(network_account_id, NoteExecutionHint::Always).unwrap();
1957 let attachment: NoteAttachment = target.into();
1958 let attachments = NoteAttachments::new(vec![attachment]).unwrap();
1959 let partial_metadata = PartialNoteMetadata::new(sender_id, NoteType::Public);
1960 let metadata = NoteMetadata::new(partial_metadata, &attachments);
1961 let script = CodeBuilder::new()
1962 .compile_note_script("@note_script\npub proc main\n nop\nend")
1963 .unwrap();
1964 let recipient = NoteRecipient::new(
1965 Word::from([7u32, 8, 9, 10]),
1966 script,
1967 NoteStorage::new(vec![]).unwrap(),
1968 );
1969 let recipient_digest = recipient.digest();
1970 let assets = NoteAssets::new(vec![]).unwrap();
1971
1972 let output_note = OutputNoteRecord::new(
1975 recipient_digest,
1976 assets.clone(),
1977 metadata,
1978 OutputNoteState::ExpectedFull { recipient },
1979 BlockNumber::from(1u32),
1980 NoteAttachments::default(),
1981 );
1982 let erased_note_id = output_note.id();
1983 let erased_note_header = NoteHeader::new(output_note.details_commitment(), metadata);
1984
1985 let mock_rpc = MockRpcApi::new(chain);
1986 mock_rpc.mark_note_as_erased(erased_note_header);
1987
1988 let network_header =
1991 AccountHeader::new(network_account_id, ZERO, EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
1992
1993 let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
1994
1995 let genesis_peaks =
1996 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
1997 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
1998
1999 let sync_input = StateSyncInput {
2000 accounts: vec![AccountHeader::from(sender_account), network_header],
2001 note_tags: BTreeSet::new(),
2002 input_notes: vec![],
2003 output_notes: vec![output_note],
2004 uncommitted_transactions: vec![],
2005 };
2006
2007 let update = state_sync.sync_state(&mut partial_mmr, sync_input).await.unwrap();
2008
2009 let updated_output = update
2011 .note_updates
2012 .updated_output_notes()
2013 .find(|n| n.id() == erased_note_id)
2014 .expect("output note should be in the update");
2015 assert!(
2016 updated_output.inner().is_consumed(),
2017 "output note should be consumed, got: {}",
2018 updated_output.inner().state()
2019 );
2020
2021 let input_note_update = update
2023 .note_updates
2024 .updated_input_notes()
2025 .find(|n| n.id() == Some(erased_note_id))
2026 .expect("input note should be created from the erased output note");
2027
2028 let inner = input_note_update.inner();
2029 assert!(
2030 inner.is_consumed(),
2031 "input note should be in a consumed state, got: {}",
2032 inner.state()
2033 );
2034 assert_eq!(
2035 inner.consumer_account(),
2036 Some(network_account_id),
2037 "consumer should be the tracked network account"
2038 );
2039 }
2040
2041 #[tokio::test]
2044 async fn validate_chain_mmr_response_rejects_tampered_responses() {
2045 let mock_rpc = MockRpcApi::default();
2046 mock_rpc.advance_blocks(3);
2047 let chain_tip = mock_rpc.get_chain_tip_block_num();
2048 let current = BlockNumber::GENESIS;
2049
2050 let header_of =
2051 |block_num: u32| mock_rpc.mock_chain.read().block_header(block_num as usize);
2052 let chain_mmr_response = || async {
2053 mock_rpc.sync_chain_mmr(current, SyncTarget::CommittedChainTip).await.unwrap()
2054 };
2055
2056 let response = chain_mmr_response().await;
2058 StateSync::validate_chain_mmr_response(&response, current).unwrap();
2059
2060 let mut response = chain_mmr_response().await;
2062 response.block_header = header_of(chain_tip.as_u32() - 1);
2063 let result = StateSync::validate_chain_mmr_response(&response, current);
2064 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2065
2066 let mut response = chain_mmr_response().await;
2068 response.block_from = current + 1;
2069 let result = StateSync::validate_chain_mmr_response(&response, current);
2070 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2071
2072 let mut response = chain_mmr_response().await;
2074 response.block_from = chain_tip;
2075 response.block_to = BlockNumber::GENESIS;
2076 response.block_header = header_of(0);
2077 let result = StateSync::validate_chain_mmr_response(&response, chain_tip);
2078 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2079 }
2080
2081 #[test]
2084 fn validate_note_blocks_range_rejects_out_of_range_blocks() {
2085 let mock_rpc = MockRpcApi::default();
2086 mock_rpc.advance_blocks(3);
2087 let chain_tip = mock_rpc.get_chain_tip_block_num();
2088 let current = BlockNumber::GENESIS;
2089
2090 StateSync::validate_note_blocks_range(&[], current, chain_tip).unwrap();
2092
2093 let genesis_note_block = NoteSyncBlock {
2095 block_header: mock_rpc.mock_chain.read().block_header(0),
2096 mmr_path: MerklePath::new(Vec::new()),
2097 notes: BTreeMap::new(),
2098 };
2099 let result =
2100 StateSync::validate_note_blocks_range(&[genesis_note_block], current, chain_tip);
2101 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2102 }
2103
2104 #[test]
2107 fn advance_mmr_rejects_delta_inconsistent_with_chain_commitment() {
2108 let mock_rpc = MockRpcApi::default();
2109 mock_rpc.advance_blocks(3);
2110 let chain_tip = mock_rpc.get_chain_tip_block_num();
2111
2112 let chain_tip_header = mock_rpc.mock_chain.read().block_header(chain_tip.as_usize());
2113 let genesis_partial_mmr = || {
2114 let peaks = mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2115 PartialMmr::from_peaks(peaks)
2116 };
2117
2118 let full_delta = mock_rpc
2120 .get_mmr()
2121 .get_delta(Forest::new(1).unwrap(), Forest::new(chain_tip.as_usize()).unwrap())
2122 .unwrap();
2123 StateSync::advance_mmr(
2124 full_delta,
2125 &chain_tip_header,
2126 &mut genesis_partial_mmr(),
2127 &mut PartialBlockchainUpdates::default(),
2128 )
2129 .unwrap();
2130
2131 let truncated_delta = mock_rpc
2133 .get_mmr()
2134 .get_delta(Forest::new(1).unwrap(), Forest::new(chain_tip.as_usize() - 1).unwrap())
2135 .unwrap();
2136 let result = StateSync::advance_mmr(
2137 truncated_delta,
2138 &chain_tip_header,
2139 &mut genesis_partial_mmr(),
2140 &mut PartialBlockchainUpdates::default(),
2141 );
2142 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2143 }
2144}