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