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 PartialBlockchainUpdates,
19 PublicAccountDelta,
20 PublicAccountUpdate,
21 StateSyncUpdate,
22};
23use crate::ClientError;
24use crate::note::{NoteConsumption, NoteUpdateTracker};
25use crate::rpc::NodeRpcClient;
26use crate::rpc::domain::account::{AccountDetails, GetAccountRequest, StorageMapFetch, VaultFetch};
27use crate::rpc::domain::note::{CommittedNote, NoteSyncBlock, SyncedNoteDetails};
28use crate::rpc::domain::sync::{ChainMmrInfo, SyncTarget};
29use crate::rpc::domain::transaction::TransactionRecord as RpcTransactionRecord;
30use crate::store::{InputNoteRecord, OutputNoteRecord, StoreError};
31use crate::transaction::TransactionRecord;
32
33enum PublicAccountSync {
38 Apply(Box<PublicAccountUpdate>),
40 Superseded,
42 Ignore,
44}
45
46struct FetchedSyncData {
52 mmr_delta: MmrDelta,
54 chain_tip_header: BlockHeader,
56 note_blocks: Vec<NoteSyncBlock>,
58 synced_notes: BTreeMap<NoteId, SyncedNoteDetails>,
61 transactions: Vec<RpcTransactionRecord>,
63}
64
65pub struct StateSyncInput {
81 pub accounts: Vec<AccountHeader>,
83 pub note_tags: BTreeSet<NoteTag>,
85 pub input_notes: Vec<InputNoteRecord>,
87 pub output_notes: Vec<OutputNoteRecord>,
89 pub uncommitted_transactions: Vec<TransactionRecord>,
91}
92
93#[allow(clippy::large_enum_variant)]
98pub enum NoteUpdateAction {
99 Commit(CommittedNote),
102 Insert(InputNoteRecord),
104 Discard,
106}
107
108#[async_trait(?Send)]
109pub trait OnNoteReceived {
110 async fn on_note_received(
121 &self,
122 committed_note: CommittedNote,
123 public_note: Option<InputNoteRecord>,
124 ) -> Result<NoteUpdateAction, ClientError>;
125}
126#[derive(Clone)]
133pub struct StateSync {
134 rpc_api: Arc<dyn NodeRpcClient>,
136 note_screener: Arc<dyn OnNoteReceived>,
139 tx_discard_delta: Option<u32>,
142 sync_nullifiers: bool,
146}
147
148impl StateSync {
149 pub fn new(
160 rpc_api: Arc<dyn NodeRpcClient>,
161 note_screener: Arc<dyn OnNoteReceived>,
162 tx_discard_delta: Option<u32>,
163 ) -> Self {
164 Self {
165 rpc_api,
166 note_screener,
167 tx_discard_delta,
168 sync_nullifiers: true,
169 }
170 }
171
172 pub fn disable_nullifier_sync(&mut self) {
178 self.sync_nullifiers = false;
179 }
180
181 pub fn enable_nullifier_sync(&mut self) {
183 self.sync_nullifiers = true;
184 }
185
186 pub async fn sync_state(
204 &self,
205 current_partial_mmr: &mut PartialMmr,
206 input: StateSyncInput,
207 ) -> Result<StateSyncUpdate, ClientError> {
208 let StateSyncInput {
209 accounts,
210 note_tags,
211 input_notes,
212 output_notes,
213 uncommitted_transactions,
214 } = input;
215 let block_num = u32::try_from(current_partial_mmr.forest().num_leaves().saturating_sub(1))
216 .map_err(|_| ClientError::InvalidPartialMmrForest)?
217 .into();
218
219 let note_tags = Arc::new(note_tags);
220 let account_ids: Vec<AccountId> = accounts.iter().map(AccountHeader::id).collect();
221
222 let mut state_sync_update = StateSyncUpdate {
223 block_num,
224 note_updates: NoteUpdateTracker::new(input_notes, output_notes),
225 transaction_updates: TransactionUpdateTracker::new(uncommitted_transactions),
226 ..Default::default()
227 };
228 let Some(sync_data) = self
229 .fetch_sync_data(state_sync_update.block_num, &account_ids, ¬e_tags)
230 .await?
231 else {
232 return Ok(state_sync_update);
234 };
235
236 state_sync_update.block_num = sync_data.chain_tip_header.block_num();
237
238 let new_commitments = derive_account_commitments(&sync_data.transactions);
239 let superseded_states = self
240 .account_state_sync(
241 &mut state_sync_update.account_updates,
242 &accounts,
243 &new_commitments,
244 block_num,
245 )
246 .await?;
247
248 for superseded_state in superseded_states {
250 state_sync_update
251 .transaction_updates
252 .apply_superseded_account_state(superseded_state);
253 }
254
255 self.apply_sync_result(sync_data, &mut state_sync_update, current_partial_mmr)
257 .await?;
258
259 if self.sync_nullifiers {
260 self.nullifiers_state_sync(&mut state_sync_update, block_num).await?;
261 }
262
263 Ok(state_sync_update)
264 }
265
266 async fn fetch_sync_data(
275 &self,
276 current_block_num: BlockNumber,
277 account_ids: &[AccountId],
278 note_tags: &Arc<BTreeSet<NoteTag>>,
279 ) -> Result<Option<FetchedSyncData>, ClientError> {
280 let chain_mmr_info = self
282 .rpc_api
283 .sync_chain_mmr(current_block_num, SyncTarget::CommittedChainTip)
284 .await?;
285 let chain_tip = chain_mmr_info.block_to;
286
287 Self::validate_chain_mmr_response(&chain_mmr_info, current_block_num)?;
289
290 if chain_tip == current_block_num {
292 info!(block_num = %current_block_num, "Already at chain tip, nothing to sync.");
293 return Ok(None);
294 }
295
296 info!(
297 block_from = %current_block_num,
298 block_to = %chain_tip,
299 "Syncing state.",
300 );
301
302 let (note_blocks, synced_notes) = if note_tags.is_empty() {
307 (Vec::new(), BTreeMap::new())
308 } else {
309 self.rpc_api
310 .sync_notes_with_details(current_block_num + 1, chain_tip, note_tags.as_ref())
311 .await?
312 };
313
314 Self::validate_note_blocks_range(¬e_blocks, current_block_num, chain_tip)?;
316
317 let note_count: usize = note_blocks.iter().map(|b| b.notes.len()).sum();
318 info!(
319 blocks_with_notes = note_blocks.len(),
320 notes = note_count,
321 synced_notes = synced_notes.len(),
322 "Fetched note sync data.",
323 );
324
325 let transaction_records = if account_ids.is_empty() {
328 Vec::new()
329 } else {
330 self.rpc_api
331 .sync_transactions(current_block_num + 1, chain_tip, account_ids.to_vec())
332 .await?
333 };
334
335 Ok(Some(FetchedSyncData {
336 mmr_delta: chain_mmr_info.mmr_delta,
337 chain_tip_header: chain_mmr_info.block_header,
338 note_blocks,
339 synced_notes,
340 transactions: transaction_records,
341 }))
342 }
343
344 async fn apply_sync_result(
354 &self,
355 sync_data: FetchedSyncData,
356 state_sync_update: &mut StateSyncUpdate,
357 current_partial_mmr: &mut PartialMmr,
358 ) -> Result<(), ClientError> {
359 let FetchedSyncData {
360 mmr_delta,
361 chain_tip_header,
362 note_blocks,
363 synced_notes,
364 transactions,
365 } = sync_data;
366
367 let mut working_mmr = current_partial_mmr.clone();
370
371 Self::advance_mmr(
372 mmr_delta,
373 &chain_tip_header,
374 &mut working_mmr,
375 &mut state_sync_update.partial_blockchain_updates,
376 )?;
377
378 self.screen_note_blocks(note_blocks, synced_notes, state_sync_update, &mut working_mmr)
379 .await?;
380
381 self.apply_transactions_and_nullifiers(
382 &chain_tip_header,
383 &transactions,
384 state_sync_update,
385 )?;
386
387 *current_partial_mmr = working_mmr;
389
390 Ok(())
391 }
392
393 fn validate_chain_mmr_response(
395 chain_mmr_info: &ChainMmrInfo,
396 current_block_num: BlockNumber,
397 ) -> Result<(), ClientError> {
398 if chain_mmr_info.block_header.block_num() != chain_mmr_info.block_to {
399 return Err(ClientError::ChainValidationError(format!(
400 "sync_chain_mmr block_header.block_num ({}) does not match block_to ({})",
401 chain_mmr_info.block_header.block_num(),
402 chain_mmr_info.block_to
403 )));
404 }
405 if chain_mmr_info.block_from != current_block_num {
406 return Err(ClientError::ChainValidationError(format!(
407 "sync_chain_mmr block_from mismatch: expected {current_block_num}, got {}",
408 chain_mmr_info.block_from
409 )));
410 }
411 if chain_mmr_info.block_to < current_block_num {
412 return Err(ClientError::ChainValidationError(format!(
413 "sync_chain_mmr block_to ({}) is behind current block {current_block_num}",
414 chain_mmr_info.block_to
415 )));
416 }
417 Ok(())
418 }
419
420 fn validate_note_blocks_range(
423 note_blocks: &[NoteSyncBlock],
424 current_block_num: BlockNumber,
425 chain_tip: BlockNumber,
426 ) -> Result<(), ClientError> {
427 for block in note_blocks {
428 let block_num = block.block_header.block_num();
429 if block_num <= current_block_num || block_num > chain_tip {
430 return Err(ClientError::ChainValidationError(format!(
431 "sync_notes returned block {block_num} outside requested range ({current_block_num}, {chain_tip}]"
432 )));
433 }
434 }
435 Ok(())
436 }
437
438 fn advance_mmr(
445 mmr_delta: MmrDelta,
446 chain_tip_header: &BlockHeader,
447 current_partial_mmr: &mut PartialMmr,
448 partial_blockchain_updates: &mut PartialBlockchainUpdates,
449 ) -> Result<(), ClientError> {
450 let mut new_authentication_nodes =
451 current_partial_mmr.apply(mmr_delta).map_err(StoreError::MmrError)?;
452 let new_peaks = current_partial_mmr.peaks();
453
454 let peaks_commitment = new_peaks.hash_peaks();
458 if peaks_commitment != chain_tip_header.chain_commitment() {
459 return Err(ClientError::ChainValidationError(format!(
460 "MMR peaks commitment is {} and does not match block header chain commitment {}",
461 peaks_commitment.to_hex(),
462 chain_tip_header.chain_commitment().to_hex()
463 )));
464 }
465
466 partial_blockchain_updates.new_peaks = new_peaks;
467
468 new_authentication_nodes.append(
473 &mut current_partial_mmr
474 .add(chain_tip_header.commitment(), false)
475 .map_err(StoreError::MmrError)?,
476 );
477
478 partial_blockchain_updates.insert(
479 chain_tip_header.clone(),
480 false,
481 new_authentication_nodes,
482 );
483
484 Ok(())
485 }
486
487 async fn screen_note_blocks(
491 &self,
492 note_blocks: Vec<NoteSyncBlock>,
493 synced_notes: BTreeMap<NoteId, SyncedNoteDetails>,
494 state_sync_update: &mut StateSyncUpdate,
495 current_partial_mmr: &mut PartialMmr,
496 ) -> Result<(), ClientError> {
497 let private_attachments: BTreeMap<NoteId, NoteAttachments> = synced_notes
500 .iter()
501 .filter_map(|(id, synced)| match synced {
502 SyncedNoteDetails::Private(Some(attachments)) => Some((*id, attachments.clone())),
503 _ => None,
504 })
505 .collect();
506 let public_note_records = Self::build_public_note_records(synced_notes, ¬e_blocks);
507
508 for block in note_blocks {
509 let found_relevant_note = self
510 .note_state_sync(
511 &mut state_sync_update.note_updates,
512 block.notes,
513 &block.block_header,
514 &public_note_records,
515 &private_attachments,
516 )
517 .await?;
518
519 if found_relevant_note {
520 let block_pos = block.block_header.block_num().as_usize();
521
522 let nodes_before: BTreeMap<_, _> =
523 current_partial_mmr.nodes().map(|(k, v)| (*k, *v)).collect();
524
525 if !current_partial_mmr.is_tracked(block_pos) {
526 current_partial_mmr
527 .track(block_pos, block.block_header.commitment(), &block.mmr_path)
528 .map_err(StoreError::MmrError)?;
529 }
530
531 let track_auth_nodes: Vec<_> = current_partial_mmr
536 .nodes()
537 .filter(|(k, _)| !nodes_before.contains_key(k))
538 .map(|(k, v)| (*k, *v))
539 .collect();
540
541 state_sync_update.partial_blockchain_updates.insert(
542 block.block_header,
543 true,
544 track_auth_nodes,
545 );
546 }
547 }
548
549 Ok(())
550 }
551
552 fn apply_transactions_and_nullifiers(
556 &self,
557 chain_tip_header: &BlockHeader,
558 transactions: &[RpcTransactionRecord],
559 state_sync_update: &mut StateSyncUpdate,
560 ) -> Result<(), ClientError> {
561 state_sync_update
562 .note_updates
563 .extend_nullifiers(compute_ordered_nullifiers(transactions));
564
565 for record in transactions {
566 state_sync_update
567 .transaction_updates
568 .apply_transaction_inclusion(record, u64::from(chain_tip_header.timestamp())); }
570 state_sync_update
571 .transaction_updates
572 .apply_sync_height_update(chain_tip_header.block_num(), self.tx_discard_delta);
573
574 for transaction in transactions {
575 state_sync_update
579 .note_updates
580 .apply_output_note_inclusion_proofs(&transaction.output_notes)?;
581
582 Self::mark_erased_notes_as_consumed(state_sync_update, transaction);
584 }
585
586 Ok(())
587 }
588
589 fn mark_erased_notes_as_consumed(
595 state_sync_update: &mut StateSyncUpdate,
596 transaction: &RpcTransactionRecord,
597 ) {
598 for note_header in &transaction.erased_output_notes {
599 let _ = state_sync_update
601 .note_updates
602 .mark_erased_note_as_consumed(note_header, transaction.block_num);
603 }
604 }
605
606 async fn account_state_sync(
619 &self,
620 account_updates: &mut AccountUpdates,
621 accounts: &[AccountHeader],
622 account_commitment_updates: &[(AccountId, Word)],
623 block_from: BlockNumber,
624 ) -> Result<Vec<Word>, ClientError> {
625 let (public_accounts, private_accounts): (Vec<_>, Vec<_>) =
628 accounts.iter().partition(|header| !header.id().is_private());
629
630 let superseded_states = self
631 .sync_public_accounts(
632 account_updates,
633 account_commitment_updates,
634 &public_accounts,
635 block_from,
636 )
637 .await?;
638
639 let mismatched_private_accounts = account_commitment_updates
640 .iter()
641 .filter(|(account_id, digest)| {
642 private_accounts
643 .iter()
644 .any(|header| header.id() == *account_id && &header.to_commitment() != digest)
645 })
646 .copied()
647 .collect::<Vec<_>>();
648
649 account_updates.extend(AccountUpdates::new(Vec::new(), mismatched_private_accounts));
650
651 Ok(superseded_states)
652 }
653
654 async fn sync_public_accounts(
663 &self,
664 account_updates: &mut AccountUpdates,
665 commitment_updates: &[(AccountId, Word)],
666 current_public_accounts: &[&AccountHeader],
667 block_from: BlockNumber,
668 ) -> Result<Vec<Word>, ClientError> {
669 let local_headers: BTreeMap<AccountId, &AccountHeader> =
670 current_public_accounts.iter().map(|header| (header.id(), *header)).collect();
671 let mut superseded_states = Vec::new();
673 for (id, commitment) in commitment_updates {
674 let Some(local_header) = local_headers.get(id).copied() else {
675 continue;
676 };
677
678 if local_header.to_commitment() == *commitment {
679 continue;
680 }
681
682 match self.sync_public_account(*id, local_header, block_from).await? {
683 PublicAccountSync::Apply(public_update) => {
684 account_updates.extend(AccountUpdates::new(vec![*public_update], Vec::new()));
685 },
686 PublicAccountSync::Superseded => {
687 superseded_states.push(local_header.to_commitment());
688 },
689 PublicAccountSync::Ignore => {},
690 }
691 }
692
693 Ok(superseded_states)
694 }
695
696 async fn sync_public_account(
710 &self,
711 account_id: AccountId,
712 local_header: &AccountHeader,
713 block_from: BlockNumber,
714 ) -> Result<PublicAccountSync, ClientError> {
715 let (proof_block_num, proof) = self
718 .rpc_api
719 .get_account(
720 account_id,
721 GetAccountRequest::new()
722 .with_storage(StorageMapFetch::All)
723 .with_vault(VaultFetch::Always),
724 )
725 .await
726 .map_err(ClientError::RpcError)?;
727
728 let details = proof.into_details().expect("node returned no details for a public account");
729 match details
730 .header
731 .nonce()
732 .as_canonical_u64()
733 .cmp(&local_header.nonce().as_canonical_u64())
734 {
735 Ordering::Less => return Ok(PublicAccountSync::Ignore),
738 Ordering::Equal => return Ok(PublicAccountSync::Superseded),
740 Ordering::Greater => {},
742 }
743
744 let vault_oversized = details.vault_details.too_many_assets;
745 let any_map_oversized =
746 details.storage_details.map_details.iter().any(|m| m.too_many_entries);
747
748 let public_update = if vault_oversized || any_map_oversized {
752 self.build_delta_update(account_id, &details, block_from, proof_block_num)
754 .await?
755 } else {
756 let account = Account::try_from(&details).map_err(ClientError::RpcError)?;
758 PublicAccountUpdate::Full(account)
759 };
760
761 Ok(PublicAccountSync::Apply(Box::new(public_update)))
762 }
763
764 async fn build_delta_update(
767 &self,
768 account_id: AccountId,
769 details: &AccountDetails,
770 block_from: BlockNumber,
771 block_to: BlockNumber,
772 ) -> Result<PublicAccountUpdate, ClientError> {
773 let value_slot_updates: Vec<(_, Word)> = details
774 .storage_details
775 .header
776 .slots()
777 .filter(|slot| slot.slot_type() == StorageSlotType::Value)
778 .map(|slot| (slot.name().clone(), slot.value()))
779 .collect();
780
781 let map_info = self
784 .rpc_api
785 .sync_storage_maps(block_from + 1, block_to, account_id)
786 .await
787 .map_err(ClientError::RpcError)?;
788 let vault_info = self
789 .rpc_api
790 .sync_account_vault(block_from + 1, block_to, account_id)
791 .await
792 .map_err(ClientError::RpcError)?;
793
794 Ok(PublicAccountUpdate::Delta(PublicAccountDelta::new(
795 details.header.clone(),
796 block_from,
797 block_to,
798 value_slot_updates,
799 map_info.updates,
800 vault_info.updates,
801 )))
802 }
803
804 async fn note_state_sync(
821 &self,
822 note_updates: &mut NoteUpdateTracker,
823 note_inclusions: BTreeMap<NoteId, CommittedNote>,
824 block_header: &BlockHeader,
825 public_notes: &BTreeMap<NoteId, InputNoteRecord>,
826 private_attachments: &BTreeMap<NoteId, NoteAttachments>,
827 ) -> Result<bool, ClientError> {
828 let mut found_relevant_note = false;
830
831 for (_, committed_note) in note_inclusions {
832 let public_note = (committed_note.note_type() != NoteType::Private)
833 .then(|| public_notes.get(committed_note.note_id()))
834 .flatten()
835 .cloned();
836
837 match self.note_screener.on_note_received(committed_note, public_note).await? {
838 NoteUpdateAction::Commit(committed_note) => {
839 let attachments = private_attachments.get(committed_note.note_id());
843 found_relevant_note |= note_updates.apply_committed_note_state_transitions(
844 &committed_note,
845 block_header,
846 attachments,
847 )?;
848 },
849 NoteUpdateAction::Insert(public_note) => {
850 found_relevant_note = true;
851
852 note_updates.apply_new_public_note(public_note, block_header)?;
853 },
854 NoteUpdateAction::Discard => {},
855 }
856 }
857
858 Ok(found_relevant_note)
859 }
860
861 async fn nullifiers_state_sync(
867 &self,
868 state_sync_update: &mut StateSyncUpdate,
869 current_block_num: BlockNumber,
870 ) -> Result<(), ClientError> {
871 let nullifiers_tags: Vec<u16> = state_sync_update
877 .note_updates
878 .unspent_nullifiers()
879 .map(|nullifier| nullifier.prefix())
880 .collect();
881
882 let mut new_nullifiers = self
883 .rpc_api
884 .sync_nullifiers(&nullifiers_tags, current_block_num + 1, state_sync_update.block_num)
885 .await?;
886
887 new_nullifiers.retain(|update| update.block_num <= state_sync_update.block_num);
890
891 let consumptions: Vec<NoteConsumption> = new_nullifiers
893 .into_iter()
894 .map(|update| NoteConsumption {
895 external_consumer: state_sync_update
896 .transaction_updates
897 .external_nullifier_account(&update.nullifier),
898 nullifier: update.nullifier,
899 block_num: update.block_num,
900 })
901 .collect();
902
903 for consumption in consumptions {
904 state_sync_update.note_updates.apply_note_consumption(
905 &consumption,
906 state_sync_update.transaction_updates.committed_transactions(),
907 )?;
908
909 state_sync_update
913 .transaction_updates
914 .apply_input_note_nullified(consumption.nullifier);
915 }
916
917 Ok(())
918 }
919
920 fn build_public_note_records(
923 synced_notes: BTreeMap<NoteId, SyncedNoteDetails>,
924 note_blocks: &[NoteSyncBlock],
925 ) -> BTreeMap<NoteId, InputNoteRecord> {
926 let mut records = BTreeMap::new();
927 for (note_id, synced) in synced_notes {
928 let SyncedNoteDetails::Public(note) = synced else {
929 continue;
930 };
931 let inclusion_proof = note_blocks
932 .iter()
933 .find_map(|b| b.notes.get(¬e_id))
934 .map(|committed| committed.inclusion_proof().clone());
935
936 if let Some(inclusion_proof) = inclusion_proof {
937 let state = crate::store::input_note_states::UnverifiedNoteState {
938 metadata: *note.metadata(),
939 inclusion_proof,
940 }
941 .into();
942 let attachments = note.attachments().clone();
943 let record = InputNoteRecord::new(note.into(), attachments, None, state);
944 let id = record.id().expect("CommittedNoteState carries metadata, so id() is Some");
945 records.insert(id, record);
946 }
947 }
948 records
949 }
950}
951
952fn group_txs_by_account_block(
957 transaction_records: &[RpcTransactionRecord],
958) -> BTreeMap<(AccountId, BlockNumber), Vec<&RpcTransactionRecord>> {
959 let mut groups: BTreeMap<(AccountId, BlockNumber), Vec<&RpcTransactionRecord>> =
960 BTreeMap::new();
961 for record in transaction_records {
962 let account_id = record.transaction_header.account_id();
963 groups.entry((account_id, record.block_num)).or_default().push(record);
964 }
965 groups
966}
967
968fn walk_execution_chain<'a>(
974 txs: &'a [&'a RpcTransactionRecord],
975) -> impl Iterator<Item = &'a RpcTransactionRecord> + 'a {
976 let (self_loops, chained): (Vec<&RpcTransactionRecord>, Vec<&RpcTransactionRecord>) =
977 txs.iter().copied().partition(|tx| {
978 tx.transaction_header.initial_state_commitment()
979 == tx.transaction_header.final_state_commitment()
980 });
981
982 let final_states: BTreeSet<Word> = chained
983 .iter()
984 .map(|tx| tx.transaction_header.final_state_commitment())
985 .collect();
986
987 let mut init_to_tx: BTreeMap<Word, &RpcTransactionRecord> = chained
988 .iter()
989 .map(|tx| (tx.transaction_header.initial_state_commitment(), *tx))
990 .collect();
991
992 let start = chained
993 .iter()
994 .find(|tx| !final_states.contains(&tx.transaction_header.initial_state_commitment()))
995 .copied();
996
997 assert!(start.is_some() || chained.is_empty(), "cannot walk cyclic execution chain");
998
999 let mut current =
1000 start.and_then(|tx| init_to_tx.remove(&tx.transaction_header.initial_state_commitment()));
1001 let mut self_loops_iter = self_loops.into_iter();
1002
1003 core::iter::from_fn(move || {
1004 if let Some(tx) = current {
1005 current = init_to_tx.remove(&tx.transaction_header.final_state_commitment());
1006 return Some(tx);
1007 }
1008 self_loops_iter.next()
1009 })
1010}
1011
1012fn derive_account_commitments(
1017 transaction_records: &[RpcTransactionRecord],
1018) -> Vec<(AccountId, Word)> {
1019 let mut latest_by_account: BTreeMap<AccountId, (BlockNumber, Word)> = BTreeMap::new();
1020
1021 for ((account_id, block_num), txs) in &group_txs_by_account_block(transaction_records) {
1022 let terminal_state = walk_execution_chain(txs)
1023 .last()
1024 .expect("account must have a final state")
1025 .transaction_header
1026 .final_state_commitment();
1027
1028 latest_by_account
1029 .entry(*account_id)
1030 .and_modify(|(existing_block, existing_state)| {
1031 if *block_num > *existing_block {
1032 *existing_block = *block_num;
1033 *existing_state = terminal_state;
1034 }
1035 })
1036 .or_insert((*block_num, terminal_state));
1037 }
1038
1039 latest_by_account
1040 .into_iter()
1041 .map(|(account_id, (_, state))| (account_id, state))
1042 .collect()
1043}
1044
1045fn compute_ordered_nullifiers(transaction_records: &[RpcTransactionRecord]) -> Vec<Nullifier> {
1052 let mut result = Vec::new();
1053
1054 for txs in group_txs_by_account_block(transaction_records).values() {
1055 for tx in walk_execution_chain(txs) {
1056 for commitment in tx.transaction_header.input_notes().iter() {
1057 result.push(commitment.nullifier());
1058 }
1059 }
1060 }
1061
1062 result
1063}
1064
1065#[cfg(all(test, feature = "testing"))]
1066mod tests {
1067 use alloc::collections::BTreeSet;
1068 use alloc::sync::Arc;
1069
1070 use async_trait::async_trait;
1071 use miden_protocol::account::Account;
1072 use miden_protocol::assembly::DefaultSourceManager;
1073 use miden_protocol::asset::{Asset, FungibleAsset};
1074 use miden_protocol::block::BlockNumber;
1075 use miden_protocol::crypto::merkle::MerklePath;
1076 use miden_protocol::crypto::merkle::mmr::{Forest, InOrderIndex, PartialMmr};
1077 use miden_protocol::note::{
1078 Note,
1079 NoteAssets,
1080 NoteAttachment,
1081 NoteAttachments,
1082 NoteDetails,
1083 NoteHeader,
1084 NoteMetadata,
1085 NoteRecipient,
1086 NoteStorage,
1087 NoteTag,
1088 NoteType,
1089 PartialNoteMetadata,
1090 };
1091 use miden_protocol::testing::account_id::{
1092 ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET,
1093 ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
1094 ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE,
1095 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
1096 ACCOUNT_ID_SENDER,
1097 };
1098 use miden_protocol::transaction::{InputNotes, TransactionArgs, TransactionHeader};
1099 use miden_protocol::vm::AdviceMap;
1100 use miden_protocol::{EMPTY_WORD, Felt, Word, ZERO};
1101 use miden_standards::code_builder::CodeBuilder;
1102 use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint};
1103 use miden_testing::{MockChainBuilder, TxContextInput};
1104
1105 use super::*;
1106 use crate::rpc::domain::transaction::ACCOUNT_ID_NATIVE_ASSET_FAUCET;
1107 use crate::store::{OutputNoteRecord, OutputNoteState};
1108 use crate::test_utils::mock::MockRpcApi;
1109
1110 struct MockScreener;
1112
1113 #[async_trait(?Send)]
1114 impl OnNoteReceived for MockScreener {
1115 async fn on_note_received(
1116 &self,
1117 _committed_note: CommittedNote,
1118 _public_note: Option<InputNoteRecord>,
1119 ) -> Result<NoteUpdateAction, ClientError> {
1120 Ok(NoteUpdateAction::Discard)
1121 }
1122 }
1123
1124 fn empty() -> StateSyncInput {
1125 StateSyncInput {
1126 accounts: vec![],
1127 note_tags: BTreeSet::new(),
1128 input_notes: vec![],
1129 output_notes: vec![],
1130 uncommitted_transactions: vec![],
1131 }
1132 }
1133
1134 fn word(n: u64) -> miden_protocol::Word {
1135 [
1136 Felt::new(n).expect("test value should fit into the base field"),
1137 ZERO,
1138 ZERO,
1139 ZERO,
1140 ]
1141 .into()
1142 }
1143
1144 #[tokio::test]
1145 async fn sync_public_accounts_ignores_older_node_snapshot() {
1146 let mut builder = MockChainBuilder::new();
1147 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1148 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1149 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1150
1151 let local_header =
1154 AccountHeader::new(account.id(), Felt::from(2u32), EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
1155 let current_public_accounts = vec![&local_header];
1156 let commitment_updates = vec![(account.id(), account.to_commitment())];
1157 let mut account_updates = AccountUpdates::default();
1158
1159 let superseded = state_sync
1160 .sync_public_accounts(
1161 &mut account_updates,
1162 &commitment_updates,
1163 ¤t_public_accounts,
1164 BlockNumber::GENESIS,
1165 )
1166 .await
1167 .unwrap();
1168
1169 assert!(
1170 account_updates.updated_public_accounts().is_empty(),
1171 "public account sync should ignore node snapshots that are older than local"
1172 );
1173 assert!(
1174 superseded.is_empty(),
1175 "an older node snapshot must not supersede the local state"
1176 );
1177 }
1178
1179 #[tokio::test]
1180 async fn sync_public_accounts_marks_same_nonce_mismatch_as_superseded() {
1181 let mut builder = MockChainBuilder::new();
1182 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1183 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1184 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1185
1186 let local_header =
1189 AccountHeader::new(account.id(), account.nonce(), EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
1190 let current_public_accounts = vec![&local_header];
1191 let commitment_updates = vec![(account.id(), account.to_commitment())];
1192 let mut account_updates = AccountUpdates::default();
1193
1194 let superseded = state_sync
1195 .sync_public_accounts(
1196 &mut account_updates,
1197 &commitment_updates,
1198 ¤t_public_accounts,
1199 BlockNumber::GENESIS,
1200 )
1201 .await
1202 .unwrap();
1203
1204 assert!(
1205 account_updates.updated_public_accounts().is_empty(),
1206 "a same-nonce fork must not overwrite the account while its tx is still pending"
1207 );
1208 assert_eq!(
1209 superseded,
1210 vec![local_header.to_commitment()],
1211 "the superseded local state should be reported so its transaction is discarded"
1212 );
1213 }
1214
1215 mod compute_nullifiers_tests {
1219 use alloc::vec;
1220
1221 use miden_protocol::asset::FungibleAsset;
1222 use miden_protocol::block::BlockNumber;
1223 use miden_protocol::note::Nullifier;
1224 use miden_protocol::transaction::{InputNoteCommitment, InputNotes, TransactionHeader};
1225
1226 use super::word;
1227 use crate::rpc::domain::transaction::{
1228 ACCOUNT_ID_NATIVE_ASSET_FAUCET,
1229 TransactionRecord as RpcTransactionRecord,
1230 };
1231
1232 fn make_rpc_tx(
1233 init_state: u64,
1234 final_state: u64,
1235 nullifier_vals: &[u64],
1236 block_number: u32,
1237 ) -> RpcTransactionRecord {
1238 let account_id = miden_protocol::account::AccountId::try_from(
1239 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE,
1240 )
1241 .unwrap();
1242
1243 let input_notes = InputNotes::new_unchecked(
1244 nullifier_vals
1245 .iter()
1246 .map(|v| InputNoteCommitment::from(Nullifier::from_raw(word(*v))))
1247 .collect(),
1248 );
1249
1250 let fee =
1251 FungibleAsset::new(ACCOUNT_ID_NATIVE_ASSET_FAUCET.try_into().expect("valid"), 0u64)
1252 .unwrap();
1253
1254 RpcTransactionRecord {
1255 block_num: BlockNumber::from(block_number),
1256 transaction_header: TransactionHeader::new(
1257 account_id,
1258 word(init_state),
1259 word(final_state),
1260 input_notes,
1261 vec![],
1262 fee,
1263 ),
1264 output_notes: vec![],
1265 erased_output_notes: vec![],
1266 }
1267 }
1268
1269 #[test]
1270 fn chains_rpc_transactions_by_state_commitment() {
1271 let tx_a = make_rpc_tx(1, 2, &[10], 5);
1274 let tx_b = make_rpc_tx(2, 3, &[20], 5);
1275 let tx_c = make_rpc_tx(3, 4, &[30], 5);
1276
1277 let result = super::super::compute_ordered_nullifiers(&[tx_c, tx_a, tx_b]);
1278
1279 assert_eq!(result[0], Nullifier::from_raw(word(10)));
1280 assert_eq!(result[1], Nullifier::from_raw(word(20)));
1281 assert_eq!(result[2], Nullifier::from_raw(word(30)));
1282 }
1283
1284 #[test]
1285 fn groups_independently_by_account_and_block() {
1286 let tx_a1 = make_rpc_tx(1, 2, &[10], 5);
1288 let tx_a2 = make_rpc_tx(2, 3, &[20], 5);
1289
1290 let tx_a3 = make_rpc_tx(3, 4, &[30], 6);
1292
1293 let account_b = miden_protocol::account::AccountId::try_from(
1295 miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
1296 )
1297 .unwrap();
1298
1299 let fee =
1300 FungibleAsset::new(ACCOUNT_ID_NATIVE_ASSET_FAUCET.try_into().expect("valid"), 0u64)
1301 .unwrap();
1302
1303 let tx_b1 = RpcTransactionRecord {
1304 block_num: BlockNumber::from(5u32),
1305 transaction_header: TransactionHeader::new(
1306 account_b,
1307 word(100),
1308 word(200),
1309 InputNotes::new_unchecked(vec![InputNoteCommitment::from(
1310 Nullifier::from_raw(word(40)),
1311 )]),
1312 vec![],
1313 fee,
1314 ),
1315 output_notes: vec![],
1316 erased_output_notes: vec![],
1317 };
1318
1319 let result = super::super::compute_ordered_nullifiers(&[tx_a2, tx_b1, tx_a3, tx_a1]);
1320
1321 let pos = |val: u64| -> usize {
1324 result.iter().position(|n| *n == Nullifier::from_raw(word(val))).unwrap()
1325 };
1326
1327 assert!(pos(10) < pos(20)); assert!(result.contains(&Nullifier::from_raw(word(30)))); assert!(result.contains(&Nullifier::from_raw(word(40)))); }
1333
1334 #[test]
1335 fn multiple_nullifiers_per_transaction_are_consecutive() {
1336 let tx = make_rpc_tx(1, 2, &[10, 20, 30], 5);
1338
1339 let result = super::super::compute_ordered_nullifiers(&[tx]);
1340
1341 assert_eq!(result.len(), 3);
1342 assert!(result.contains(&Nullifier::from_raw(word(10))));
1343 assert!(result.contains(&Nullifier::from_raw(word(20))));
1344 assert!(result.contains(&Nullifier::from_raw(word(30))));
1345 }
1346
1347 #[test]
1348 fn empty_input_returns_empty_vec() {
1349 let result = super::super::compute_ordered_nullifiers(&[]);
1350 assert!(result.is_empty());
1351 }
1352 }
1353
1354 #[test]
1365 fn derive_account_commitments_walks_chains_per_account() {
1366 let fee =
1367 FungibleAsset::new(ACCOUNT_ID_NATIVE_ASSET_FAUCET.try_into().expect("valid"), 0u64)
1368 .unwrap();
1369 let make_tx = |account: AccountId, init_state: u64, final_state: u64, block_num: u32| {
1370 RpcTransactionRecord {
1371 block_num: BlockNumber::from(block_num),
1372 transaction_header: TransactionHeader::new(
1373 account,
1374 word(init_state),
1375 word(final_state),
1376 InputNotes::new_unchecked(vec![]),
1377 vec![],
1378 fee,
1379 ),
1380 output_notes: vec![],
1381 erased_output_notes: vec![],
1382 }
1383 };
1384
1385 let account_a: AccountId =
1386 ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE.try_into().unwrap();
1387 let account_b: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap();
1388
1389 let tx_a_b5_1 = make_tx(account_a, 1, 2, 5);
1390 let tx_a_b5_2 = make_tx(account_a, 2, 3, 5);
1391 let tx_a_b6_1 = make_tx(account_a, 3, 4, 6);
1392 let tx_a_b6_2 = make_tx(account_a, 4, 5, 6);
1393 let tx_b_b6 = make_tx(account_b, 10, 20, 6);
1394
1395 let result = super::derive_account_commitments(&[
1397 tx_a_b6_1, tx_b_b6, tx_a_b5_2, tx_a_b6_2, tx_a_b5_1,
1398 ]);
1399
1400 assert_eq!(result.len(), 2, "one entry per account");
1401 assert!(
1402 result.contains(&(account_a, word(5))),
1403 "account A: must walk block 6's chain, not return block 5 or an intermediate",
1404 );
1405 assert!(
1406 result.contains(&(account_b, word(20))),
1407 "account B: must be resolved independently of account A",
1408 );
1409 }
1410
1411 struct CommitAllScreener;
1417
1418 #[async_trait(?Send)]
1419 impl OnNoteReceived for CommitAllScreener {
1420 async fn on_note_received(
1421 &self,
1422 committed_note: CommittedNote,
1423 _public_note: Option<InputNoteRecord>,
1424 ) -> Result<NoteUpdateAction, ClientError> {
1425 Ok(NoteUpdateAction::Commit(committed_note))
1426 }
1427 }
1428
1429 async fn build_chain_with_chained_consume_txs() -> (miden_testing::MockChain, Account, [Note; 3])
1433 {
1434 let sender_id: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
1435 let faucet_id: AccountId = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into().unwrap();
1436
1437 let mut builder = MockChainBuilder::new();
1438 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1439 let account_id = account.id();
1440
1441 let asset = Asset::Fungible(FungibleAsset::new(faucet_id, 100u64).unwrap());
1442 let note1 = builder
1443 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
1444 .unwrap();
1445 let note2 = builder
1446 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
1447 .unwrap();
1448 let note3 = builder
1449 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
1450 .unwrap();
1451
1452 let mut chain = builder.build().unwrap();
1453 chain.prove_next_block().unwrap(); let mut current_account = account.clone();
1457 for note in [¬e1, ¬e2, ¬e3] {
1458 let tx = Box::pin(
1459 chain
1460 .build_tx_context(
1461 TxContextInput::Account(current_account.clone()),
1462 &[],
1463 core::slice::from_ref(note),
1464 )
1465 .unwrap()
1466 .build()
1467 .unwrap()
1468 .execute(),
1469 )
1470 .await
1471 .unwrap();
1472 current_account.apply_delta(tx.account_delta()).unwrap();
1473 chain.add_pending_executed_transaction(&tx).unwrap();
1474 }
1475
1476 chain.prove_next_block().unwrap(); (chain, account, [note1, note2, note3])
1478 }
1479
1480 #[tokio::test]
1483 async fn sync_state_sets_consumed_tx_order_for_chained_transactions() {
1484 use miden_protocol::note::NoteMetadata;
1485
1486 let (chain, account, [note1, note2, note3]) = build_chain_with_chained_consume_txs().await;
1487
1488 let mock_rpc = MockRpcApi::new(chain);
1489 let state_sync =
1490 StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(CommitAllScreener), None);
1491
1492 let genesis_peaks =
1493 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
1494 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
1495
1496 let input_notes: Vec<InputNoteRecord> = [¬e1, ¬e2, ¬e3]
1497 .into_iter()
1498 .map(|n| InputNoteRecord::from(n.clone()))
1499 .collect();
1500
1501 let note_tags: BTreeSet<NoteTag> =
1502 input_notes.iter().filter_map(|n| n.metadata().map(NoteMetadata::tag)).collect();
1503
1504 let account_id = account.id();
1505 let sync_input = StateSyncInput {
1506 accounts: vec![AccountHeader::from(account)],
1507 note_tags,
1508 input_notes,
1509 output_notes: vec![],
1510 uncommitted_transactions: vec![],
1511 };
1512
1513 let update = state_sync.sync_state(&mut partial_mmr, sync_input).await.unwrap();
1514
1515 let updated_notes: Vec<_> = update.note_updates.updated_input_notes().collect();
1516
1517 let find_order = |details_commitment| -> Option<u32> {
1518 updated_notes
1519 .iter()
1520 .find(|n| n.inner().details_commitment() == details_commitment)
1521 .and_then(|n| n.consumed_tx_order())
1522 };
1523
1524 assert_eq!(find_order(note1.details_commitment()), Some(0), "note1 should have tx_order 0");
1525 assert_eq!(find_order(note2.details_commitment()), Some(1), "note2 should have tx_order 1");
1526 assert_eq!(find_order(note3.details_commitment()), Some(2), "note3 should have tx_order 2");
1527
1528 for note in &updated_notes {
1531 let record = note.inner();
1532 assert!(record.is_consumed(), "note should be in a consumed state");
1533 assert_eq!(
1534 record.consumer_account(),
1535 Some(account_id),
1536 "externally-consumed notes by a tracked account should have consumer_account set",
1537 );
1538 }
1539 }
1540
1541 #[tokio::test]
1542 async fn sync_state_across_multiple_iterations_with_same_mmr() {
1543 let mock_rpc = MockRpcApi::default();
1545 mock_rpc.advance_blocks(3);
1546 let chain_tip_1 = mock_rpc.get_chain_tip_block_num();
1547
1548 let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
1549
1550 let genesis_peaks =
1552 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
1553 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
1554 assert_eq!(partial_mmr.forest().num_leaves(), 1);
1555
1556 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
1558
1559 assert_eq!(update.block_num, chain_tip_1);
1560 let forest_1 = partial_mmr.forest();
1561 assert_eq!(forest_1.num_leaves(), chain_tip_1.as_u32() as usize + 1);
1563
1564 mock_rpc.advance_blocks(2);
1566 let chain_tip_2 = mock_rpc.get_chain_tip_block_num();
1567
1568 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
1569
1570 assert_eq!(update.block_num, chain_tip_2);
1571 let forest_2 = partial_mmr.forest();
1572 assert!(forest_2 > forest_1);
1573 assert_eq!(forest_2.num_leaves(), chain_tip_2.as_u32() as usize + 1);
1574
1575 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
1577
1578 assert_eq!(update.block_num, chain_tip_2);
1579 assert_eq!(partial_mmr.forest(), forest_2);
1580 }
1581
1582 async fn build_chain_with_mint_notes(
1585 num_blocks: u64,
1586 ) -> (miden_testing::MockChain, BTreeSet<NoteTag>) {
1587 let mut builder = MockChainBuilder::new();
1588 let faucet = builder
1589 .add_existing_basic_faucet(
1590 miden_testing::Auth::BasicAuth {
1591 auth_scheme: miden_protocol::account::auth::AuthScheme::Falcon512Poseidon2,
1592 },
1593 "TST",
1594 10_000,
1595 None,
1596 )
1597 .unwrap();
1598 let _target = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1599 let mut chain = builder.build().unwrap();
1600
1601 let note_script = CodeBuilder::new()
1606 .compile_note_script("@note_script\npub proc main\n nop\nend")
1607 .unwrap();
1608 let note_recipient = NoteRecipient::new(
1609 Word::from([1u32, 2, 3, 4]),
1610 note_script,
1611 NoteStorage::new(vec![]).unwrap(),
1612 );
1613 let recipient = note_recipient.digest();
1614 let note_details = NoteDetails::new(NoteAssets::new(vec![]).unwrap(), note_recipient);
1617 let mut recipient_args = TransactionArgs::new(AdviceMap::default());
1618 recipient_args.add_output_note_recipient(¬e_details);
1619 let recipient_advice = recipient_args.advice_inputs().clone();
1620
1621 let tag = NoteTag::default();
1622 let mut faucet_account = faucet.clone();
1623 let mut note_tags = BTreeSet::new();
1624
1625 for i in 0..num_blocks {
1626 let amount = 100 + i;
1627 let source_manager = Arc::new(DefaultSourceManager::default());
1628 let tx_script_code = format!(
1634 "
1635 begin
1636 push.{recipient}
1637 push.{note_type}
1638 push.{tag}
1639 push.{amount}
1640 push.{faucet_id_prefix}
1641 push.{faucet_id_suffix}
1642 push.1
1643 exec.::miden::protocol::asset::create_fungible_asset
1644 call.::miden::standards::faucets::fungible::mint_and_send
1645 dropw dropw dropw dropw
1646 end
1647 ",
1648 recipient = recipient,
1649 note_type = NoteType::Private as u8,
1650 tag = u32::from(tag),
1651 amount = amount,
1652 faucet_id_prefix = faucet_account.id().prefix().as_felt(),
1653 faucet_id_suffix = faucet_account.id().suffix(),
1654 );
1655 let tx_script = CodeBuilder::with_source_manager(source_manager.clone())
1656 .compile_tx_script(tx_script_code)
1657 .unwrap();
1658 let tx = Box::pin(
1659 chain
1660 .build_tx_context(
1661 miden_testing::TxContextInput::Account(faucet_account.clone()),
1662 &[],
1663 &[],
1664 )
1665 .unwrap()
1666 .extend_advice_inputs(recipient_advice.clone())
1667 .tx_script(tx_script)
1668 .with_source_manager(source_manager)
1669 .build()
1670 .unwrap()
1671 .execute(),
1672 )
1673 .await
1674 .unwrap();
1675
1676 for output_note in tx.output_notes().iter() {
1677 note_tags.insert(output_note.metadata().tag());
1678 }
1679
1680 faucet_account.apply_delta(tx.account_delta()).unwrap();
1681 chain.add_pending_executed_transaction(&tx).unwrap();
1682 chain.prove_next_block().unwrap();
1683 }
1684
1685 (chain, note_tags)
1686 }
1687
1688 #[tokio::test]
1698 async fn sync_state_tracks_note_blocks_in_mmr() {
1699 let (chain, note_tags) = build_chain_with_mint_notes(3).await;
1700 let mock_rpc = MockRpcApi::new(chain);
1701 let chain_tip = mock_rpc.get_chain_tip_block_num();
1702
1703 let note_blocks = mock_rpc
1705 .sync_notes(BlockNumber::from(0u32), chain_tip, ¬e_tags)
1706 .await
1707 .unwrap();
1708 assert!(
1709 note_blocks.len() >= 2,
1710 "expected notes in multiple blocks, got {}",
1711 note_blocks.len()
1712 );
1713
1714 let note_block_nums: BTreeSet<BlockNumber> =
1716 note_blocks.iter().map(|b| b.block_header.block_num()).collect();
1717
1718 let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
1721
1722 let genesis_peaks =
1723 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
1724 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
1725
1726 let sync_data = state_sync
1727 .fetch_sync_data(BlockNumber::GENESIS, &[], &Arc::new(note_tags.clone()))
1728 .await
1729 .unwrap()
1730 .expect("should have progressed past genesis");
1731
1732 assert_eq!(sync_data.chain_tip_header.block_num(), chain_tip);
1734 assert!(!sync_data.note_blocks.is_empty(), "should have note blocks");
1735
1736 let _auth_nodes: Vec<(InOrderIndex, Word)> =
1738 partial_mmr.apply(sync_data.mmr_delta).map_err(StoreError::MmrError).unwrap();
1739 partial_mmr
1740 .add(sync_data.chain_tip_header.commitment(), false)
1741 .expect("chain tip should append to the partial MMR");
1742
1743 assert_eq!(partial_mmr.forest().num_leaves(), chain_tip.as_u32() as usize + 1);
1744
1745 for block in &sync_data.note_blocks {
1747 let bn = block.block_header.block_num();
1748 partial_mmr
1749 .track(bn.as_usize(), block.block_header.commitment(), &block.mmr_path)
1750 .map_err(StoreError::MmrError)
1751 .unwrap();
1752
1753 assert!(
1754 partial_mmr.is_tracked(bn.as_usize()),
1755 "block {bn} should be tracked after calling track()"
1756 );
1757 }
1758
1759 for &bn in ¬e_block_nums {
1761 assert!(
1762 partial_mmr.is_tracked(bn.as_usize()),
1763 "block {bn} with notes should be tracked in partial MMR"
1764 );
1765 }
1766 }
1767
1768 #[tokio::test]
1769 async fn sync_notes_with_details_fetches_inclusive_upper_bound_page() {
1770 let (chain, note_tags) = build_chain_with_mint_notes(10).await;
1771 let mock_rpc = MockRpcApi::new(chain);
1772
1773 let (blocks, _synced_notes) = mock_rpc
1774 .sync_notes_with_details(4_u32.into(), 10_u32.into(), ¬e_tags)
1775 .await
1776 .expect("sync notes should succeed");
1777
1778 assert_eq!(blocks.last().unwrap().block_header.block_num(), BlockNumber::from(10u32));
1779 assert!(
1780 blocks
1781 .iter()
1782 .any(|block| block.block_header.block_num() == BlockNumber::from(9u32))
1783 );
1784 }
1785
1786 #[tokio::test]
1792 async fn erased_notes_are_marked_as_consumed() {
1793 let sender_id: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
1795 let partial_metadata = PartialNoteMetadata::new(sender_id, NoteType::Public);
1796 let metadata = NoteMetadata::new(partial_metadata, &NoteAttachments::empty());
1797 let script = CodeBuilder::new()
1798 .compile_note_script("@note_script\npub proc main\n nop\nend")
1799 .unwrap();
1800 let recipient = NoteRecipient::new(
1801 Word::from([1u32, 2, 3, 4]),
1802 script,
1803 NoteStorage::new(vec![]).unwrap(),
1804 );
1805 let output_note = OutputNoteRecord::new(
1806 recipient.digest(),
1807 NoteAssets::new(vec![]).unwrap(),
1808 metadata,
1809 OutputNoteState::ExpectedFull { recipient },
1810 BlockNumber::from(1u32),
1811 NoteAttachments::default(),
1812 );
1813 let note_id = output_note.id();
1814 let note_header = NoteHeader::new(output_note.details_commitment(), metadata);
1815
1816 let mut note_updates = NoteUpdateTracker::new(vec![], vec![output_note]);
1818
1819 let block_num = BlockNumber::from(3u32);
1821 note_updates
1822 .mark_erased_note_as_consumed(¬e_header, block_num)
1823 .expect("marking erased note should succeed");
1824
1825 let updated = note_updates
1826 .updated_output_notes()
1827 .find(|n| n.id() == note_id)
1828 .expect("output note should be in the update");
1829
1830 assert!(
1831 updated.inner().is_consumed(),
1832 "output note should be consumed after erasure detection, but state is: {}",
1833 updated.inner().state()
1834 );
1835 }
1836
1837 #[allow(clippy::too_many_lines)]
1854 #[ignore = "consumer derivation removed; see comment above"]
1855 #[tokio::test]
1856 async fn erased_notes_are_marked_as_consumed_by_network_account() {
1857 let mut builder = MockChainBuilder::new();
1860 let p2id_sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
1861 let faucet_id: AccountId = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into().unwrap();
1862 let sender_account =
1863 builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1864 let sender_id = sender_account.id();
1865
1866 let asset = Asset::Fungible(FungibleAsset::new(faucet_id, 100u64).unwrap());
1867 let note = builder
1868 .add_p2id_note(p2id_sender, sender_id, &[asset], NoteType::Public)
1869 .unwrap();
1870
1871 let mut chain = builder.build().unwrap();
1872 chain.prove_next_block().unwrap();
1873
1874 let tx = Box::pin(
1875 chain
1876 .build_tx_context(
1877 TxContextInput::Account(sender_account.clone()),
1878 &[],
1879 core::slice::from_ref(¬e),
1880 )
1881 .unwrap()
1882 .build()
1883 .unwrap()
1884 .execute(),
1885 )
1886 .await
1887 .unwrap();
1888 chain.add_pending_executed_transaction(&tx).unwrap();
1889 chain.prove_next_block().unwrap();
1890
1891 let network_account_id: AccountId =
1893 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into().unwrap();
1894 let target =
1895 NetworkAccountTarget::new(network_account_id, NoteExecutionHint::Always).unwrap();
1896 let attachment: NoteAttachment = target.into();
1897 let attachments = NoteAttachments::new(vec![attachment]).unwrap();
1898 let partial_metadata = PartialNoteMetadata::new(sender_id, NoteType::Public);
1899 let metadata = NoteMetadata::new(partial_metadata, &attachments);
1900 let script = CodeBuilder::new()
1901 .compile_note_script("@note_script\npub proc main\n nop\nend")
1902 .unwrap();
1903 let recipient = NoteRecipient::new(
1904 Word::from([7u32, 8, 9, 10]),
1905 script,
1906 NoteStorage::new(vec![]).unwrap(),
1907 );
1908 let recipient_digest = recipient.digest();
1909 let assets = NoteAssets::new(vec![]).unwrap();
1910
1911 let output_note = OutputNoteRecord::new(
1914 recipient_digest,
1915 assets.clone(),
1916 metadata,
1917 OutputNoteState::ExpectedFull { recipient },
1918 BlockNumber::from(1u32),
1919 NoteAttachments::default(),
1920 );
1921 let erased_note_id = output_note.id();
1922 let erased_note_header = NoteHeader::new(output_note.details_commitment(), metadata);
1923
1924 let mock_rpc = MockRpcApi::new(chain);
1925 mock_rpc.mark_note_as_erased(erased_note_header);
1926
1927 let network_header =
1930 AccountHeader::new(network_account_id, ZERO, EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
1931
1932 let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
1933
1934 let genesis_peaks =
1935 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
1936 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
1937
1938 let sync_input = StateSyncInput {
1939 accounts: vec![AccountHeader::from(sender_account), network_header],
1940 note_tags: BTreeSet::new(),
1941 input_notes: vec![],
1942 output_notes: vec![output_note],
1943 uncommitted_transactions: vec![],
1944 };
1945
1946 let update = state_sync.sync_state(&mut partial_mmr, sync_input).await.unwrap();
1947
1948 let updated_output = update
1950 .note_updates
1951 .updated_output_notes()
1952 .find(|n| n.id() == erased_note_id)
1953 .expect("output note should be in the update");
1954 assert!(
1955 updated_output.inner().is_consumed(),
1956 "output note should be consumed, got: {}",
1957 updated_output.inner().state()
1958 );
1959
1960 let input_note_update = update
1962 .note_updates
1963 .updated_input_notes()
1964 .find(|n| n.id() == Some(erased_note_id))
1965 .expect("input note should be created from the erased output note");
1966
1967 let inner = input_note_update.inner();
1968 assert!(
1969 inner.is_consumed(),
1970 "input note should be in a consumed state, got: {}",
1971 inner.state()
1972 );
1973 assert_eq!(
1974 inner.consumer_account(),
1975 Some(network_account_id),
1976 "consumer should be the tracked network account"
1977 );
1978 }
1979
1980 #[tokio::test]
1983 async fn validate_chain_mmr_response_rejects_tampered_responses() {
1984 let mock_rpc = MockRpcApi::default();
1985 mock_rpc.advance_blocks(3);
1986 let chain_tip = mock_rpc.get_chain_tip_block_num();
1987 let current = BlockNumber::GENESIS;
1988
1989 let header_of =
1990 |block_num: u32| mock_rpc.mock_chain.read().block_header(block_num as usize);
1991 let chain_mmr_response = || async {
1992 mock_rpc.sync_chain_mmr(current, SyncTarget::CommittedChainTip).await.unwrap()
1993 };
1994
1995 let response = chain_mmr_response().await;
1997 StateSync::validate_chain_mmr_response(&response, current).unwrap();
1998
1999 let mut response = chain_mmr_response().await;
2001 response.block_header = header_of(chain_tip.as_u32() - 1);
2002 let result = StateSync::validate_chain_mmr_response(&response, current);
2003 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2004
2005 let mut response = chain_mmr_response().await;
2007 response.block_from = current + 1;
2008 let result = StateSync::validate_chain_mmr_response(&response, current);
2009 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2010
2011 let mut response = chain_mmr_response().await;
2013 response.block_from = chain_tip;
2014 response.block_to = BlockNumber::GENESIS;
2015 response.block_header = header_of(0);
2016 let result = StateSync::validate_chain_mmr_response(&response, chain_tip);
2017 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2018 }
2019
2020 #[test]
2023 fn validate_note_blocks_range_rejects_out_of_range_blocks() {
2024 let mock_rpc = MockRpcApi::default();
2025 mock_rpc.advance_blocks(3);
2026 let chain_tip = mock_rpc.get_chain_tip_block_num();
2027 let current = BlockNumber::GENESIS;
2028
2029 StateSync::validate_note_blocks_range(&[], current, chain_tip).unwrap();
2031
2032 let genesis_note_block = NoteSyncBlock {
2034 block_header: mock_rpc.mock_chain.read().block_header(0),
2035 mmr_path: MerklePath::new(Vec::new()),
2036 notes: BTreeMap::new(),
2037 };
2038 let result =
2039 StateSync::validate_note_blocks_range(&[genesis_note_block], current, chain_tip);
2040 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2041 }
2042
2043 #[test]
2046 fn advance_mmr_rejects_delta_inconsistent_with_chain_commitment() {
2047 let mock_rpc = MockRpcApi::default();
2048 mock_rpc.advance_blocks(3);
2049 let chain_tip = mock_rpc.get_chain_tip_block_num();
2050
2051 let chain_tip_header = mock_rpc.mock_chain.read().block_header(chain_tip.as_usize());
2052 let genesis_partial_mmr = || {
2053 let peaks = mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2054 PartialMmr::from_peaks(peaks)
2055 };
2056
2057 let full_delta = mock_rpc
2059 .get_mmr()
2060 .get_delta(Forest::new(1).unwrap(), Forest::new(chain_tip.as_usize()).unwrap())
2061 .unwrap();
2062 StateSync::advance_mmr(
2063 full_delta,
2064 &chain_tip_header,
2065 &mut genesis_partial_mmr(),
2066 &mut PartialBlockchainUpdates::default(),
2067 )
2068 .unwrap();
2069
2070 let truncated_delta = mock_rpc
2072 .get_mmr()
2073 .get_delta(Forest::new(1).unwrap(), Forest::new(chain_tip.as_usize() - 1).unwrap())
2074 .unwrap();
2075 let result = StateSync::advance_mmr(
2076 truncated_delta,
2077 &chain_tip_header,
2078 &mut genesis_partial_mmr(),
2079 &mut PartialBlockchainUpdates::default(),
2080 );
2081 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2082 }
2083}