Skip to main content

miden_client/sync/
state_sync_update.rs

1use alloc::collections::{BTreeMap, BTreeSet};
2use alloc::vec::Vec;
3
4use miden_protocol::account::{
5    Account,
6    AccountCode,
7    AccountHeader,
8    AccountId,
9    AccountPatch,
10    AccountStoragePatch,
11    AccountVaultPatch,
12    StorageMapPatch,
13    StorageMapPatchEntries,
14    StorageSlotName,
15    StorageSlotPatch,
16    StorageValuePatch,
17};
18use miden_protocol::block::{BlockHeader, BlockNumber};
19use miden_protocol::crypto::merkle::mmr::{InOrderIndex, MmrPeaks};
20use miden_protocol::errors::AccountPatchError;
21use miden_protocol::note::{NoteId, Nullifier};
22use miden_protocol::transaction::TransactionId;
23use miden_protocol::{Felt, ONE, Word};
24
25use super::SyncSummary;
26use crate::note::{NoteUpdateTracker, NoteUpdateType};
27use crate::rpc::domain::transaction::TransactionRecord as RpcTransactionRecord;
28use crate::transaction::{DiscardCause, TransactionRecord, TransactionStatus};
29
30// STATE SYNC UPDATE
31// ================================================================================================
32
33/// Contains all information needed to apply the update in the store after syncing with the node.
34#[derive(Default)]
35pub struct StateSyncUpdate {
36    /// The block number of the last block that was synced.
37    pub block_num: BlockNumber,
38    /// New blocks, authentication nodes and MMR peaks.
39    pub partial_blockchain_updates: PartialBlockchainUpdates,
40    /// New and updated notes to be upserted in the store.
41    pub note_updates: NoteUpdateTracker,
42    /// Committed and discarded transactions after the sync.
43    pub transaction_updates: TransactionUpdateTracker,
44    /// Public account updates and mismatched private accounts after the sync.
45    pub account_updates: AccountUpdates,
46}
47
48impl From<&StateSyncUpdate> for SyncSummary {
49    fn from(value: &StateSyncUpdate) -> Self {
50        let new_public_note_ids = value
51            .note_updates
52            .updated_input_notes()
53            .filter_map(|note_update| {
54                let note = note_update.inner();
55                if let NoteUpdateType::Insert = note_update.update_type() {
56                    note.id()
57                } else {
58                    None
59                }
60            })
61            .collect();
62
63        let committed_note_ids: BTreeSet<NoteId> = value
64            .note_updates
65            .updated_input_notes()
66            .filter_map(|note_update| {
67                let note = note_update.inner();
68                // `InsertCommitted` is a previously-tracked expected note that just committed, so
69                // it counts as committed (not as a newly-discovered note) even though it is
70                // persisted via a full-row insert.
71                if matches!(
72                    note_update.update_type(),
73                    NoteUpdateType::Update | NoteUpdateType::InsertCommitted
74                ) && note.is_committed()
75                {
76                    note.id()
77                } else {
78                    None
79                }
80            })
81            .chain(value.note_updates.updated_output_notes().filter_map(|note_update| {
82                let note = note_update.inner();
83                if let NoteUpdateType::Update = note_update.update_type() {
84                    note.is_committed().then_some(note.id())
85                } else {
86                    None
87                }
88            }))
89            .collect();
90
91        let consumed_note_ids: BTreeSet<NoteId> =
92            value.note_updates.consumed_input_note_ids().collect();
93
94        SyncSummary::new(
95            value.block_num,
96            new_public_note_ids,
97            // Populated by Client::sync_state from the Note Transport Layer fetch.
98            Vec::new(),
99            committed_note_ids.into_iter().collect(),
100            consumed_note_ids.into_iter().collect(),
101            value
102                .account_updates
103                .updated_public_accounts()
104                .iter()
105                .map(PublicAccountUpdate::id)
106                .collect(),
107            value
108                .account_updates
109                .mismatched_private_accounts()
110                .iter()
111                .map(|(id, _)| *id)
112                .collect(),
113            value.transaction_updates.committed_transactions().map(|t| t.id).collect(),
114        )
115    }
116}
117
118/// Contains all the partial blockchain information that needs to be added in the client's store
119/// after a sync: block headers, authentication nodes and the MMR peaks at the new sync height.
120#[derive(Debug, Clone, Default)]
121pub struct PartialBlockchainUpdates {
122    /// New block headers to be stored, keyed by block number. The value contains the block
123    /// header and a flag indicating whether the block contains notes relevant to the client.
124    block_headers: BTreeMap<BlockNumber, (BlockHeader, bool)>,
125    /// New authentication nodes that are meant to be stored in order to authenticate block
126    /// headers.
127    new_authentication_nodes: Vec<(InOrderIndex, Word)>,
128    /// MMR peaks at the new sync height.
129    pub new_peaks: MmrPeaks,
130}
131
132impl PartialBlockchainUpdates {
133    /// Adds or updates a block header in this [`PartialBlockchainUpdates`].
134    ///
135    /// If the block header already exists (same block number), the `has_client_notes` flag is
136    /// OR-ed. Otherwise a new entry is added.
137    pub fn insert(
138        &mut self,
139        block_header: BlockHeader,
140        has_client_notes: bool,
141        new_authentication_nodes: Vec<(InOrderIndex, Word)>,
142    ) {
143        self.block_headers
144            .entry(block_header.block_num())
145            .and_modify(|(_, existing_has_notes)| {
146                *existing_has_notes |= has_client_notes;
147            })
148            .or_insert((block_header, has_client_notes));
149
150        self.new_authentication_nodes.extend(new_authentication_nodes);
151    }
152
153    /// Returns the new block headers to be stored, along with a flag indicating whether the block
154    /// contains notes that are relevant to the client.
155    pub fn block_headers(&self) -> impl Iterator<Item = &(BlockHeader, bool)> {
156        self.block_headers.values()
157    }
158
159    /// Returns the new authentication nodes that are meant to be stored in order to authenticate
160    /// block headers.
161    pub fn new_authentication_nodes(&self) -> &[(InOrderIndex, Word)] {
162        &self.new_authentication_nodes
163    }
164}
165
166/// Contains transaction changes to apply to the store.
167#[derive(Default)]
168pub struct TransactionUpdateTracker {
169    /// Transactions that were committed in the block.
170    transactions: BTreeMap<TransactionId, TransactionRecord>,
171    /// Nullifier-to-account mappings from external transactions by tracked accounts.
172    external_nullifier_accounts: BTreeMap<Nullifier, AccountId>,
173}
174
175impl TransactionUpdateTracker {
176    /// Creates a new [`TransactionUpdateTracker`]
177    pub fn new(transactions: Vec<TransactionRecord>) -> Self {
178        let transactions =
179            transactions.into_iter().map(|tx| (tx.id, tx)).collect::<BTreeMap<_, _>>();
180
181        Self {
182            transactions,
183            external_nullifier_accounts: BTreeMap::new(),
184        }
185    }
186
187    /// Returns a reference to committed transactions.
188    pub fn committed_transactions(&self) -> impl Iterator<Item = &TransactionRecord> {
189        self.transactions
190            .values()
191            .filter(|tx| matches!(tx.status, TransactionStatus::Committed { .. }))
192    }
193
194    /// Returns a reference to discarded transactions.
195    pub fn discarded_transactions(&self) -> impl Iterator<Item = &TransactionRecord> {
196        self.transactions
197            .values()
198            .filter(|tx| matches!(tx.status, TransactionStatus::Discarded(_)))
199    }
200
201    /// Returns a mutable reference to pending transactions in the tracker.
202    fn mutable_pending_transactions(&mut self) -> impl Iterator<Item = &mut TransactionRecord> {
203        self.transactions
204            .values_mut()
205            .filter(|tx| matches!(tx.status, TransactionStatus::Pending))
206    }
207
208    /// Returns transaction IDs of all transactions that have been updated.
209    pub fn updated_transaction_ids(&self) -> impl Iterator<Item = TransactionId> {
210        self.committed_transactions()
211            .chain(self.discarded_transactions())
212            .map(|tx| tx.id)
213    }
214
215    /// Returns the account ID that consumed the given nullifier in an external transaction, if
216    /// available.
217    pub fn external_nullifier_account(&self, nullifier: &Nullifier) -> Option<AccountId> {
218        self.external_nullifier_accounts.get(nullifier).copied()
219    }
220
221    /// Applies the necessary state transitions to the [`TransactionUpdateTracker`] when a
222    /// transaction is included in a block.
223    pub fn apply_transaction_inclusion(&mut self, record: &RpcTransactionRecord, timestamp: u64) {
224        let header = &record.transaction_header;
225        let account_id = header.account_id();
226
227        if let Some(transaction) = self.transactions.get_mut(&header.id()) {
228            transaction.commit_transaction(record.block_num, timestamp);
229            return;
230        }
231
232        // Fallback for transactions with unauthenticated input notes: the node
233        // authenticates these notes during processing, which changes the transaction
234        // ID. Match by account ID and pre-transaction state instead.
235        if let Some(transaction) = self.transactions.values_mut().find(|tx| {
236            tx.details.account_id == account_id
237                && tx.details.init_account_state == header.initial_state_commitment()
238        }) {
239            transaction.commit_transaction(record.block_num, timestamp);
240            return;
241        }
242
243        // No local transaction matched. This is an external transaction by a tracked account.
244        // Record the nullifier→account mappings so we can attribute note consumption to tracked
245        // accounts during nullifier processing.
246        for commitment in header.input_notes().iter() {
247            self.external_nullifier_accounts.insert(commitment.nullifier(), account_id);
248        }
249    }
250
251    /// Applies the necessary state transitions to the [`TransactionUpdateTracker`] when a the sync
252    /// height of the client is updated. This may result in stale or expired transactions.
253    pub fn apply_sync_height_update(
254        &mut self,
255        new_sync_height: BlockNumber,
256        tx_discard_delta: Option<u32>,
257    ) {
258        if let Some(tx_discard_delta) = tx_discard_delta {
259            self.discard_transaction_with_predicate(
260                |transaction| {
261                    transaction.details.submission_height
262                        < new_sync_height.checked_sub(tx_discard_delta).unwrap_or_default()
263                },
264                DiscardCause::Stale,
265            );
266        }
267
268        // NOTE: we check for <= new_sync height because at this point we would have committed the
269        // transaction otherwise
270        self.discard_transaction_with_predicate(
271            |transaction| transaction.details.expiration_block_num <= new_sync_height,
272            DiscardCause::Expired,
273        );
274    }
275
276    /// Applies the necessary state transitions to the [`TransactionUpdateTracker`] when a note is
277    /// nullified. this may result in transactions being discarded because they were processing the
278    /// nullified note.
279    pub fn apply_input_note_nullified(&mut self, input_note_nullifier: Nullifier) {
280        self.discard_transaction_with_predicate(
281            |transaction| {
282                // Check if the note was being processed by a local transaction that didn't end up
283                // being committed so it should be discarded
284                transaction
285                    .details
286                    .input_note_nullifiers
287                    .contains(&input_note_nullifier.as_word())
288            },
289            DiscardCause::InputConsumed,
290        );
291    }
292
293    /// Discards the local transaction that produced this now-superseded account state.
294    pub fn apply_superseded_account_state(&mut self, superseded_account_state: Word) {
295        self.discard_transaction_with_predicate(
296            |transaction| transaction.details.final_account_state == superseded_account_state,
297            DiscardCause::Superseded,
298        );
299    }
300
301    /// Discards transactions that have the same initial account state as the provided one.
302    pub fn apply_invalid_initial_account_state(&mut self, invalid_account_state: Word) {
303        self.discard_transaction_with_predicate(
304            |transaction| transaction.details.init_account_state == invalid_account_state,
305            DiscardCause::DiscardedInitialState,
306        );
307    }
308
309    /// Discards transactions that match the predicate and also applies the new invalid account
310    /// states
311    fn discard_transaction_with_predicate<F>(&mut self, predicate: F, discard_cause: DiscardCause)
312    where
313        F: Fn(&TransactionRecord) -> bool,
314    {
315        let mut new_invalid_account_states = vec![];
316
317        for transaction in self.mutable_pending_transactions() {
318            // Discard transactions, and also push the invalid account state if the transaction
319            // got correctly discarded
320            // NOTE: previous updates in a chain of state syncs could have committed a transaction,
321            // so we need to check that `discard_transaction` returns `true` here (aka, it got
322            // discarded from a valid state)
323            if predicate(transaction) && transaction.discard_transaction(discard_cause) {
324                new_invalid_account_states.push(transaction.details.final_account_state);
325            }
326        }
327
328        for state in new_invalid_account_states {
329            self.apply_invalid_initial_account_state(state);
330        }
331    }
332}
333
334// PUBLIC ACCOUNT UPDATE
335// ================================================================================================
336
337/// Update to a single tracked public account.
338///
339/// `StateSync` emits one of two variants depending on whether the node could return the account's
340/// full state in a single response:
341///
342/// - [`PublicAccountUpdate::Full`] carries the new [`Account`] state directly (used when no storage
343///   map is oversized and the vault fits in the response). The store applies it by replacing the
344///   local state.
345/// - [`PublicAccountUpdate::Patch`] carries the new account header plus the absolute
346///   [`AccountPatch`] built from the node's incremental endpoints (`sync_storage_maps` and
347///   `sync_account_vault`, used when any part of the account is oversized). The header is included
348///   because the patch does not carry the final commitments.
349#[derive(Debug, Clone)]
350pub enum PublicAccountUpdate {
351    /// The account fits in a single proof response — the new full state is carried as-is.
352    Full(Account),
353    /// The account is oversized in some dimension. The new state is described by the absolute
354    /// patch, which advances the local state to `new_header`.
355    Patch {
356        /// The new account header after applying the patch.
357        new_header: AccountHeader,
358        /// The absolute patch to apply.
359        patch: AccountPatch,
360    },
361}
362
363impl PublicAccountUpdate {
364    /// Returns the account ID for this update.
365    pub fn id(&self) -> AccountId {
366        match self {
367            Self::Full(account) => account.id(),
368            Self::Patch { new_header, .. } => new_header.id(),
369        }
370    }
371
372    /// Returns the account nonce that this update advances the local state to.
373    pub fn nonce(&self) -> Felt {
374        match self {
375            Self::Full(account) => account.nonce(),
376            Self::Patch { new_header, .. } => new_header.nonce(),
377        }
378    }
379}
380
381/// Builds the absolute [`AccountPatch`] implied by the updates fetched from the node's incremental
382/// endpoints: the value-slot values, the absolute changed map entries per slot, and the absolute
383/// vault patch.
384///
385/// The carried updates are already merged to the new absolute value of each changed storage slot,
386/// map entry, and vault asset, so the patch is assembled directly from them with no need to load
387/// the prior account state.
388///
389/// An update of an existing account (final nonce > 1) yields a partial-state patch with no code. A
390/// newly created account (final nonce 1) cannot be represented as a partial-state patch, so the
391/// patch becomes a full-state patch carrying `code` (already validated against the on-chain code
392/// commitment by the caller).
393pub(crate) fn build_account_patch(
394    new_header: &AccountHeader,
395    value_slot_updates: Vec<(StorageSlotName, Word)>,
396    map_entries: BTreeMap<StorageSlotName, StorageMapPatchEntries>,
397    vault_patch: AccountVaultPatch,
398    code: AccountCode,
399) -> Result<AccountPatch, AccountPatchError> {
400    let is_full_state = new_header.nonce() == ONE;
401
402    let value_entries = value_slot_updates.into_iter().map(|(slot_name, new_value)| {
403        let value_patch = if is_full_state {
404            StorageValuePatch::Create { value: new_value }
405        } else {
406            StorageValuePatch::Update { value: new_value }
407        };
408        (slot_name, StorageSlotPatch::Value(value_patch))
409    });
410
411    let map_entries = map_entries.into_iter().map(|(slot_name, entries)| {
412        let map_patch = if is_full_state {
413            StorageMapPatch::Create { entries }
414        } else {
415            StorageMapPatch::Update { entries }
416        };
417        (slot_name, StorageSlotPatch::Map(map_patch))
418    });
419
420    let storage = AccountStoragePatch::from_entries(value_entries.chain(map_entries))?;
421
422    let code = is_full_state.then_some(code);
423
424    AccountPatch::new(new_header.id(), storage, vault_patch, code, Some(new_header.nonce()))
425}
426
427// ACCOUNT UPDATES
428// ================================================================================================
429
430/// Contains account changes to apply to the store after a sync request.
431#[derive(Debug, Clone, Default)]
432#[allow(clippy::struct_field_names)]
433pub struct AccountUpdates {
434    /// Updated public accounts, either as full state replacements or incremental patches.
435    updated_public_accounts: Vec<PublicAccountUpdate>,
436    /// Account commitments received from the network that don't match the currently
437    /// locally-tracked state of the private accounts.
438    ///
439    /// These updates may represent a stale account commitment (meaning that the latest local state
440    /// hasn't been committed). If this is not the case, the account may be locked until the state
441    /// is restored manually.
442    mismatched_private_accounts: Vec<(AccountId, Word)>,
443}
444
445impl AccountUpdates {
446    /// Creates a new instance of `AccountUpdates`.
447    pub fn new(
448        updated_public_accounts: Vec<PublicAccountUpdate>,
449        mismatched_private_accounts: Vec<(AccountId, Word)>,
450    ) -> Self {
451        Self {
452            updated_public_accounts,
453            mismatched_private_accounts,
454        }
455    }
456
457    /// Returns the updated public accounts.
458    pub fn updated_public_accounts(&self) -> &[PublicAccountUpdate] {
459        &self.updated_public_accounts
460    }
461
462    /// Returns the mismatched private accounts.
463    pub fn mismatched_private_accounts(&self) -> &[(AccountId, Word)] {
464        &self.mismatched_private_accounts
465    }
466
467    pub fn extend(&mut self, other: AccountUpdates) {
468        self.updated_public_accounts.extend(other.updated_public_accounts);
469        self.mismatched_private_accounts.extend(other.mismatched_private_accounts);
470    }
471}
472
473// TESTS
474// ================================================================================================
475
476#[cfg(test)]
477mod tests {
478    use alloc::collections::BTreeMap;
479    use alloc::vec;
480
481    use miden_protocol::account::{AccountCode, StorageMapKey, StorageMapPatchEntries};
482    use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE;
483
484    use super::*;
485
486    fn account_id() -> AccountId {
487        ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into().unwrap()
488    }
489
490    fn slot_name(name: &str) -> StorageSlotName {
491        StorageSlotName::new(name).unwrap()
492    }
493
494    fn word(n: u64) -> Word {
495        Word::from([
496            Felt::new_unchecked(n),
497            Felt::new_unchecked(0),
498            Felt::new_unchecked(0),
499            Felt::new_unchecked(0),
500        ])
501    }
502
503    fn header_with_nonce(nonce: u64) -> AccountHeader {
504        AccountHeader::new(
505            account_id(),
506            Felt::new(nonce).expect("test nonce must be a valid Felt"),
507            Word::default(),
508            Word::default(),
509            Word::default(),
510        )
511    }
512
513    fn build_patch(
514        new_nonce: u64,
515        value_slot_updates: Vec<(StorageSlotName, Word)>,
516        map_entries: BTreeMap<StorageSlotName, StorageMapPatchEntries>,
517    ) -> Result<AccountPatch, AccountPatchError> {
518        build_account_patch(
519            &header_with_nonce(new_nonce),
520            value_slot_updates,
521            map_entries,
522            AccountVaultPatch::default(),
523            AccountCode::mock(),
524        )
525    }
526
527    #[test]
528    fn build_patch_empty_payload_carries_only_nonce() {
529        let patch = build_patch(4, vec![], BTreeMap::new()).unwrap();
530
531        assert_eq!(patch.final_nonce(), Some(Felt::new_unchecked(4)));
532        assert!(patch.storage().is_empty());
533        assert!(patch.vault().is_empty());
534        assert!(!patch.is_full_state());
535    }
536
537    #[test]
538    fn build_patch_sets_value_slot_absolutely() {
539        let value_slot = slot_name("miden::test::value");
540        let patch = build_patch(2, vec![(value_slot.clone(), word(2))], BTreeMap::new()).unwrap();
541
542        assert_eq!(patch.storage().updated_value(&value_slot), Some(word(2)));
543    }
544
545    #[test]
546    fn build_patch_wraps_merged_map_entries() {
547        let map_slot = slot_name("miden::test::map");
548        let key = StorageMapKey::from_raw(word(42));
549        let mut entries = StorageMapPatchEntries::new();
550        entries.insert(key, word(300));
551        let map_entries = BTreeMap::from([(map_slot.clone(), entries)]);
552
553        let patch = build_patch(2, vec![], map_entries).unwrap();
554
555        let entries =
556            patch.storage().updated_map(&map_slot).expect("patch should contain map slot");
557        assert_eq!(entries.as_map().len(), 1);
558        assert_eq!(*entries.as_map().values().next().unwrap(), word(300));
559    }
560
561    #[test]
562    fn build_patch_rejects_zero_nonce() {
563        let result = build_patch(0, vec![], BTreeMap::new());
564        assert!(result.is_err());
565    }
566
567    /// A newly created account (final nonce 1) observed via the oversized sync path yields a
568    /// full-state patch carrying the supplied code, rather than failing to build.
569    #[test]
570    fn build_patch_for_new_account_is_full_state() {
571        let value_slot = slot_name("miden::test::value");
572        let patch = build_patch(1, vec![(value_slot, word(1))], BTreeMap::new()).unwrap();
573
574        assert!(patch.is_full_state());
575        assert_eq!(patch.final_nonce(), Some(ONE));
576    }
577
578    /// A newly created account (final nonce 1, full-state) emits each map slot as a `Create`, which
579    /// the store applies by starting the slot from an empty map.
580    #[test]
581    fn build_patch_emits_map_create_for_new_account() {
582        let map_slot = slot_name("miden::test::map");
583        let mut entries = StorageMapPatchEntries::new();
584        entries.insert(StorageMapKey::from_raw(word(1)), word(100));
585        let map_entries = BTreeMap::from([(map_slot.clone(), entries)]);
586
587        let patch = build_patch(1, vec![], map_entries).unwrap();
588
589        assert!(patch.storage().created_map(&map_slot).is_some());
590    }
591
592    /// An update to an existing account (final nonce > 1) emits map slots as `Update`, never
593    /// `Create`, so the sync path never asks the store to re-create a populated map.
594    #[test]
595    fn build_patch_emits_map_update_for_existing_account() {
596        let map_slot = slot_name("miden::test::map");
597        let mut entries = StorageMapPatchEntries::new();
598        entries.insert(StorageMapKey::from_raw(word(1)), word(100));
599        let map_entries = BTreeMap::from([(map_slot.clone(), entries)]);
600
601        let patch = build_patch(2, vec![], map_entries).unwrap();
602
603        assert!(patch.storage().updated_map(&map_slot).is_some());
604    }
605}