1use alloc::boxed::Box;
2use alloc::collections::{BTreeMap, BTreeSet};
3use alloc::sync::Arc;
4use alloc::vec::Vec;
5use core::cmp::Ordering;
6
7use async_trait::async_trait;
8use miden_protocol::Word;
9use miden_protocol::account::{Account, AccountHeader, AccountId, StorageSlotType};
10use miden_protocol::block::account_tree::AccountIdKey;
11use miden_protocol::block::{BlockHeader, BlockNumber};
12use miden_protocol::crypto::merkle::mmr::{MmrDelta, PartialMmr};
13use miden_protocol::note::{NoteAttachments, NoteId, NoteTag, NoteType, Nullifier};
14use tracing::info;
15
16use super::state_sync_update::TransactionUpdateTracker;
17use super::{
18 AccountUpdates,
19 NoteObserver,
20 PartialBlockchainUpdates,
21 PublicAccountDelta,
22 PublicAccountUpdate,
23 StateSyncUpdate,
24};
25use crate::ClientError;
26use crate::note::{NoteConsumption, NoteUpdateTracker};
27use crate::rpc::domain::account::{
28 AccountDetails,
29 AccountProof,
30 GetAccountRequest,
31 StorageMapFetch,
32 VaultFetch,
33};
34use crate::rpc::domain::note::{CommittedNote, NoteSyncBlock, SyncedNoteDetails};
35use crate::rpc::domain::sync::{ChainMmrInfo, SyncTarget};
36use crate::rpc::domain::transaction::TransactionRecord as RpcTransactionRecord;
37use crate::rpc::{AccountStateAt, NodeRpcClient};
38use crate::store::{InputNoteRecord, OutputNoteRecord, StoreError};
39use crate::transaction::TransactionRecord;
40
41enum PublicAccountSync {
46 Apply(Box<PublicAccountUpdate>),
48 Superseded,
50 Ignore,
52}
53
54struct FetchedSyncData {
60 mmr_delta: MmrDelta,
62 chain_tip_header: BlockHeader,
64 note_blocks: Vec<NoteSyncBlock>,
66 synced_notes: BTreeMap<NoteId, SyncedNoteDetails>,
69 transactions: Vec<RpcTransactionRecord>,
71}
72
73pub struct StateSyncInput {
89 pub accounts: Vec<AccountHeader>,
91 pub note_tags: BTreeSet<NoteTag>,
93 pub input_notes: Vec<InputNoteRecord>,
95 pub output_notes: Vec<OutputNoteRecord>,
101 pub uncommitted_transactions: Vec<TransactionRecord>,
103}
104
105#[allow(clippy::large_enum_variant)]
110pub enum NoteUpdateAction {
111 Commit(CommittedNote),
114 Insert(InputNoteRecord),
116 Discard,
118}
119
120#[async_trait(?Send)]
121pub trait OnNoteReceived {
122 async fn on_note_received(
133 &self,
134 committed_note: CommittedNote,
135 public_note: Option<InputNoteRecord>,
136 ) -> Result<NoteUpdateAction, ClientError>;
137}
138#[derive(Clone)]
145pub struct StateSync {
146 rpc_api: Arc<dyn NodeRpcClient>,
148 note_screener: Arc<dyn OnNoteReceived>,
151 note_observers: Vec<Arc<dyn NoteObserver>>,
154 tx_discard_delta: Option<u32>,
157 sync_nullifiers: bool,
160}
161
162impl StateSync {
163 pub fn new(
174 rpc_api: Arc<dyn NodeRpcClient>,
175 note_screener: Arc<dyn OnNoteReceived>,
176 tx_discard_delta: Option<u32>,
177 ) -> Self {
178 Self {
179 rpc_api,
180 note_screener,
181 note_observers: Vec::new(),
182 tx_discard_delta,
183 sync_nullifiers: true,
184 }
185 }
186
187 #[must_use]
191 pub fn with_note_observer(mut self, observer: Arc<dyn NoteObserver>) -> Self {
192 self.note_observers.push(observer);
193 self
194 }
195
196 pub fn disable_nullifier_sync(&mut self) {
202 self.sync_nullifiers = false;
203 }
204
205 pub fn enable_nullifier_sync(&mut self) {
207 self.sync_nullifiers = true;
208 }
209
210 pub(crate) async fn run_apply_hooks(
217 &self,
218 state_sync_update: &StateSyncUpdate,
219 ) -> Result<(), ClientError> {
220 for observer in &self.note_observers {
221 crate::errors::log_observer_failure(
222 observer.name(),
223 "NoteObserver::apply",
224 observer.apply(state_sync_update).await,
225 );
226 }
227 Ok(())
228 }
229
230 pub async fn sync_state(
248 &self,
249 current_partial_mmr: &mut PartialMmr,
250 input: StateSyncInput,
251 ) -> Result<StateSyncUpdate, ClientError> {
252 let StateSyncInput {
253 accounts,
254 note_tags,
255 input_notes,
256 output_notes,
257 uncommitted_transactions,
258 } = input;
259 let block_num = u32::try_from(current_partial_mmr.forest().num_leaves().saturating_sub(1))
260 .map_err(|_| ClientError::InvalidPartialMmrForest)?
261 .into();
262
263 let note_tags = Arc::new(note_tags);
264 let account_ids: Vec<AccountId> = accounts.iter().map(AccountHeader::id).collect();
265
266 let mut state_sync_update = StateSyncUpdate {
267 block_num,
268 note_updates: NoteUpdateTracker::new(input_notes, output_notes),
269 transaction_updates: TransactionUpdateTracker::new(uncommitted_transactions),
270 ..Default::default()
271 };
272 let Some(sync_data) = self
273 .fetch_sync_data(state_sync_update.block_num, &account_ids, ¬e_tags)
274 .await?
275 else {
276 return Ok(state_sync_update);
278 };
279
280 state_sync_update.block_num = sync_data.chain_tip_header.block_num();
281
282 let new_commitments = derive_account_commitments(&sync_data.transactions);
283 let superseded_states = self
284 .account_state_sync(
285 &mut state_sync_update.account_updates,
286 &accounts,
287 &new_commitments,
288 block_num,
289 &sync_data.chain_tip_header,
290 )
291 .await?;
292
293 for superseded_state in superseded_states {
295 state_sync_update
296 .transaction_updates
297 .apply_superseded_account_state(superseded_state);
298 }
299
300 self.apply_sync_result(sync_data, &mut state_sync_update, current_partial_mmr)
302 .await?;
303
304 if self.sync_nullifiers {
305 self.nullifiers_state_sync(&mut state_sync_update, block_num).await?;
306 }
307
308 Ok(state_sync_update)
309 }
310
311 async fn fetch_sync_data(
320 &self,
321 current_block_num: BlockNumber,
322 account_ids: &[AccountId],
323 note_tags: &Arc<BTreeSet<NoteTag>>,
324 ) -> Result<Option<FetchedSyncData>, ClientError> {
325 let chain_mmr_info = self
327 .rpc_api
328 .sync_chain_mmr(current_block_num, SyncTarget::CommittedChainTip)
329 .await?;
330 let chain_tip = chain_mmr_info.block_to;
331
332 Self::validate_chain_mmr_response(&chain_mmr_info, current_block_num)?;
334
335 if chain_tip == current_block_num {
337 info!(block_num = %current_block_num, "Already at chain tip, nothing to sync.");
338 return Ok(None);
339 }
340
341 info!(
342 block_from = %current_block_num,
343 block_to = %chain_tip,
344 "Syncing state.",
345 );
346
347 let (note_blocks, synced_notes) = if note_tags.is_empty() {
352 (Vec::new(), BTreeMap::new())
353 } else {
354 self.rpc_api
355 .sync_notes_with_details(current_block_num + 1, chain_tip, note_tags.as_ref())
356 .await?
357 };
358
359 Self::validate_note_blocks_range(¬e_blocks, current_block_num, chain_tip)?;
361
362 let note_count: usize = note_blocks.iter().map(|b| b.notes.len()).sum();
363 info!(
364 blocks_with_notes = note_blocks.len(),
365 notes = note_count,
366 synced_notes = synced_notes.len(),
367 "Fetched note sync data.",
368 );
369
370 let transaction_records = if account_ids.is_empty() {
373 Vec::new()
374 } else {
375 self.rpc_api
376 .sync_transactions(current_block_num + 1, chain_tip, account_ids.to_vec())
377 .await?
378 };
379
380 Ok(Some(FetchedSyncData {
381 mmr_delta: chain_mmr_info.mmr_delta,
382 chain_tip_header: chain_mmr_info.block_header,
383 note_blocks,
384 synced_notes,
385 transactions: transaction_records,
386 }))
387 }
388
389 async fn apply_sync_result(
399 &self,
400 sync_data: FetchedSyncData,
401 state_sync_update: &mut StateSyncUpdate,
402 current_partial_mmr: &mut PartialMmr,
403 ) -> Result<(), ClientError> {
404 let FetchedSyncData {
405 mmr_delta,
406 chain_tip_header,
407 note_blocks,
408 synced_notes,
409 transactions,
410 } = sync_data;
411
412 let mut working_mmr = current_partial_mmr.clone();
415
416 Self::advance_mmr(
417 mmr_delta,
418 &chain_tip_header,
419 &mut working_mmr,
420 &mut state_sync_update.partial_blockchain_updates,
421 )?;
422
423 self.screen_note_blocks(note_blocks, synced_notes, state_sync_update, &mut working_mmr)
424 .await?;
425
426 self.apply_transactions_and_nullifiers(
427 &chain_tip_header,
428 &transactions,
429 state_sync_update,
430 )?;
431
432 *current_partial_mmr = working_mmr;
434
435 Ok(())
436 }
437
438 fn validate_chain_mmr_response(
440 chain_mmr_info: &ChainMmrInfo,
441 current_block_num: BlockNumber,
442 ) -> Result<(), ClientError> {
443 if chain_mmr_info.block_header.block_num() != chain_mmr_info.block_to {
444 return Err(ClientError::ChainValidationError(format!(
445 "sync_chain_mmr block_header.block_num ({}) does not match block_to ({})",
446 chain_mmr_info.block_header.block_num(),
447 chain_mmr_info.block_to
448 )));
449 }
450 if chain_mmr_info.block_from != current_block_num {
451 return Err(ClientError::ChainValidationError(format!(
452 "sync_chain_mmr block_from mismatch: expected {current_block_num}, got {}",
453 chain_mmr_info.block_from
454 )));
455 }
456 if chain_mmr_info.block_to < current_block_num {
457 return Err(ClientError::ChainValidationError(format!(
458 "sync_chain_mmr block_to ({}) is behind current block {current_block_num}",
459 chain_mmr_info.block_to
460 )));
461 }
462 Ok(())
463 }
464
465 fn validate_note_blocks_range(
468 note_blocks: &[NoteSyncBlock],
469 current_block_num: BlockNumber,
470 chain_tip: BlockNumber,
471 ) -> Result<(), ClientError> {
472 for block in note_blocks {
473 let block_num = block.block_header.block_num();
474 if block_num <= current_block_num || block_num > chain_tip {
475 return Err(ClientError::ChainValidationError(format!(
476 "sync_notes returned block {block_num} outside requested range ({current_block_num}, {chain_tip}]"
477 )));
478 }
479 }
480 Ok(())
481 }
482
483 fn advance_mmr(
490 mmr_delta: MmrDelta,
491 chain_tip_header: &BlockHeader,
492 current_partial_mmr: &mut PartialMmr,
493 partial_blockchain_updates: &mut PartialBlockchainUpdates,
494 ) -> Result<(), ClientError> {
495 let mut new_authentication_nodes =
496 current_partial_mmr.apply(mmr_delta).map_err(StoreError::MmrError)?;
497 let new_peaks = current_partial_mmr.peaks();
498
499 let peaks_commitment = new_peaks.hash_peaks();
503 if peaks_commitment != chain_tip_header.chain_commitment() {
504 return Err(ClientError::ChainValidationError(format!(
505 "MMR peaks commitment is {} and does not match block header chain commitment {}",
506 peaks_commitment.to_hex(),
507 chain_tip_header.chain_commitment().to_hex()
508 )));
509 }
510
511 partial_blockchain_updates.new_peaks = new_peaks;
512
513 new_authentication_nodes.append(
518 &mut current_partial_mmr
519 .add(chain_tip_header.commitment(), false)
520 .map_err(StoreError::MmrError)?,
521 );
522
523 partial_blockchain_updates.insert(
524 chain_tip_header.clone(),
525 false,
526 new_authentication_nodes,
527 );
528
529 Ok(())
530 }
531
532 async fn screen_note_blocks(
536 &self,
537 note_blocks: Vec<NoteSyncBlock>,
538 synced_notes: BTreeMap<NoteId, SyncedNoteDetails>,
539 state_sync_update: &mut StateSyncUpdate,
540 current_partial_mmr: &mut PartialMmr,
541 ) -> Result<(), ClientError> {
542 let private_attachments: BTreeMap<NoteId, NoteAttachments> = synced_notes
545 .iter()
546 .filter_map(|(id, synced)| match synced {
547 SyncedNoteDetails::Private(Some(attachments)) => Some((*id, attachments.clone())),
548 _ => None,
549 })
550 .collect();
551 let public_note_records = Self::build_public_note_records(synced_notes, ¬e_blocks);
552
553 for block in note_blocks {
554 let found_relevant_note = self
555 .note_state_sync(
556 &mut state_sync_update.note_updates,
557 block.notes,
558 &block.block_header,
559 &public_note_records,
560 &private_attachments,
561 )
562 .await?;
563
564 if found_relevant_note {
565 let block_pos = block.block_header.block_num().as_usize();
566
567 let nodes_before: BTreeMap<_, _> =
568 current_partial_mmr.nodes().map(|(k, v)| (*k, *v)).collect();
569
570 if !current_partial_mmr.is_tracked(block_pos) {
571 current_partial_mmr
572 .track(block_pos, block.block_header.commitment(), &block.mmr_path)
573 .map_err(StoreError::MmrError)?;
574 }
575
576 let track_auth_nodes: Vec<_> = current_partial_mmr
581 .nodes()
582 .filter(|(k, _)| !nodes_before.contains_key(k))
583 .map(|(k, v)| (*k, *v))
584 .collect();
585
586 state_sync_update.partial_blockchain_updates.insert(
587 block.block_header,
588 true,
589 track_auth_nodes,
590 );
591 }
592 }
593
594 Ok(())
595 }
596
597 fn apply_transactions_and_nullifiers(
601 &self,
602 chain_tip_header: &BlockHeader,
603 transactions: &[RpcTransactionRecord],
604 state_sync_update: &mut StateSyncUpdate,
605 ) -> Result<(), ClientError> {
606 state_sync_update
607 .note_updates
608 .extend_nullifiers(compute_ordered_nullifiers(transactions));
609
610 for record in transactions {
611 state_sync_update
612 .transaction_updates
613 .apply_transaction_inclusion(record, u64::from(chain_tip_header.timestamp())); }
615 state_sync_update
616 .transaction_updates
617 .apply_sync_height_update(chain_tip_header.block_num(), self.tx_discard_delta);
618
619 for transaction in transactions {
620 state_sync_update
624 .note_updates
625 .apply_output_note_inclusion_proofs(&transaction.output_notes)?;
626
627 Self::mark_erased_notes_as_consumed(state_sync_update, transaction);
629 }
630
631 Ok(())
632 }
633
634 fn mark_erased_notes_as_consumed(
640 state_sync_update: &mut StateSyncUpdate,
641 transaction: &RpcTransactionRecord,
642 ) {
643 for note_header in &transaction.erased_output_notes {
644 let _ = state_sync_update
646 .note_updates
647 .mark_erased_note_as_consumed(note_header, transaction.block_num);
648 }
649 }
650
651 async fn account_state_sync(
664 &self,
665 account_updates: &mut AccountUpdates,
666 accounts: &[AccountHeader],
667 account_commitment_updates: &[(AccountId, Word)],
668 block_from: BlockNumber,
669 chain_tip_header: &BlockHeader,
670 ) -> Result<Vec<Word>, ClientError> {
671 let (public_accounts, private_accounts): (Vec<_>, Vec<_>) =
674 accounts.iter().partition(|header| !header.id().is_private());
675
676 let superseded_states = self
677 .sync_public_accounts(
678 account_updates,
679 account_commitment_updates,
680 &public_accounts,
681 block_from,
682 chain_tip_header,
683 )
684 .await?;
685
686 let mismatched_private_accounts = account_commitment_updates
687 .iter()
688 .filter(|(account_id, digest)| {
689 private_accounts
690 .iter()
691 .any(|header| header.id() == *account_id && &header.to_commitment() != digest)
692 })
693 .copied()
694 .collect::<Vec<_>>();
695
696 account_updates.extend(AccountUpdates::new(Vec::new(), mismatched_private_accounts));
697
698 Ok(superseded_states)
699 }
700
701 async fn sync_public_accounts(
710 &self,
711 account_updates: &mut AccountUpdates,
712 commitment_updates: &[(AccountId, Word)],
713 current_public_accounts: &[&AccountHeader],
714 block_from: BlockNumber,
715 chain_tip_header: &BlockHeader,
716 ) -> Result<Vec<Word>, ClientError> {
717 let local_headers: BTreeMap<AccountId, &AccountHeader> =
718 current_public_accounts.iter().map(|header| (header.id(), *header)).collect();
719 let mut superseded_states = Vec::new();
721 for (id, commitment) in commitment_updates {
722 let Some(local_header) = local_headers.get(id).copied() else {
723 continue;
724 };
725
726 if local_header.to_commitment() == *commitment {
727 continue;
728 }
729
730 match self
731 .sync_public_account(*id, local_header, block_from, chain_tip_header)
732 .await?
733 {
734 PublicAccountSync::Apply(public_update) => {
735 account_updates.extend(AccountUpdates::new(vec![*public_update], Vec::new()));
736 },
737 PublicAccountSync::Superseded => {
738 superseded_states.push(local_header.to_commitment());
739 },
740 PublicAccountSync::Ignore => {},
741 }
742 }
743
744 Ok(superseded_states)
745 }
746
747 async fn sync_public_account(
761 &self,
762 account_id: AccountId,
763 local_header: &AccountHeader,
764 block_from: BlockNumber,
765 chain_tip_header: &BlockHeader,
766 ) -> Result<PublicAccountSync, ClientError> {
767 let target_block_num = chain_tip_header.block_num();
768
769 let (proof_block_num, proof) = self
772 .rpc_api
773 .get_account(
774 account_id,
775 GetAccountRequest::new()
776 .at(AccountStateAt::Block(target_block_num))
777 .with_storage(StorageMapFetch::All)
778 .with_vault(VaultFetch::Always),
779 )
780 .await
781 .map_err(ClientError::RpcError)?;
782
783 let details =
784 Self::validate_account_proof(proof, proof_block_num, account_id, chain_tip_header)?;
785
786 match details
787 .header
788 .nonce()
789 .as_canonical_u64()
790 .cmp(&local_header.nonce().as_canonical_u64())
791 {
792 Ordering::Less => return Ok(PublicAccountSync::Ignore),
795 Ordering::Equal => return Ok(PublicAccountSync::Superseded),
797 Ordering::Greater => {},
799 }
800
801 let vault_oversized = details.vault_details.too_many_assets;
802 let any_map_oversized =
803 details.storage_details.map_details.iter().any(|m| m.too_many_entries);
804
805 let public_update = if vault_oversized || any_map_oversized {
809 self.build_delta_update(account_id, &details, block_from, proof_block_num)
811 .await?
812 } else {
813 let account = Account::try_from(&details).map_err(ClientError::RpcError)?;
815 PublicAccountUpdate::Full(account)
816 };
817
818 Ok(PublicAccountSync::Apply(Box::new(public_update)))
819 }
820
821 fn validate_account_proof(
837 proof: AccountProof,
838 proof_block_num: BlockNumber,
839 account_id: AccountId,
840 chain_tip_header: &BlockHeader,
841 ) -> Result<AccountDetails, ClientError> {
842 let target_block_num = chain_tip_header.block_num();
843
844 if proof_block_num != target_block_num {
845 return Err(ClientError::ChainValidationError(format!(
846 "get_account returned block {proof_block_num} but {target_block_num} was requested"
847 )));
848 }
849
850 let (witness, details) = proof.into_parts();
851
852 if witness.id() != account_id {
854 return Err(ClientError::ChainValidationError(format!(
855 "get_account returned account {} but {account_id} was requested",
856 witness.id()
857 )));
858 }
859
860 let account_key = AccountIdKey::from(account_id).as_word();
861 let state_commitment = witness.state_commitment();
862 witness
863 .into_proof()
864 .verify_presence(&account_key, &state_commitment, &chain_tip_header.account_root())
865 .map_err(|err| {
866 ClientError::ChainValidationError(format!(
867 "get_account witness for account {account_id} does not open under block \
868 {target_block_num} account root: {err}"
869 ))
870 })?;
871
872 Ok(details.expect("node returned no details for a public account"))
873 }
874
875 async fn build_delta_update(
878 &self,
879 account_id: AccountId,
880 details: &AccountDetails,
881 block_from: BlockNumber,
882 block_to: BlockNumber,
883 ) -> Result<PublicAccountUpdate, ClientError> {
884 let value_slot_updates: Vec<(_, Word)> = details
885 .storage_details
886 .header
887 .slots()
888 .filter(|slot| slot.slot_type() == StorageSlotType::Value)
889 .map(|slot| (slot.name().clone(), slot.value()))
890 .collect();
891
892 let map_info = self
895 .rpc_api
896 .sync_storage_maps(block_from + 1, block_to, account_id)
897 .await
898 .map_err(ClientError::RpcError)?;
899 let vault_info = self
900 .rpc_api
901 .sync_account_vault(block_from + 1, block_to, account_id)
902 .await
903 .map_err(ClientError::RpcError)?;
904
905 Ok(PublicAccountUpdate::Delta(PublicAccountDelta::new(
906 details.header.clone(),
907 block_from,
908 block_to,
909 value_slot_updates,
910 map_info.updates,
911 vault_info.updates,
912 )))
913 }
914
915 async fn note_state_sync(
932 &self,
933 note_updates: &mut NoteUpdateTracker,
934 note_inclusions: BTreeMap<NoteId, CommittedNote>,
935 block_header: &BlockHeader,
936 public_notes: &BTreeMap<NoteId, InputNoteRecord>,
937 private_attachments: &BTreeMap<NoteId, NoteAttachments>,
938 ) -> Result<bool, ClientError> {
939 let mut found_relevant_note = false;
941
942 for (_, committed_note) in note_inclusions {
943 let public_note = (committed_note.note_type() != NoteType::Private)
944 .then(|| public_notes.get(committed_note.note_id()))
945 .flatten()
946 .cloned();
947
948 if !self.note_observers.is_empty() {
953 let note_attachments = if committed_note.note_type() == NoteType::Private {
957 private_attachments.get(committed_note.note_id())
958 } else {
959 public_note.as_ref().map(InputNoteRecord::attachments)
960 };
961 for obs in &self.note_observers {
962 match obs.observe(&committed_note, note_attachments).await {
963 Ok(true) => found_relevant_note = true,
964 Ok(false) => {},
965 Err(err) => {
966 tracing::warn!(
967 observer = obs.name(),
968 error = ?err,
969 "note observer failed; sync continues",
970 );
971 },
972 }
973 }
974 }
975
976 match self.note_screener.on_note_received(committed_note, public_note).await? {
977 NoteUpdateAction::Commit(committed_note) => {
978 let attachments = private_attachments.get(committed_note.note_id());
982 found_relevant_note |= note_updates.apply_committed_note_state_transitions(
983 &committed_note,
984 block_header,
985 attachments,
986 )?;
987 },
988 NoteUpdateAction::Insert(public_note) => {
989 found_relevant_note = true;
990
991 note_updates.apply_new_public_note(public_note, block_header)?;
992 },
993 NoteUpdateAction::Discard => {},
994 }
995 }
996
997 Ok(found_relevant_note)
998 }
999
1000 async fn nullifiers_state_sync(
1006 &self,
1007 state_sync_update: &mut StateSyncUpdate,
1008 current_block_num: BlockNumber,
1009 ) -> Result<(), ClientError> {
1010 let nullifiers_tags: Vec<u16> = state_sync_update
1016 .note_updates
1017 .unspent_nullifiers()
1018 .map(|nullifier| nullifier.prefix())
1019 .collect();
1020
1021 let mut new_nullifiers = self
1022 .rpc_api
1023 .sync_nullifiers(&nullifiers_tags, current_block_num + 1, state_sync_update.block_num)
1024 .await?;
1025
1026 new_nullifiers.retain(|update| update.block_num <= state_sync_update.block_num);
1029
1030 let consumptions: Vec<NoteConsumption> = new_nullifiers
1032 .into_iter()
1033 .map(|update| NoteConsumption {
1034 external_consumer: state_sync_update
1035 .transaction_updates
1036 .external_nullifier_account(&update.nullifier),
1037 nullifier: update.nullifier,
1038 block_num: update.block_num,
1039 })
1040 .collect();
1041
1042 for consumption in consumptions {
1043 state_sync_update.note_updates.apply_note_consumption(
1044 &consumption,
1045 state_sync_update.transaction_updates.committed_transactions(),
1046 )?;
1047
1048 state_sync_update
1052 .transaction_updates
1053 .apply_input_note_nullified(consumption.nullifier);
1054 }
1055
1056 Ok(())
1057 }
1058
1059 fn build_public_note_records(
1062 synced_notes: BTreeMap<NoteId, SyncedNoteDetails>,
1063 note_blocks: &[NoteSyncBlock],
1064 ) -> BTreeMap<NoteId, InputNoteRecord> {
1065 let mut records = BTreeMap::new();
1066 for (note_id, synced) in synced_notes {
1067 let SyncedNoteDetails::Public(note) = synced else {
1068 continue;
1069 };
1070 let inclusion_proof = note_blocks
1071 .iter()
1072 .find_map(|b| b.notes.get(¬e_id))
1073 .map(|committed| committed.inclusion_proof().clone());
1074
1075 if let Some(inclusion_proof) = inclusion_proof {
1076 let state = crate::store::input_note_states::UnverifiedNoteState {
1077 metadata: *note.metadata(),
1078 inclusion_proof,
1079 }
1080 .into();
1081 let attachments = note.attachments().clone();
1082 let record = InputNoteRecord::new(note.into(), attachments, None, state);
1083 let id = record.id().expect("CommittedNoteState carries metadata, so id() is Some");
1084 records.insert(id, record);
1085 }
1086 }
1087 records
1088 }
1089}
1090
1091fn group_txs_by_account_block(
1096 transaction_records: &[RpcTransactionRecord],
1097) -> BTreeMap<(AccountId, BlockNumber), Vec<&RpcTransactionRecord>> {
1098 let mut groups: BTreeMap<(AccountId, BlockNumber), Vec<&RpcTransactionRecord>> =
1099 BTreeMap::new();
1100 for record in transaction_records {
1101 let account_id = record.transaction_header.account_id();
1102 groups.entry((account_id, record.block_num)).or_default().push(record);
1103 }
1104 groups
1105}
1106
1107fn walk_execution_chain<'a>(
1113 txs: &'a [&'a RpcTransactionRecord],
1114) -> impl Iterator<Item = &'a RpcTransactionRecord> + 'a {
1115 let (self_loops, chained): (Vec<&RpcTransactionRecord>, Vec<&RpcTransactionRecord>) =
1116 txs.iter().copied().partition(|tx| {
1117 tx.transaction_header.initial_state_commitment()
1118 == tx.transaction_header.final_state_commitment()
1119 });
1120
1121 let final_states: BTreeSet<Word> = chained
1122 .iter()
1123 .map(|tx| tx.transaction_header.final_state_commitment())
1124 .collect();
1125
1126 let mut init_to_tx: BTreeMap<Word, &RpcTransactionRecord> = chained
1127 .iter()
1128 .map(|tx| (tx.transaction_header.initial_state_commitment(), *tx))
1129 .collect();
1130
1131 let start = chained
1132 .iter()
1133 .find(|tx| !final_states.contains(&tx.transaction_header.initial_state_commitment()))
1134 .copied();
1135
1136 assert!(start.is_some() || chained.is_empty(), "cannot walk cyclic execution chain");
1137
1138 let mut current =
1139 start.and_then(|tx| init_to_tx.remove(&tx.transaction_header.initial_state_commitment()));
1140 let mut self_loops_iter = self_loops.into_iter();
1141
1142 core::iter::from_fn(move || {
1143 if let Some(tx) = current {
1144 current = init_to_tx.remove(&tx.transaction_header.final_state_commitment());
1145 return Some(tx);
1146 }
1147 self_loops_iter.next()
1148 })
1149}
1150
1151fn derive_account_commitments(
1156 transaction_records: &[RpcTransactionRecord],
1157) -> Vec<(AccountId, Word)> {
1158 let mut latest_by_account: BTreeMap<AccountId, (BlockNumber, Word)> = BTreeMap::new();
1159
1160 for ((account_id, block_num), txs) in &group_txs_by_account_block(transaction_records) {
1161 let terminal_state = walk_execution_chain(txs)
1162 .last()
1163 .expect("account must have a final state")
1164 .transaction_header
1165 .final_state_commitment();
1166
1167 latest_by_account
1168 .entry(*account_id)
1169 .and_modify(|(existing_block, existing_state)| {
1170 if *block_num > *existing_block {
1171 *existing_block = *block_num;
1172 *existing_state = terminal_state;
1173 }
1174 })
1175 .or_insert((*block_num, terminal_state));
1176 }
1177
1178 latest_by_account
1179 .into_iter()
1180 .map(|(account_id, (_, state))| (account_id, state))
1181 .collect()
1182}
1183
1184fn compute_ordered_nullifiers(transaction_records: &[RpcTransactionRecord]) -> Vec<Nullifier> {
1191 let mut result = Vec::new();
1192
1193 for txs in group_txs_by_account_block(transaction_records).values() {
1194 for tx in walk_execution_chain(txs) {
1195 for commitment in tx.transaction_header.input_notes().iter() {
1196 result.push(commitment.nullifier());
1197 }
1198 }
1199 }
1200
1201 result
1202}
1203
1204#[cfg(all(test, feature = "testing"))]
1205mod tests {
1206 use alloc::collections::BTreeSet;
1207 use alloc::sync::Arc;
1208
1209 use async_trait::async_trait;
1210 use miden_protocol::account::Account;
1211 use miden_protocol::assembly::DefaultSourceManager;
1212 use miden_protocol::asset::{Asset, FungibleAsset};
1213 use miden_protocol::block::BlockNumber;
1214 use miden_protocol::crypto::merkle::MerklePath;
1215 use miden_protocol::crypto::merkle::mmr::{Forest, InOrderIndex, PartialMmr};
1216 use miden_protocol::note::{
1217 Note,
1218 NoteAssets,
1219 NoteAttachment,
1220 NoteAttachments,
1221 NoteDetails,
1222 NoteHeader,
1223 NoteMetadata,
1224 NoteRecipient,
1225 NoteStorage,
1226 NoteTag,
1227 NoteType,
1228 PartialNoteMetadata,
1229 };
1230 use miden_protocol::testing::account_id::{
1231 ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET,
1232 ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
1233 ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE,
1234 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
1235 ACCOUNT_ID_SENDER,
1236 };
1237 use miden_protocol::transaction::{InputNotes, TransactionArgs, TransactionHeader};
1238 use miden_protocol::vm::AdviceMap;
1239 use miden_protocol::{EMPTY_WORD, Felt, Word, ZERO};
1240 use miden_standards::code_builder::CodeBuilder;
1241 use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint};
1242 use miden_testing::{MockChainBuilder, TxContextInput};
1243
1244 use super::*;
1245 use crate::rpc::domain::transaction::ACCOUNT_ID_NATIVE_ASSET_FAUCET;
1246 use crate::store::{OutputNoteRecord, OutputNoteState};
1247 use crate::test_utils::mock::MockRpcApi;
1248
1249 struct MockScreener;
1251
1252 #[async_trait(?Send)]
1253 impl OnNoteReceived for MockScreener {
1254 async fn on_note_received(
1255 &self,
1256 _committed_note: CommittedNote,
1257 _public_note: Option<InputNoteRecord>,
1258 ) -> Result<NoteUpdateAction, ClientError> {
1259 Ok(NoteUpdateAction::Discard)
1260 }
1261 }
1262
1263 fn empty() -> StateSyncInput {
1264 StateSyncInput {
1265 accounts: vec![],
1266 note_tags: BTreeSet::new(),
1267 input_notes: vec![],
1268 output_notes: vec![],
1269 uncommitted_transactions: vec![],
1270 }
1271 }
1272
1273 fn word(n: u64) -> miden_protocol::Word {
1274 [
1275 Felt::new(n).expect("test value should fit into the base field"),
1276 ZERO,
1277 ZERO,
1278 ZERO,
1279 ]
1280 .into()
1281 }
1282
1283 fn header_with_account_root(header: &BlockHeader, account_root: Word) -> BlockHeader {
1284 BlockHeader::new(
1285 header.version(),
1286 header.prev_block_commitment(),
1287 header.block_num(),
1288 header.chain_commitment(),
1289 account_root,
1290 header.nullifier_root(),
1291 header.note_root(),
1292 header.tx_commitment(),
1293 header.tx_kernel_commitment(),
1294 header.validator_key().clone(),
1295 header.fee_parameters().clone(),
1296 header.timestamp(),
1297 )
1298 }
1299
1300 #[tokio::test]
1301 async fn sync_public_accounts_ignores_older_node_snapshot() {
1302 let mut builder = MockChainBuilder::new();
1303 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1304 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1305 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1306 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1307
1308 let local_header =
1311 AccountHeader::new(account.id(), Felt::from(2u32), EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
1312 let current_public_accounts = vec![&local_header];
1313 let commitment_updates = vec![(account.id(), account.to_commitment())];
1314 let mut account_updates = AccountUpdates::default();
1315
1316 let superseded = state_sync
1317 .sync_public_accounts(
1318 &mut account_updates,
1319 &commitment_updates,
1320 ¤t_public_accounts,
1321 BlockNumber::GENESIS,
1322 &chain_tip_header,
1323 )
1324 .await
1325 .unwrap();
1326
1327 assert!(
1328 account_updates.updated_public_accounts().is_empty(),
1329 "public account sync should ignore node snapshots that are older than local"
1330 );
1331 assert!(
1332 superseded.is_empty(),
1333 "an older node snapshot must not supersede the local state"
1334 );
1335 }
1336
1337 #[tokio::test]
1338 async fn sync_public_accounts_marks_same_nonce_mismatch_as_superseded() {
1339 let mut builder = MockChainBuilder::new();
1340 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1341 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1342 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1343 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1344
1345 let local_header =
1348 AccountHeader::new(account.id(), account.nonce(), EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
1349 let current_public_accounts = vec![&local_header];
1350 let commitment_updates = vec![(account.id(), account.to_commitment())];
1351 let mut account_updates = AccountUpdates::default();
1352
1353 let superseded = state_sync
1354 .sync_public_accounts(
1355 &mut account_updates,
1356 &commitment_updates,
1357 ¤t_public_accounts,
1358 BlockNumber::GENESIS,
1359 &chain_tip_header,
1360 )
1361 .await
1362 .unwrap();
1363
1364 assert!(
1365 account_updates.updated_public_accounts().is_empty(),
1366 "a same-nonce fork must not overwrite the account while its tx is still pending"
1367 );
1368 assert_eq!(
1369 superseded,
1370 vec![local_header.to_commitment()],
1371 "the superseded local state should be reported so its transaction is discarded"
1372 );
1373 }
1374
1375 #[tokio::test]
1378 async fn sync_public_accounts_pins_account_fetch_to_sync_target() {
1379 let mut builder = MockChainBuilder::new();
1380 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1381 let mut chain = builder.build().unwrap();
1382
1383 let sync_target_header = chain.latest_block_header();
1385 let tx = Box::pin(
1386 chain
1387 .build_tx_context(TxContextInput::AccountId(account.id()), &[], &[])
1388 .unwrap()
1389 .build()
1390 .unwrap()
1391 .execute(),
1392 )
1393 .await
1394 .unwrap();
1395 let local_header = tx.final_account().clone();
1396 assert_ne!(local_header.to_commitment(), account.to_commitment());
1397 chain.add_pending_executed_transaction(&tx).unwrap();
1398
1399 let rpc_api = MockRpcApi::new(chain);
1400 rpc_api.prove_block();
1402 assert_eq!(
1403 rpc_api
1404 .mock_chain
1405 .read()
1406 .committed_account(account.id())
1407 .unwrap()
1408 .to_commitment(),
1409 local_header.to_commitment()
1410 );
1411 let state_sync = StateSync::new(Arc::new(rpc_api), Arc::new(MockScreener), None);
1412
1413 let current_public_accounts = vec![&local_header];
1414 let commitment_updates = vec![(account.id(), account.to_commitment())];
1415 let mut account_updates = AccountUpdates::default();
1416
1417 let superseded = state_sync
1418 .sync_public_accounts(
1419 &mut account_updates,
1420 &commitment_updates,
1421 ¤t_public_accounts,
1422 BlockNumber::GENESIS,
1423 &sync_target_header,
1424 )
1425 .await
1426 .unwrap();
1427
1428 assert!(superseded.is_empty(), "the transaction must not be superseded");
1429 assert!(
1430 account_updates.updated_public_accounts().is_empty(),
1431 "the target state must not overwrite the local account"
1432 );
1433 }
1434
1435 async fn get_account_proof(
1437 rpc_api: &MockRpcApi,
1438 account_id: AccountId,
1439 ) -> (BlockNumber, AccountProof) {
1440 rpc_api
1441 .get_account(
1442 account_id,
1443 GetAccountRequest::new()
1444 .with_storage(StorageMapFetch::All)
1445 .with_vault(VaultFetch::Always),
1446 )
1447 .await
1448 .unwrap()
1449 }
1450
1451 #[tokio::test]
1453 async fn validate_account_proof_rejects_mismatched_account() {
1454 let mut builder = MockChainBuilder::new();
1455 let account_a = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1456 let account_b = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1457 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1458 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1459
1460 let (proof_block_num, proof) = get_account_proof(&rpc_api, account_b.id()).await;
1462 let result = StateSync::validate_account_proof(
1463 proof,
1464 proof_block_num,
1465 account_a.id(),
1466 &chain_tip_header,
1467 );
1468
1469 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1470 }
1471
1472 #[tokio::test]
1474 async fn validate_account_proof_rejects_wrong_account_root() {
1475 let mut builder = MockChainBuilder::new();
1476 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1477 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1478 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1479 let wrong_header = header_with_account_root(&chain_tip_header, word(999));
1480
1481 let (proof_block_num, proof) = get_account_proof(&rpc_api, account.id()).await;
1483 let result =
1484 StateSync::validate_account_proof(proof, proof_block_num, account.id(), &wrong_header);
1485
1486 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1487 }
1488
1489 #[tokio::test]
1491 async fn validate_account_proof_rejects_wrong_block() {
1492 let mut builder = MockChainBuilder::new();
1493 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1494 let rpc_api = MockRpcApi::new(builder.build().unwrap());
1495 let chain_tip_header = rpc_api.mock_chain.read().latest_block_header();
1496
1497 let (proof_block_num, proof) = get_account_proof(&rpc_api, account.id()).await;
1499 let result = StateSync::validate_account_proof(
1500 proof,
1501 proof_block_num + 1,
1502 account.id(),
1503 &chain_tip_header,
1504 );
1505
1506 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
1507 }
1508
1509 mod compute_nullifiers_tests {
1513 use alloc::vec;
1514
1515 use miden_protocol::asset::FungibleAsset;
1516 use miden_protocol::block::BlockNumber;
1517 use miden_protocol::note::Nullifier;
1518 use miden_protocol::transaction::{InputNoteCommitment, InputNotes, TransactionHeader};
1519
1520 use super::word;
1521 use crate::rpc::domain::transaction::{
1522 ACCOUNT_ID_NATIVE_ASSET_FAUCET,
1523 TransactionRecord as RpcTransactionRecord,
1524 };
1525
1526 fn make_rpc_tx(
1527 init_state: u64,
1528 final_state: u64,
1529 nullifier_vals: &[u64],
1530 block_number: u32,
1531 ) -> RpcTransactionRecord {
1532 let account_id = miden_protocol::account::AccountId::try_from(
1533 miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE,
1534 )
1535 .unwrap();
1536
1537 let input_notes = InputNotes::new_unchecked(
1538 nullifier_vals
1539 .iter()
1540 .map(|v| InputNoteCommitment::from(Nullifier::from_raw(word(*v))))
1541 .collect(),
1542 );
1543
1544 let fee =
1545 FungibleAsset::new(ACCOUNT_ID_NATIVE_ASSET_FAUCET.try_into().expect("valid"), 0u64)
1546 .unwrap();
1547
1548 RpcTransactionRecord {
1549 block_num: BlockNumber::from(block_number),
1550 transaction_header: TransactionHeader::new(
1551 account_id,
1552 word(init_state),
1553 word(final_state),
1554 input_notes,
1555 vec![],
1556 fee,
1557 ),
1558 output_notes: vec![],
1559 erased_output_notes: vec![],
1560 }
1561 }
1562
1563 #[test]
1564 fn chains_rpc_transactions_by_state_commitment() {
1565 let tx_a = make_rpc_tx(1, 2, &[10], 5);
1568 let tx_b = make_rpc_tx(2, 3, &[20], 5);
1569 let tx_c = make_rpc_tx(3, 4, &[30], 5);
1570
1571 let result = super::super::compute_ordered_nullifiers(&[tx_c, tx_a, tx_b]);
1572
1573 assert_eq!(result[0], Nullifier::from_raw(word(10)));
1574 assert_eq!(result[1], Nullifier::from_raw(word(20)));
1575 assert_eq!(result[2], Nullifier::from_raw(word(30)));
1576 }
1577
1578 #[test]
1579 fn groups_independently_by_account_and_block() {
1580 let tx_a1 = make_rpc_tx(1, 2, &[10], 5);
1582 let tx_a2 = make_rpc_tx(2, 3, &[20], 5);
1583
1584 let tx_a3 = make_rpc_tx(3, 4, &[30], 6);
1586
1587 let account_b = miden_protocol::account::AccountId::try_from(
1589 miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
1590 )
1591 .unwrap();
1592
1593 let fee =
1594 FungibleAsset::new(ACCOUNT_ID_NATIVE_ASSET_FAUCET.try_into().expect("valid"), 0u64)
1595 .unwrap();
1596
1597 let tx_b1 = RpcTransactionRecord {
1598 block_num: BlockNumber::from(5u32),
1599 transaction_header: TransactionHeader::new(
1600 account_b,
1601 word(100),
1602 word(200),
1603 InputNotes::new_unchecked(vec![InputNoteCommitment::from(
1604 Nullifier::from_raw(word(40)),
1605 )]),
1606 vec![],
1607 fee,
1608 ),
1609 output_notes: vec![],
1610 erased_output_notes: vec![],
1611 };
1612
1613 let result = super::super::compute_ordered_nullifiers(&[tx_a2, tx_b1, tx_a3, tx_a1]);
1614
1615 let pos = |val: u64| -> usize {
1618 result.iter().position(|n| *n == Nullifier::from_raw(word(val))).unwrap()
1619 };
1620
1621 assert!(pos(10) < pos(20)); assert!(result.contains(&Nullifier::from_raw(word(30)))); assert!(result.contains(&Nullifier::from_raw(word(40)))); }
1627
1628 #[test]
1629 fn multiple_nullifiers_per_transaction_are_consecutive() {
1630 let tx = make_rpc_tx(1, 2, &[10, 20, 30], 5);
1632
1633 let result = super::super::compute_ordered_nullifiers(&[tx]);
1634
1635 assert_eq!(result.len(), 3);
1636 assert!(result.contains(&Nullifier::from_raw(word(10))));
1637 assert!(result.contains(&Nullifier::from_raw(word(20))));
1638 assert!(result.contains(&Nullifier::from_raw(word(30))));
1639 }
1640
1641 #[test]
1642 fn empty_input_returns_empty_vec() {
1643 let result = super::super::compute_ordered_nullifiers(&[]);
1644 assert!(result.is_empty());
1645 }
1646 }
1647
1648 #[test]
1659 fn derive_account_commitments_walks_chains_per_account() {
1660 let fee =
1661 FungibleAsset::new(ACCOUNT_ID_NATIVE_ASSET_FAUCET.try_into().expect("valid"), 0u64)
1662 .unwrap();
1663 let make_tx = |account: AccountId, init_state: u64, final_state: u64, block_num: u32| {
1664 RpcTransactionRecord {
1665 block_num: BlockNumber::from(block_num),
1666 transaction_header: TransactionHeader::new(
1667 account,
1668 word(init_state),
1669 word(final_state),
1670 InputNotes::new_unchecked(vec![]),
1671 vec![],
1672 fee,
1673 ),
1674 output_notes: vec![],
1675 erased_output_notes: vec![],
1676 }
1677 };
1678
1679 let account_a: AccountId =
1680 ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE.try_into().unwrap();
1681 let account_b: AccountId = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap();
1682
1683 let tx_a_b5_1 = make_tx(account_a, 1, 2, 5);
1684 let tx_a_b5_2 = make_tx(account_a, 2, 3, 5);
1685 let tx_a_b6_1 = make_tx(account_a, 3, 4, 6);
1686 let tx_a_b6_2 = make_tx(account_a, 4, 5, 6);
1687 let tx_b_b6 = make_tx(account_b, 10, 20, 6);
1688
1689 let result = super::derive_account_commitments(&[
1691 tx_a_b6_1, tx_b_b6, tx_a_b5_2, tx_a_b6_2, tx_a_b5_1,
1692 ]);
1693
1694 assert_eq!(result.len(), 2, "one entry per account");
1695 assert!(
1696 result.contains(&(account_a, word(5))),
1697 "account A: must walk block 6's chain, not return block 5 or an intermediate",
1698 );
1699 assert!(
1700 result.contains(&(account_b, word(20))),
1701 "account B: must be resolved independently of account A",
1702 );
1703 }
1704
1705 struct CommitAllScreener;
1711
1712 #[async_trait(?Send)]
1713 impl OnNoteReceived for CommitAllScreener {
1714 async fn on_note_received(
1715 &self,
1716 committed_note: CommittedNote,
1717 _public_note: Option<InputNoteRecord>,
1718 ) -> Result<NoteUpdateAction, ClientError> {
1719 Ok(NoteUpdateAction::Commit(committed_note))
1720 }
1721 }
1722
1723 async fn build_chain_with_chained_consume_txs() -> (miden_testing::MockChain, Account, [Note; 3])
1727 {
1728 let sender_id: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
1729 let faucet_id: AccountId = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into().unwrap();
1730
1731 let mut builder = MockChainBuilder::new();
1732 let account = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1733 let account_id = account.id();
1734
1735 let asset = Asset::Fungible(FungibleAsset::new(faucet_id, 100u64).unwrap());
1736 let note1 = builder
1737 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
1738 .unwrap();
1739 let note2 = builder
1740 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
1741 .unwrap();
1742 let note3 = builder
1743 .add_p2id_note(sender_id, account_id, &[asset], NoteType::Public)
1744 .unwrap();
1745
1746 let mut chain = builder.build().unwrap();
1747 chain.prove_next_block().unwrap(); let mut current_account = account.clone();
1751 for note in [¬e1, ¬e2, ¬e3] {
1752 let tx = Box::pin(
1753 chain
1754 .build_tx_context(
1755 TxContextInput::Account(current_account.clone()),
1756 &[],
1757 core::slice::from_ref(note),
1758 )
1759 .unwrap()
1760 .build()
1761 .unwrap()
1762 .execute(),
1763 )
1764 .await
1765 .unwrap();
1766 current_account.apply_delta(tx.account_delta()).unwrap();
1767 chain.add_pending_executed_transaction(&tx).unwrap();
1768 }
1769
1770 chain.prove_next_block().unwrap(); (chain, account, [note1, note2, note3])
1772 }
1773
1774 #[tokio::test]
1777 async fn sync_state_sets_consumed_tx_order_for_chained_transactions() {
1778 use miden_protocol::note::NoteMetadata;
1779
1780 let (chain, account, [note1, note2, note3]) = build_chain_with_chained_consume_txs().await;
1781
1782 let mock_rpc = MockRpcApi::new(chain);
1783 let state_sync =
1784 StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(CommitAllScreener), None);
1785
1786 let genesis_peaks =
1787 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
1788 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
1789
1790 let input_notes: Vec<InputNoteRecord> = [¬e1, ¬e2, ¬e3]
1791 .into_iter()
1792 .map(|n| InputNoteRecord::from(n.clone()))
1793 .collect();
1794
1795 let note_tags: BTreeSet<NoteTag> =
1796 input_notes.iter().filter_map(|n| n.metadata().map(NoteMetadata::tag)).collect();
1797
1798 let account_id = account.id();
1799 let sync_input = StateSyncInput {
1800 accounts: vec![AccountHeader::from(account)],
1801 note_tags,
1802 input_notes,
1803 output_notes: vec![],
1804 uncommitted_transactions: vec![],
1805 };
1806
1807 let update = state_sync.sync_state(&mut partial_mmr, sync_input).await.unwrap();
1808
1809 let updated_notes: Vec<_> = update.note_updates.updated_input_notes().collect();
1810
1811 let find_order = |details_commitment| -> Option<u32> {
1812 updated_notes
1813 .iter()
1814 .find(|n| n.inner().details_commitment() == details_commitment)
1815 .and_then(|n| n.consumed_tx_order())
1816 };
1817
1818 assert_eq!(find_order(note1.details_commitment()), Some(0), "note1 should have tx_order 0");
1819 assert_eq!(find_order(note2.details_commitment()), Some(1), "note2 should have tx_order 1");
1820 assert_eq!(find_order(note3.details_commitment()), Some(2), "note3 should have tx_order 2");
1821
1822 for note in &updated_notes {
1825 let record = note.inner();
1826 assert!(record.is_consumed(), "note should be in a consumed state");
1827 assert_eq!(
1828 record.consumer_account(),
1829 Some(account_id),
1830 "externally-consumed notes by a tracked account should have consumer_account set",
1831 );
1832 }
1833 }
1834
1835 #[tokio::test]
1836 async fn sync_state_across_multiple_iterations_with_same_mmr() {
1837 let mock_rpc = MockRpcApi::default();
1839 mock_rpc.advance_blocks(3);
1840 let chain_tip_1 = mock_rpc.get_chain_tip_block_num();
1841
1842 let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
1843
1844 let genesis_peaks =
1846 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
1847 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
1848 assert_eq!(partial_mmr.forest().num_leaves(), 1);
1849
1850 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
1852
1853 assert_eq!(update.block_num, chain_tip_1);
1854 let forest_1 = partial_mmr.forest();
1855 assert_eq!(forest_1.num_leaves(), chain_tip_1.as_u32() as usize + 1);
1857
1858 mock_rpc.advance_blocks(2);
1860 let chain_tip_2 = mock_rpc.get_chain_tip_block_num();
1861
1862 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
1863
1864 assert_eq!(update.block_num, chain_tip_2);
1865 let forest_2 = partial_mmr.forest();
1866 assert!(forest_2 > forest_1);
1867 assert_eq!(forest_2.num_leaves(), chain_tip_2.as_u32() as usize + 1);
1868
1869 let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap();
1871
1872 assert_eq!(update.block_num, chain_tip_2);
1873 assert_eq!(partial_mmr.forest(), forest_2);
1874 }
1875
1876 async fn build_chain_with_mint_notes(
1879 num_blocks: u64,
1880 ) -> (miden_testing::MockChain, BTreeSet<NoteTag>) {
1881 let mut builder = MockChainBuilder::new();
1882 let faucet = builder
1883 .add_existing_basic_faucet(
1884 miden_testing::Auth::BasicAuth {
1885 auth_scheme: miden_protocol::account::auth::AuthScheme::Falcon512Poseidon2,
1886 },
1887 "TST",
1888 10_000,
1889 None,
1890 )
1891 .unwrap();
1892 let _target = builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
1893 let mut chain = builder.build().unwrap();
1894
1895 let note_script = CodeBuilder::new()
1900 .compile_note_script("@note_script\npub proc main\n nop\nend")
1901 .unwrap();
1902 let note_recipient = NoteRecipient::new(
1903 Word::from([1u32, 2, 3, 4]),
1904 note_script,
1905 NoteStorage::new(vec![]).unwrap(),
1906 );
1907 let recipient = note_recipient.digest();
1908 let note_details = NoteDetails::new(NoteAssets::new(vec![]).unwrap(), note_recipient);
1911 let mut recipient_args = TransactionArgs::new(AdviceMap::default());
1912 recipient_args.add_output_note_recipient(¬e_details);
1913 let recipient_advice = recipient_args.advice_inputs().clone();
1914
1915 let tag = NoteTag::default();
1916 let mut faucet_account = faucet.clone();
1917 let mut note_tags = BTreeSet::new();
1918
1919 for i in 0..num_blocks {
1920 let amount = 100 + i;
1921 let source_manager = Arc::new(DefaultSourceManager::default());
1922 let tx_script_code = format!(
1928 "
1929 begin
1930 push.{recipient}
1931 push.{note_type}
1932 push.{tag}
1933 push.{amount}
1934 push.{faucet_id_prefix}
1935 push.{faucet_id_suffix}
1936 push.1
1937 exec.::miden::protocol::asset::create_fungible_asset
1938 call.::miden::standards::faucets::fungible::mint_and_send
1939 dropw dropw dropw dropw
1940 end
1941 ",
1942 recipient = recipient,
1943 note_type = NoteType::Private as u8,
1944 tag = u32::from(tag),
1945 amount = amount,
1946 faucet_id_prefix = faucet_account.id().prefix().as_felt(),
1947 faucet_id_suffix = faucet_account.id().suffix(),
1948 );
1949 let tx_script = CodeBuilder::with_source_manager(source_manager.clone())
1950 .compile_tx_script(tx_script_code)
1951 .unwrap();
1952 let tx = Box::pin(
1953 chain
1954 .build_tx_context(
1955 miden_testing::TxContextInput::Account(faucet_account.clone()),
1956 &[],
1957 &[],
1958 )
1959 .unwrap()
1960 .extend_advice_inputs(recipient_advice.clone())
1961 .tx_script(tx_script)
1962 .with_source_manager(source_manager)
1963 .build()
1964 .unwrap()
1965 .execute(),
1966 )
1967 .await
1968 .unwrap();
1969
1970 for output_note in tx.output_notes().iter() {
1971 note_tags.insert(output_note.metadata().tag());
1972 }
1973
1974 faucet_account.apply_delta(tx.account_delta()).unwrap();
1975 chain.add_pending_executed_transaction(&tx).unwrap();
1976 chain.prove_next_block().unwrap();
1977 }
1978
1979 (chain, note_tags)
1980 }
1981
1982 #[tokio::test]
1992 async fn sync_state_tracks_note_blocks_in_mmr() {
1993 let (chain, note_tags) = build_chain_with_mint_notes(3).await;
1994 let mock_rpc = MockRpcApi::new(chain);
1995 let chain_tip = mock_rpc.get_chain_tip_block_num();
1996
1997 let note_blocks = mock_rpc
1999 .sync_notes(BlockNumber::from(0u32), chain_tip, ¬e_tags)
2000 .await
2001 .unwrap();
2002 assert!(
2003 note_blocks.len() >= 2,
2004 "expected notes in multiple blocks, got {}",
2005 note_blocks.len()
2006 );
2007
2008 let note_block_nums: BTreeSet<BlockNumber> =
2010 note_blocks.iter().map(|b| b.block_header.block_num()).collect();
2011
2012 let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
2015
2016 let genesis_peaks =
2017 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2018 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2019
2020 let sync_data = state_sync
2021 .fetch_sync_data(BlockNumber::GENESIS, &[], &Arc::new(note_tags.clone()))
2022 .await
2023 .unwrap()
2024 .expect("should have progressed past genesis");
2025
2026 assert_eq!(sync_data.chain_tip_header.block_num(), chain_tip);
2028 assert!(!sync_data.note_blocks.is_empty(), "should have note blocks");
2029
2030 let _auth_nodes: Vec<(InOrderIndex, Word)> =
2032 partial_mmr.apply(sync_data.mmr_delta).map_err(StoreError::MmrError).unwrap();
2033 partial_mmr
2034 .add(sync_data.chain_tip_header.commitment(), false)
2035 .expect("chain tip should append to the partial MMR");
2036
2037 assert_eq!(partial_mmr.forest().num_leaves(), chain_tip.as_u32() as usize + 1);
2038
2039 for block in &sync_data.note_blocks {
2041 let bn = block.block_header.block_num();
2042 partial_mmr
2043 .track(bn.as_usize(), block.block_header.commitment(), &block.mmr_path)
2044 .map_err(StoreError::MmrError)
2045 .unwrap();
2046
2047 assert!(
2048 partial_mmr.is_tracked(bn.as_usize()),
2049 "block {bn} should be tracked after calling track()"
2050 );
2051 }
2052
2053 for &bn in ¬e_block_nums {
2055 assert!(
2056 partial_mmr.is_tracked(bn.as_usize()),
2057 "block {bn} with notes should be tracked in partial MMR"
2058 );
2059 }
2060 }
2061
2062 #[tokio::test]
2063 async fn sync_notes_with_details_fetches_inclusive_upper_bound_page() {
2064 let (chain, note_tags) = build_chain_with_mint_notes(10).await;
2065 let mock_rpc = MockRpcApi::new(chain);
2066
2067 let (blocks, _synced_notes) = mock_rpc
2068 .sync_notes_with_details(4_u32.into(), 10_u32.into(), ¬e_tags)
2069 .await
2070 .expect("sync notes should succeed");
2071
2072 assert_eq!(blocks.last().unwrap().block_header.block_num(), BlockNumber::from(10u32));
2073 assert!(
2074 blocks
2075 .iter()
2076 .any(|block| block.block_header.block_num() == BlockNumber::from(9u32))
2077 );
2078 }
2079
2080 #[tokio::test]
2086 async fn erased_notes_are_marked_as_consumed() {
2087 let sender_id: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
2089 let partial_metadata = PartialNoteMetadata::new(sender_id, NoteType::Public);
2090 let metadata = NoteMetadata::new(partial_metadata, &NoteAttachments::empty());
2091 let script = CodeBuilder::new()
2092 .compile_note_script("@note_script\npub proc main\n nop\nend")
2093 .unwrap();
2094 let recipient = NoteRecipient::new(
2095 Word::from([1u32, 2, 3, 4]),
2096 script,
2097 NoteStorage::new(vec![]).unwrap(),
2098 );
2099 let output_note = OutputNoteRecord::new(
2100 recipient.digest(),
2101 NoteAssets::new(vec![]).unwrap(),
2102 metadata,
2103 OutputNoteState::ExpectedFull { recipient },
2104 BlockNumber::from(1u32),
2105 NoteAttachments::default(),
2106 );
2107 let note_id = output_note.id();
2108 let note_header = NoteHeader::new(output_note.details_commitment(), metadata);
2109
2110 let mut note_updates = NoteUpdateTracker::new(vec![], vec![output_note]);
2112
2113 let block_num = BlockNumber::from(3u32);
2115 note_updates
2116 .mark_erased_note_as_consumed(¬e_header, block_num)
2117 .expect("marking erased note should succeed");
2118
2119 let updated = note_updates
2120 .updated_output_notes()
2121 .find(|n| n.id() == note_id)
2122 .expect("output note should be in the update");
2123
2124 assert!(
2125 updated.inner().is_consumed(),
2126 "output note should be consumed after erasure detection, but state is: {}",
2127 updated.inner().state()
2128 );
2129 }
2130
2131 #[allow(clippy::too_many_lines)]
2148 #[ignore = "consumer derivation removed; see comment above"]
2149 #[tokio::test]
2150 async fn erased_notes_are_marked_as_consumed_by_network_account() {
2151 let mut builder = MockChainBuilder::new();
2154 let p2id_sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
2155 let faucet_id: AccountId = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into().unwrap();
2156 let sender_account =
2157 builder.add_existing_mock_account(miden_testing::Auth::IncrNonce).unwrap();
2158 let sender_id = sender_account.id();
2159
2160 let asset = Asset::Fungible(FungibleAsset::new(faucet_id, 100u64).unwrap());
2161 let note = builder
2162 .add_p2id_note(p2id_sender, sender_id, &[asset], NoteType::Public)
2163 .unwrap();
2164
2165 let mut chain = builder.build().unwrap();
2166 chain.prove_next_block().unwrap();
2167
2168 let tx = Box::pin(
2169 chain
2170 .build_tx_context(
2171 TxContextInput::Account(sender_account.clone()),
2172 &[],
2173 core::slice::from_ref(¬e),
2174 )
2175 .unwrap()
2176 .build()
2177 .unwrap()
2178 .execute(),
2179 )
2180 .await
2181 .unwrap();
2182 chain.add_pending_executed_transaction(&tx).unwrap();
2183 chain.prove_next_block().unwrap();
2184
2185 let network_account_id: AccountId =
2187 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into().unwrap();
2188 let target =
2189 NetworkAccountTarget::new(network_account_id, NoteExecutionHint::Always).unwrap();
2190 let attachment: NoteAttachment = target.into();
2191 let attachments = NoteAttachments::new(vec![attachment]).unwrap();
2192 let partial_metadata = PartialNoteMetadata::new(sender_id, NoteType::Public);
2193 let metadata = NoteMetadata::new(partial_metadata, &attachments);
2194 let script = CodeBuilder::new()
2195 .compile_note_script("@note_script\npub proc main\n nop\nend")
2196 .unwrap();
2197 let recipient = NoteRecipient::new(
2198 Word::from([7u32, 8, 9, 10]),
2199 script,
2200 NoteStorage::new(vec![]).unwrap(),
2201 );
2202 let recipient_digest = recipient.digest();
2203 let assets = NoteAssets::new(vec![]).unwrap();
2204
2205 let output_note = OutputNoteRecord::new(
2208 recipient_digest,
2209 assets.clone(),
2210 metadata,
2211 OutputNoteState::ExpectedFull { recipient },
2212 BlockNumber::from(1u32),
2213 NoteAttachments::default(),
2214 );
2215 let erased_note_id = output_note.id();
2216 let erased_note_header = NoteHeader::new(output_note.details_commitment(), metadata);
2217
2218 let mock_rpc = MockRpcApi::new(chain);
2219 mock_rpc.mark_note_as_erased(erased_note_header);
2220
2221 let network_header =
2224 AccountHeader::new(network_account_id, ZERO, EMPTY_WORD, EMPTY_WORD, EMPTY_WORD);
2225
2226 let state_sync = StateSync::new(Arc::new(mock_rpc.clone()), Arc::new(MockScreener), None);
2227
2228 let genesis_peaks =
2229 mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2230 let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks);
2231
2232 let sync_input = StateSyncInput {
2233 accounts: vec![AccountHeader::from(sender_account), network_header],
2234 note_tags: BTreeSet::new(),
2235 input_notes: vec![],
2236 output_notes: vec![output_note],
2237 uncommitted_transactions: vec![],
2238 };
2239
2240 let update = state_sync.sync_state(&mut partial_mmr, sync_input).await.unwrap();
2241
2242 let updated_output = update
2244 .note_updates
2245 .updated_output_notes()
2246 .find(|n| n.id() == erased_note_id)
2247 .expect("output note should be in the update");
2248 assert!(
2249 updated_output.inner().is_consumed(),
2250 "output note should be consumed, got: {}",
2251 updated_output.inner().state()
2252 );
2253
2254 let input_note_update = update
2256 .note_updates
2257 .updated_input_notes()
2258 .find(|n| n.id() == Some(erased_note_id))
2259 .expect("input note should be created from the erased output note");
2260
2261 let inner = input_note_update.inner();
2262 assert!(
2263 inner.is_consumed(),
2264 "input note should be in a consumed state, got: {}",
2265 inner.state()
2266 );
2267 assert_eq!(
2268 inner.consumer_account(),
2269 Some(network_account_id),
2270 "consumer should be the tracked network account"
2271 );
2272 }
2273
2274 #[tokio::test]
2277 async fn validate_chain_mmr_response_rejects_tampered_responses() {
2278 let mock_rpc = MockRpcApi::default();
2279 mock_rpc.advance_blocks(3);
2280 let chain_tip = mock_rpc.get_chain_tip_block_num();
2281 let current = BlockNumber::GENESIS;
2282
2283 let header_of =
2284 |block_num: u32| mock_rpc.mock_chain.read().block_header(block_num as usize);
2285 let chain_mmr_response = || async {
2286 mock_rpc.sync_chain_mmr(current, SyncTarget::CommittedChainTip).await.unwrap()
2287 };
2288
2289 let response = chain_mmr_response().await;
2291 StateSync::validate_chain_mmr_response(&response, current).unwrap();
2292
2293 let mut response = chain_mmr_response().await;
2295 response.block_header = header_of(chain_tip.as_u32() - 1);
2296 let result = StateSync::validate_chain_mmr_response(&response, current);
2297 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2298
2299 let mut response = chain_mmr_response().await;
2301 response.block_from = current + 1;
2302 let result = StateSync::validate_chain_mmr_response(&response, current);
2303 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2304
2305 let mut response = chain_mmr_response().await;
2307 response.block_from = chain_tip;
2308 response.block_to = BlockNumber::GENESIS;
2309 response.block_header = header_of(0);
2310 let result = StateSync::validate_chain_mmr_response(&response, chain_tip);
2311 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2312 }
2313
2314 #[test]
2317 fn validate_note_blocks_range_rejects_out_of_range_blocks() {
2318 let mock_rpc = MockRpcApi::default();
2319 mock_rpc.advance_blocks(3);
2320 let chain_tip = mock_rpc.get_chain_tip_block_num();
2321 let current = BlockNumber::GENESIS;
2322
2323 StateSync::validate_note_blocks_range(&[], current, chain_tip).unwrap();
2325
2326 let genesis_note_block = NoteSyncBlock {
2328 block_header: mock_rpc.mock_chain.read().block_header(0),
2329 mmr_path: MerklePath::new(Vec::new()),
2330 notes: BTreeMap::new(),
2331 };
2332 let result =
2333 StateSync::validate_note_blocks_range(&[genesis_note_block], current, chain_tip);
2334 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2335 }
2336
2337 #[test]
2340 fn advance_mmr_rejects_delta_inconsistent_with_chain_commitment() {
2341 let mock_rpc = MockRpcApi::default();
2342 mock_rpc.advance_blocks(3);
2343 let chain_tip = mock_rpc.get_chain_tip_block_num();
2344
2345 let chain_tip_header = mock_rpc.mock_chain.read().block_header(chain_tip.as_usize());
2346 let genesis_partial_mmr = || {
2347 let peaks = mock_rpc.get_mmr().peaks_at(Forest::new(1).expect("valid forest")).unwrap();
2348 PartialMmr::from_peaks(peaks)
2349 };
2350
2351 let full_delta = mock_rpc
2353 .get_mmr()
2354 .get_delta(Forest::new(1).unwrap(), Forest::new(chain_tip.as_usize()).unwrap())
2355 .unwrap();
2356 StateSync::advance_mmr(
2357 full_delta,
2358 &chain_tip_header,
2359 &mut genesis_partial_mmr(),
2360 &mut PartialBlockchainUpdates::default(),
2361 )
2362 .unwrap();
2363
2364 let truncated_delta = mock_rpc
2366 .get_mmr()
2367 .get_delta(Forest::new(1).unwrap(), Forest::new(chain_tip.as_usize() - 1).unwrap())
2368 .unwrap();
2369 let result = StateSync::advance_mmr(
2370 truncated_delta,
2371 &chain_tip_header,
2372 &mut genesis_partial_mmr(),
2373 &mut PartialBlockchainUpdates::default(),
2374 );
2375 assert!(matches!(result, Err(ClientError::ChainValidationError(_))));
2376 }
2377}