1use alloc::collections::{BTreeMap, BTreeSet};
2use alloc::vec::Vec;
3
4use miden_protocol::account::{
5 Account,
6 AccountCode,
7 AccountCodePatch,
8 AccountHeader,
9 AccountId,
10 AccountPatch,
11 AccountStoragePatch,
12 AccountVaultPatch,
13 StorageMapPatch,
14 StorageMapPatchEntries,
15 StorageSlotName,
16 StorageSlotPatch,
17 StorageValuePatch,
18};
19use miden_protocol::block::account_tree::AccountWitness;
20use miden_protocol::block::{BlockHeader, BlockNumber};
21use miden_protocol::crypto::merkle::mmr::{InOrderIndex, MmrPeaks};
22use miden_protocol::errors::AccountPatchError;
23use miden_protocol::note::{NoteId, Nullifier};
24use miden_protocol::protocol_config::ProtocolConfig;
25use miden_protocol::transaction::TransactionId;
26use miden_protocol::{Felt, ONE, Word};
27
28use super::SyncSummary;
29use crate::note::{NoteUpdateTracker, NoteUpdateType};
30use crate::rpc::domain::transaction::TransactionRecord as RpcTransactionRecord;
31use crate::transaction::{DiscardCause, TransactionRecord, TransactionStatus};
32
33pub struct StateSyncUpdate {
42 block_num: BlockNumber,
44 partial_blockchain_updates: PartialBlockchainUpdates,
46 note_updates: NoteUpdateTracker,
48 transaction_updates: TransactionUpdateTracker,
50 account_updates: AccountUpdates,
52 protocol_config: Option<ProtocolConfig>,
55}
56
57impl StateSyncUpdate {
58 pub fn from_parts(
62 block_num: BlockNumber,
63 partial_blockchain_updates: PartialBlockchainUpdates,
64 note_updates: NoteUpdateTracker,
65 transaction_updates: TransactionUpdateTracker,
66 account_updates: AccountUpdates,
67 protocol_config: Option<ProtocolConfig>,
68 ) -> Self {
69 Self {
70 block_num,
71 partial_blockchain_updates,
72 note_updates,
73 transaction_updates,
74 account_updates,
75 protocol_config,
76 }
77 }
78
79 pub fn block_num(&self) -> BlockNumber {
81 self.block_num
82 }
83
84 pub fn partial_blockchain_updates(&self) -> &PartialBlockchainUpdates {
86 &self.partial_blockchain_updates
87 }
88
89 pub fn note_updates(&self) -> &NoteUpdateTracker {
91 &self.note_updates
92 }
93
94 pub fn transaction_updates(&self) -> &TransactionUpdateTracker {
96 &self.transaction_updates
97 }
98
99 pub fn account_updates(&self) -> &AccountUpdates {
101 &self.account_updates
102 }
103
104 pub fn protocol_config(&self) -> Option<&ProtocolConfig> {
106 self.protocol_config.as_ref()
107 }
108
109 pub fn into_parts(
111 self,
112 ) -> (
113 BlockNumber,
114 PartialBlockchainUpdates,
115 NoteUpdateTracker,
116 TransactionUpdateTracker,
117 AccountUpdates,
118 Option<ProtocolConfig>,
119 ) {
120 (
121 self.block_num,
122 self.partial_blockchain_updates,
123 self.note_updates,
124 self.transaction_updates,
125 self.account_updates,
126 self.protocol_config,
127 )
128 }
129}
130
131impl From<&StateSyncUpdate> for SyncSummary {
132 fn from(value: &StateSyncUpdate) -> Self {
133 let new_public_note_ids = value
134 .note_updates
135 .updated_input_notes()
136 .filter_map(|note_update| {
137 let note = note_update.inner();
138 if let NoteUpdateType::Insert = note_update.update_type() {
139 note.id()
140 } else {
141 None
142 }
143 })
144 .collect();
145
146 let committed_note_ids: BTreeSet<NoteId> = value
147 .note_updates
148 .updated_input_notes()
149 .filter_map(|note_update| {
150 let note = note_update.inner();
151 if matches!(
155 note_update.update_type(),
156 NoteUpdateType::Update | NoteUpdateType::InsertCommitted
157 ) && note.is_committed()
158 {
159 note.id()
160 } else {
161 None
162 }
163 })
164 .chain(value.note_updates.updated_output_notes().filter_map(|note_update| {
165 let note = note_update.inner();
166 if let NoteUpdateType::Update = note_update.update_type() {
167 note.is_committed().then_some(note.id())
168 } else {
169 None
170 }
171 }))
172 .collect();
173
174 let consumed_note_ids: BTreeSet<NoteId> =
175 value.note_updates.consumed_input_note_ids().collect();
176
177 SyncSummary::new(
178 value.block_num,
179 new_public_note_ids,
180 Vec::new(),
182 committed_note_ids.into_iter().collect(),
183 consumed_note_ids.into_iter().collect(),
184 value
185 .account_updates
186 .updated_public_accounts()
187 .iter()
188 .map(PublicAccountUpdate::id)
189 .collect(),
190 value
191 .account_updates
192 .mismatched_private_accounts()
193 .iter()
194 .map(|(id, _)| *id)
195 .collect(),
196 value.transaction_updates.committed_transactions().map(|t| t.id).collect(),
197 )
198 }
199}
200
201#[derive(Debug, Clone, Default)]
206pub struct PartialBlockchainUpdates {
207 block_headers: BTreeMap<BlockNumber, (BlockHeader, bool)>,
210 new_authentication_nodes: Vec<(InOrderIndex, Word)>,
212 pub new_peaks: MmrPeaks,
214}
215
216impl PartialBlockchainUpdates {
217 pub fn insert(&mut self, block_header: BlockHeader, is_relevant: bool) {
223 self.block_headers
224 .entry(block_header.block_num())
225 .and_modify(|(_, existing_is_relevant)| {
226 *existing_is_relevant |= is_relevant;
227 })
228 .or_insert((block_header, is_relevant));
229 }
230
231 pub fn extend_authentication_nodes(
236 &mut self,
237 nodes: impl IntoIterator<Item = (InOrderIndex, Word)>,
238 ) {
239 self.new_authentication_nodes.extend(nodes);
240 }
241
242 pub fn block_headers(&self) -> impl Iterator<Item = &(BlockHeader, bool)> {
245 self.block_headers.values()
246 }
247
248 pub fn block_headers_to_store(
250 &self,
251 sync_height: BlockNumber,
252 ) -> impl Iterator<Item = &(BlockHeader, bool)> {
253 self.block_headers.values().filter(move |(header, is_relevant)| {
254 *is_relevant
255 || header.block_num() == BlockNumber::GENESIS
256 || header.block_num() == sync_height
257 })
258 }
259
260 pub fn new_authentication_nodes(&self) -> &[(InOrderIndex, Word)] {
263 &self.new_authentication_nodes
264 }
265}
266
267#[derive(Default)]
269pub struct TransactionUpdateTracker {
270 transactions: BTreeMap<TransactionId, TransactionRecord>,
272 external_nullifier_accounts: BTreeMap<Nullifier, AccountId>,
274}
275
276impl TransactionUpdateTracker {
277 pub fn new(transactions: Vec<TransactionRecord>) -> Self {
279 let transactions =
280 transactions.into_iter().map(|tx| (tx.id, tx)).collect::<BTreeMap<_, _>>();
281
282 Self {
283 transactions,
284 external_nullifier_accounts: BTreeMap::new(),
285 }
286 }
287
288 pub fn committed_transactions(&self) -> impl Iterator<Item = &TransactionRecord> {
290 self.transactions
291 .values()
292 .filter(|tx| matches!(tx.status, TransactionStatus::Committed { .. }))
293 }
294
295 pub fn discarded_transactions(&self) -> impl Iterator<Item = &TransactionRecord> {
297 self.transactions
298 .values()
299 .filter(|tx| matches!(tx.status, TransactionStatus::Discarded(_)))
300 }
301
302 fn mutable_pending_transactions(&mut self) -> impl Iterator<Item = &mut TransactionRecord> {
304 self.transactions
305 .values_mut()
306 .filter(|tx| matches!(tx.status, TransactionStatus::Pending))
307 }
308
309 pub fn updated_transaction_ids(&self) -> impl Iterator<Item = TransactionId> {
311 self.committed_transactions()
312 .chain(self.discarded_transactions())
313 .map(|tx| tx.id)
314 }
315
316 pub fn external_nullifier_account(&self, nullifier: &Nullifier) -> Option<AccountId> {
319 self.external_nullifier_accounts.get(nullifier).copied()
320 }
321
322 pub fn apply_transaction_inclusion(&mut self, record: &RpcTransactionRecord, timestamp: u64) {
329 let header = &record.transaction_header;
330 let account_id = header.account_id();
331
332 if let Some(transaction) = self.transactions.get_mut(&header.id()) {
333 transaction.commit_transaction(record.block_num, timestamp);
334 return;
335 }
336
337 for commitment in header.input_notes().iter() {
341 self.external_nullifier_accounts.insert(commitment.nullifier(), account_id);
342 }
343 }
344
345 pub fn apply_sync_height_update(
348 &mut self,
349 new_sync_height: BlockNumber,
350 tx_discard_delta: Option<u32>,
351 ) {
352 if let Some(tx_discard_delta) = tx_discard_delta {
353 self.discard_transaction_with_predicate(
354 |transaction| {
355 transaction.details.submission_height
356 < new_sync_height.checked_sub(tx_discard_delta).unwrap_or_default()
357 },
358 DiscardCause::Stale,
359 );
360 }
361
362 self.discard_transaction_with_predicate(
365 |transaction| transaction.details.expiration_block_num <= new_sync_height,
366 DiscardCause::Expired,
367 );
368 }
369
370 pub fn apply_input_note_nullified(&mut self, input_note_nullifier: Nullifier) {
374 self.discard_transaction_with_predicate(
375 |transaction| {
376 transaction
379 .details
380 .input_note_nullifiers
381 .contains(&input_note_nullifier.as_word())
382 },
383 DiscardCause::InputConsumed,
384 );
385 }
386
387 pub fn apply_superseded_account_state(&mut self, superseded_account_state: Word) {
389 self.discard_transaction_with_predicate(
390 |transaction| transaction.details.final_account_state == superseded_account_state,
391 DiscardCause::Superseded,
392 );
393 }
394
395 pub fn apply_invalid_initial_account_state(&mut self, invalid_account_state: Word) {
397 self.discard_transaction_with_predicate(
398 |transaction| transaction.details.init_account_state == invalid_account_state,
399 DiscardCause::DiscardedInitialState,
400 );
401 }
402
403 fn discard_transaction_with_predicate<F>(&mut self, predicate: F, discard_cause: DiscardCause)
406 where
407 F: Fn(&TransactionRecord) -> bool,
408 {
409 let mut new_invalid_account_states = vec![];
410
411 for transaction in self.mutable_pending_transactions() {
412 if predicate(transaction) && transaction.discard_transaction(discard_cause) {
418 new_invalid_account_states.push(transaction.details.final_account_state);
419 }
420 }
421
422 for state in new_invalid_account_states {
423 self.apply_invalid_initial_account_state(state);
424 }
425 }
426}
427
428#[derive(Debug, Clone)]
444pub enum PublicAccountUpdate {
445 Full(Account),
447 Patch {
450 new_header: AccountHeader,
452 patch: AccountPatch,
454 },
455}
456
457impl PublicAccountUpdate {
458 pub fn id(&self) -> AccountId {
460 match self {
461 Self::Full(account) => account.id(),
462 Self::Patch { new_header, .. } => new_header.id(),
463 }
464 }
465
466 pub fn nonce(&self) -> Felt {
468 match self {
469 Self::Full(account) => account.nonce(),
470 Self::Patch { new_header, .. } => new_header.nonce(),
471 }
472 }
473}
474
475pub(crate) fn build_account_patch(
489 new_header: &AccountHeader,
490 value_slot_updates: Vec<(StorageSlotName, Word)>,
491 map_entries: BTreeMap<StorageSlotName, StorageMapPatchEntries>,
492 vault_patch: AccountVaultPatch,
493 code: AccountCode,
494 local_code_commitment: Word,
495) -> Result<AccountPatch, AccountPatchError> {
496 let is_new_account = new_header.nonce() == ONE;
497
498 let value_entries = value_slot_updates.into_iter().map(|(slot_name, new_value)| {
499 let value_patch = if is_new_account {
500 StorageValuePatch::Create { value: new_value }
501 } else {
502 StorageValuePatch::Update { value: new_value }
503 };
504 (slot_name, StorageSlotPatch::Value(value_patch))
505 });
506
507 let map_entries = map_entries.into_iter().map(|(slot_name, entries)| {
508 let map_patch = if is_new_account {
509 StorageMapPatch::Create { entries }
510 } else {
511 StorageMapPatch::Update { entries }
512 };
513 (slot_name, StorageSlotPatch::Map(map_patch))
514 });
515
516 let storage = AccountStoragePatch::from_entries(value_entries.chain(map_entries))?;
517
518 let carries_code = is_new_account || code.commitment() != local_code_commitment;
519 let code = AccountCodePatch::new(carries_code.then_some(code));
520
521 AccountPatch::new(new_header.id(), storage, vault_patch, code, Some(new_header.nonce()))
522}
523
524#[derive(Debug, Clone, Default)]
529#[allow(clippy::struct_field_names)]
530pub struct AccountUpdates {
531 updated_public_accounts: Vec<PublicAccountUpdate>,
533 mismatched_private_accounts: Vec<(AccountId, Word)>,
540 account_witnesses: Vec<(AccountId, AccountWitness)>,
543}
544
545impl AccountUpdates {
546 pub fn new(
548 updated_public_accounts: Vec<PublicAccountUpdate>,
549 mismatched_private_accounts: Vec<(AccountId, Word)>,
550 ) -> Self {
551 Self {
552 updated_public_accounts,
553 mismatched_private_accounts,
554 account_witnesses: Vec::new(),
555 }
556 }
557
558 #[must_use]
560 pub fn with_account_witnesses(
561 mut self,
562 account_witnesses: Vec<(AccountId, AccountWitness)>,
563 ) -> Self {
564 self.account_witnesses = account_witnesses;
565 self
566 }
567
568 pub fn updated_public_accounts(&self) -> &[PublicAccountUpdate] {
570 &self.updated_public_accounts
571 }
572
573 pub fn mismatched_private_accounts(&self) -> &[(AccountId, Word)] {
575 &self.mismatched_private_accounts
576 }
577
578 pub fn account_witnesses(&self) -> &[(AccountId, AccountWitness)] {
580 &self.account_witnesses
581 }
582
583 pub fn extend(&mut self, other: AccountUpdates) {
584 self.updated_public_accounts.extend(other.updated_public_accounts);
585 self.mismatched_private_accounts.extend(other.mismatched_private_accounts);
586 self.account_witnesses.extend(other.account_witnesses);
587 }
588}
589
590#[cfg(test)]
594mod tests {
595 use alloc::collections::BTreeMap;
596 use alloc::vec;
597
598 use miden_protocol::account::{AccountCode, StorageMapKey, StorageMapPatchEntries};
599 use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE;
600 use miden_protocol::transaction::{
601 InputNoteCommitment,
602 InputNotes,
603 RawOutputNotes,
604 TransactionHeader,
605 };
606
607 use super::*;
608 use crate::transaction::TransactionDetails;
609
610 fn account_id() -> AccountId {
611 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into().unwrap()
612 }
613
614 fn slot_name(name: &str) -> StorageSlotName {
615 StorageSlotName::new(name).unwrap()
616 }
617
618 fn word(n: u64) -> Word {
619 Word::from([
620 Felt::new_unchecked(n),
621 Felt::new_unchecked(0),
622 Felt::new_unchecked(0),
623 Felt::new_unchecked(0),
624 ])
625 }
626
627 fn header_with_nonce(nonce: u64) -> AccountHeader {
628 AccountHeader::new(
629 account_id(),
630 Felt::new(nonce).expect("test nonce must be a valid Felt"),
631 Word::default(),
632 Word::default(),
633 Word::default(),
634 )
635 }
636
637 fn build_patch(
638 new_nonce: u64,
639 value_slot_updates: Vec<(StorageSlotName, Word)>,
640 map_entries: BTreeMap<StorageSlotName, StorageMapPatchEntries>,
641 ) -> Result<AccountPatch, AccountPatchError> {
642 build_account_patch(
643 &header_with_nonce(new_nonce),
644 value_slot_updates,
645 map_entries,
646 AccountVaultPatch::default(),
647 AccountCode::mock(),
648 AccountCode::mock().commitment(),
649 )
650 }
651
652 #[test]
653 fn build_patch_empty_payload_carries_only_nonce() {
654 let patch = build_patch(4, vec![], BTreeMap::new()).unwrap();
655
656 assert_eq!(patch.final_nonce(), Some(Felt::new_unchecked(4)));
657 assert!(patch.storage().is_empty());
658 assert!(patch.vault().is_empty());
659 assert!(patch.code().is_empty());
660 }
661
662 #[test]
663 fn build_patch_sets_value_slot_absolutely() {
664 let value_slot = slot_name("miden::test::value");
665 let patch = build_patch(2, vec![(value_slot.clone(), word(2))], BTreeMap::new()).unwrap();
666
667 assert_eq!(patch.storage().updated_value(&value_slot), Some(word(2)));
668 }
669
670 #[test]
671 fn build_patch_wraps_merged_map_entries() {
672 let map_slot = slot_name("miden::test::map");
673 let key = StorageMapKey::from_raw(word(42));
674 let mut entries = StorageMapPatchEntries::new();
675 entries.insert(key, word(300));
676 let map_entries = BTreeMap::from([(map_slot.clone(), entries)]);
677
678 let patch = build_patch(2, vec![], map_entries).unwrap();
679
680 let entries =
681 patch.storage().updated_map(&map_slot).expect("patch should contain map slot");
682 assert_eq!(entries.as_map().len(), 1);
683 assert_eq!(*entries.as_map().values().next().unwrap(), word(300));
684 }
685
686 #[test]
687 fn build_patch_rejects_zero_nonce() {
688 let result = build_patch(0, vec![], BTreeMap::new());
689 assert!(result.is_err());
690 }
691
692 #[test]
695 fn build_patch_for_new_account_carries_code() {
696 let value_slot = slot_name("miden::test::value");
697 let patch = build_patch(1, vec![(value_slot, word(1))], BTreeMap::new()).unwrap();
698
699 assert_eq!(patch.code().as_code(), Some(&AccountCode::mock()));
700 assert_eq!(patch.final_nonce(), Some(ONE));
701 assert!(patch.try_to_new_account().is_ok());
702 }
703
704 #[test]
707 fn build_patch_for_code_upgrade_carries_new_code() {
708 let value_slot = slot_name("miden::test::value");
709 let local_code_commitment = word(7);
710 assert_ne!(local_code_commitment, AccountCode::mock().commitment());
711
712 let patch = build_account_patch(
713 &header_with_nonce(3),
714 vec![(value_slot.clone(), word(3))],
715 BTreeMap::new(),
716 AccountVaultPatch::default(),
717 AccountCode::mock(),
718 local_code_commitment,
719 )
720 .unwrap();
721
722 assert_eq!(patch.code().as_code(), Some(&AccountCode::mock()));
723 assert_eq!(patch.storage().updated_value(&value_slot), Some(word(3)));
724 assert!(patch.try_to_new_account().is_err());
725 }
726
727 #[test]
729 fn build_patch_without_code_change_omits_code() {
730 let value_slot = slot_name("miden::test::value");
731 let patch = build_patch(3, vec![(value_slot, word(3))], BTreeMap::new()).unwrap();
732
733 assert!(patch.code().is_empty());
734 }
735
736 #[test]
739 fn build_patch_emits_map_create_for_new_account() {
740 let map_slot = slot_name("miden::test::map");
741 let mut entries = StorageMapPatchEntries::new();
742 entries.insert(StorageMapKey::from_raw(word(1)), word(100));
743 let map_entries = BTreeMap::from([(map_slot.clone(), entries)]);
744
745 let patch = build_patch(1, vec![], map_entries).unwrap();
746
747 assert!(patch.storage().created_map(&map_slot).is_some());
748 }
749
750 #[test]
753 fn build_patch_emits_map_update_for_existing_account() {
754 let map_slot = slot_name("miden::test::map");
755 let mut entries = StorageMapPatchEntries::new();
756 entries.insert(StorageMapKey::from_raw(word(1)), word(100));
757 let map_entries = BTreeMap::from([(map_slot.clone(), entries)]);
758
759 let patch = build_patch(2, vec![], map_entries).unwrap();
760
761 assert!(patch.storage().updated_map(&map_slot).is_some());
762 }
763
764 fn rpc_transaction(init_state: u64, final_state: u64, nullifier: u64) -> RpcTransactionRecord {
768 let input_notes = InputNotes::new_unchecked(vec![InputNoteCommitment::from(
769 Nullifier::from_raw(word(nullifier)),
770 )]);
771
772 RpcTransactionRecord {
773 block_num: BlockNumber::from(5u32),
774 transaction_header: TransactionHeader::new(
775 account_id(),
776 word(init_state),
777 word(final_state),
778 input_notes,
779 vec![],
780 )
781 .unwrap(),
782 output_notes: vec![],
783 erased_output_notes: vec![],
784 consumed_note_refs: vec![],
785 }
786 }
787
788 fn pending_transaction(init_state: u64, final_state: u64, nullifier: u64) -> TransactionRecord {
789 let id = rpc_transaction(init_state, final_state, nullifier).transaction_header.id();
790 let details = TransactionDetails {
791 account_id: account_id(),
792 init_account_state: word(init_state),
793 final_account_state: word(final_state),
794 input_note_nullifiers: vec![word(nullifier)],
795 output_notes: RawOutputNotes::new(vec![]).unwrap(),
796 block_num: BlockNumber::from(1u32),
797 submission_height: BlockNumber::from(1u32),
798 expiration_block_num: BlockNumber::from(100u32),
799 creation_timestamp: 0,
800 };
801
802 TransactionRecord::new(id, details, None, TransactionStatus::Pending)
803 }
804
805 #[test]
808 fn inclusion_with_unknown_id_does_not_commit_local_transaction() {
809 let local = pending_transaction(10, 11, 1);
810 let local_id = local.id;
811 let mut tracker = TransactionUpdateTracker::new(vec![local]);
812
813 let included = rpc_transaction(10, 11, 2);
814 assert_ne!(included.transaction_header.id(), local_id);
815 tracker.apply_transaction_inclusion(&included, 0);
816
817 assert!(tracker.committed_transactions().next().is_none());
818 assert_eq!(
819 tracker.external_nullifier_account(&Nullifier::from_raw(word(2))),
820 Some(account_id())
821 );
822 }
823}