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    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
33// STATE SYNC UPDATE
34// ================================================================================================
35
36/// Contains all information needed to apply the update in the store after syncing with the node.
37///
38/// Immutable once built: [`StateSync::sync_state`](super::StateSync::sync_state) assembles the
39/// individual trackers and seals them into this type at the end of the sync pass. Use
40/// [`Self::from_parts`] to build one directly.
41pub struct StateSyncUpdate {
42    /// The block number of the last block that was synced.
43    block_num: BlockNumber,
44    /// New blocks, authentication nodes and MMR peaks.
45    partial_blockchain_updates: PartialBlockchainUpdates,
46    /// New and updated notes to be upserted in the store.
47    note_updates: NoteUpdateTracker,
48    /// Committed and discarded transactions after the sync.
49    transaction_updates: TransactionUpdateTracker,
50    /// Public account updates and mismatched private accounts after the sync.
51    account_updates: AccountUpdates,
52    /// The protocol configuration active at `block_num`. The node sends it when the sync starts at
53    /// genesis, or when the starting block and `block_num` commit to different configurations.
54    protocol_config: Option<ProtocolConfig>,
55}
56
57impl StateSyncUpdate {
58    /// Assembles an update from its constituent parts, mirroring [`Self::into_parts`].
59    ///
60    /// The parts are stored as given: no validation or minimization is applied.
61    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    /// Returns the block number of the last synced block.
80    pub fn block_num(&self) -> BlockNumber {
81        self.block_num
82    }
83
84    /// Returns the partial blockchain updates.
85    pub fn partial_blockchain_updates(&self) -> &PartialBlockchainUpdates {
86        &self.partial_blockchain_updates
87    }
88
89    /// Returns the note updates.
90    pub fn note_updates(&self) -> &NoteUpdateTracker {
91        &self.note_updates
92    }
93
94    /// Returns the transaction updates.
95    pub fn transaction_updates(&self) -> &TransactionUpdateTracker {
96        &self.transaction_updates
97    }
98
99    /// Returns the account updates.
100    pub fn account_updates(&self) -> &AccountUpdates {
101        &self.account_updates
102    }
103
104    /// Returns the protocol configuration the node sent with this sync, if any.
105    pub fn protocol_config(&self) -> Option<&ProtocolConfig> {
106        self.protocol_config.as_ref()
107    }
108
109    /// Decomposes this update into its constituent parts.
110    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                // `InsertCommitted` is a previously-tracked expected note that just committed, so
152                // it counts as committed (not as a newly-discovered note) even though it is
153                // persisted via a full-row insert.
154                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            // Populated by Client::sync_state from the Note Transport Layer fetch.
181            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/// Contains all the partial blockchain information that needs to be added in the client's store
202/// after a sync: block headers, authentication nodes and the MMR peaks at the new sync height.
203///
204/// Insert-only: entries are staged once known to be worth keeping, never revised or removed.
205#[derive(Debug, Clone, Default)]
206pub struct PartialBlockchainUpdates {
207    /// New block headers to be stored, keyed by block number. The value contains the block header
208    /// and a flag indicating whether the block is relevant and should remain tracked.
209    block_headers: BTreeMap<BlockNumber, (BlockHeader, bool)>,
210    /// New authentication nodes that are meant to be stored in order to authenticate block headers.
211    new_authentication_nodes: Vec<(InOrderIndex, Word)>,
212    /// MMR peaks at the new sync height.
213    pub new_peaks: MmrPeaks,
214}
215
216impl PartialBlockchainUpdates {
217    /// Adds a block header to this [`PartialBlockchainUpdates`].
218    ///
219    /// On a repeated block number the `is_relevant` flag is OR-ed — the chain tip block may itself
220    /// be relevant — so it only ever moves from `false` to `true`, matching
221    /// [`Store::insert_block_header`](crate::store::Store::insert_block_header)'s one-way upgrade.
222    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    /// Stages authentication nodes for storage.
232    ///
233    /// Kept as one flat set rather than per-header, since tracked blocks' paths share internal
234    /// nodes.
235    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    /// Returns the new block headers to be stored, along with a flag indicating whether each block
243    /// is relevant and should remain tracked.
244    pub fn block_headers(&self) -> impl Iterator<Item = &(BlockHeader, bool)> {
245        self.block_headers.values()
246    }
247
248    /// Returns block headers that need to be persisted for this update.
249    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    /// Returns the new authentication nodes that are meant to be stored in order to authenticate
261    /// block headers.
262    pub fn new_authentication_nodes(&self) -> &[(InOrderIndex, Word)] {
263        &self.new_authentication_nodes
264    }
265}
266
267/// Contains transaction changes to apply to the store.
268#[derive(Default)]
269pub struct TransactionUpdateTracker {
270    /// Transactions that were committed in the block.
271    transactions: BTreeMap<TransactionId, TransactionRecord>,
272    /// Nullifier-to-account mappings from external transactions by tracked accounts.
273    external_nullifier_accounts: BTreeMap<Nullifier, AccountId>,
274}
275
276impl TransactionUpdateTracker {
277    /// Creates a new [`TransactionUpdateTracker`]
278    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    /// Returns a reference to committed transactions.
289    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    /// Returns a reference to discarded transactions.
296    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    /// Returns a mutable reference to pending transactions in the tracker.
303    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    /// Returns transaction IDs of all transactions that have been updated.
310    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    /// Returns the account ID that consumed the given nullifier in an external transaction, if
317    /// available.
318    pub fn external_nullifier_account(&self, nullifier: &Nullifier) -> Option<AccountId> {
319        self.external_nullifier_accounts.get(nullifier).copied()
320    }
321
322    /// Applies the necessary state transitions to the [`TransactionUpdateTracker`] when a
323    /// transaction is included in a block.
324    ///
325    /// The included transaction is matched to a local pending transaction by its ID only. The node
326    /// reports the original transaction ID, so a record with an unknown ID is an external
327    /// transaction of a tracked account.
328    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        // No local transaction has this ID. This is an external transaction by a tracked account.
338        // Record the nullifier→account mappings so we can attribute note consumption to tracked
339        // accounts during nullifier processing.
340        for commitment in header.input_notes().iter() {
341            self.external_nullifier_accounts.insert(commitment.nullifier(), account_id);
342        }
343    }
344
345    /// Applies the necessary state transitions to the [`TransactionUpdateTracker`] when a the sync
346    /// height of the client is updated. This may result in stale or expired transactions.
347    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        // NOTE: we check for <= new_sync height because at this point we would have committed the
363        // transaction otherwise
364        self.discard_transaction_with_predicate(
365            |transaction| transaction.details.expiration_block_num <= new_sync_height,
366            DiscardCause::Expired,
367        );
368    }
369
370    /// Applies the necessary state transitions to the [`TransactionUpdateTracker`] when a note is
371    /// nullified. this may result in transactions being discarded because they were processing the
372    /// nullified note.
373    pub fn apply_input_note_nullified(&mut self, input_note_nullifier: Nullifier) {
374        self.discard_transaction_with_predicate(
375            |transaction| {
376                // Check if the note was being processed by a local transaction that didn't end up
377                // being committed so it should be discarded
378                transaction
379                    .details
380                    .input_note_nullifiers
381                    .contains(&input_note_nullifier.as_word())
382            },
383            DiscardCause::InputConsumed,
384        );
385    }
386
387    /// Discards the local transaction that produced this now-superseded account state.
388    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    /// Discards transactions that have the same initial account state as the provided one.
396    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    /// Discards transactions that match the predicate and also applies the new invalid account
404    /// states
405    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            // Discard transactions, and also push the invalid account state if the transaction got
413            // correctly discarded
414            // NOTE: previous updates in a chain of state syncs could have committed a transaction,
415            // so we need to check that `discard_transaction` returns `true` here (aka, it got
416            // discarded from a valid state)
417            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// PUBLIC ACCOUNT UPDATE
429// ================================================================================================
430
431/// Update to a single tracked public account.
432///
433/// `StateSync` emits one of two variants depending on whether the node could return the account's
434/// full state in a single response:
435///
436/// - [`PublicAccountUpdate::Full`] carries the new [`Account`] state directly (used when no storage
437///   map is oversized and the vault fits in the response). The store applies it by replacing the
438///   local state.
439/// - [`PublicAccountUpdate::Patch`] carries the new account header plus the absolute
440///   [`AccountPatch`] built from the node's incremental endpoints (`sync_storage_maps` and
441///   `sync_account_vault`, used when any part of the account is oversized). The header is included
442///   because the patch does not carry the final commitments.
443#[derive(Debug, Clone)]
444pub enum PublicAccountUpdate {
445    /// The account fits in a single proof response — the new full state is carried as-is.
446    Full(Account),
447    /// The account is oversized in some dimension. The new state is described by the absolute
448    /// patch, which advances the local state to `new_header`.
449    Patch {
450        /// The new account header after applying the patch.
451        new_header: AccountHeader,
452        /// The absolute patch to apply.
453        patch: AccountPatch,
454    },
455}
456
457impl PublicAccountUpdate {
458    /// Returns the account ID for this update.
459    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    /// Returns the account nonce that this update advances the local state to.
467    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
475/// Builds the absolute [`AccountPatch`] implied by the updates fetched from the node's incremental
476/// endpoints: the value-slot values, the absolute changed map entries per slot, and the absolute
477/// vault patch.
478///
479/// The carried updates are already merged to the new absolute value of each changed storage slot,
480/// map entry, and vault asset, so the patch is assembled directly from them with no need to load
481/// the prior account state.
482///
483/// A newly created account (final nonce 1) gets a creation patch: every storage slot is a `Create`
484/// operation and the patch carries `code`. An update of an existing account (final nonce > 1) gets
485/// `Update` operations. It carries `code` only if the code commitment differs from
486/// `local_code_commitment`, which means that the account upgraded its code. The caller must
487/// validate `code` against the on-chain code commitment.
488pub(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// ACCOUNT UPDATES
525// ================================================================================================
526
527/// Contains account changes to apply to the store after a sync request.
528#[derive(Debug, Clone, Default)]
529#[allow(clippy::struct_field_names)]
530pub struct AccountUpdates {
531    /// Updated public accounts, either as full state replacements or incremental patches.
532    updated_public_accounts: Vec<PublicAccountUpdate>,
533    /// Account commitments received from the network that don't match the currently locally-tracked
534    /// state of the private accounts.
535    ///
536    /// These updates may represent a stale account commitment (meaning that the latest local state
537    /// hasn't been committed). If this is not the case, the account may be locked until the state
538    /// is restored manually.
539    mismatched_private_accounts: Vec<(AccountId, Word)>,
540    /// Witnesses validated at the target block, for the accounts the sync queried anyway. Kept so
541    /// that the witness refresh does not request them a second time.
542    account_witnesses: Vec<(AccountId, AccountWitness)>,
543}
544
545impl AccountUpdates {
546    /// Creates a new instance of `AccountUpdates`.
547    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    /// Attaches the account witnesses the sync validated at its target block.
559    #[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    /// Returns the updated public accounts.
569    pub fn updated_public_accounts(&self) -> &[PublicAccountUpdate] {
570        &self.updated_public_accounts
571    }
572
573    /// Returns the mismatched private accounts.
574    pub fn mismatched_private_accounts(&self) -> &[(AccountId, Word)] {
575        &self.mismatched_private_accounts
576    }
577
578    /// Returns the account witnesses validated at the sync's target block.
579    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// TESTS
591// ================================================================================================
592
593#[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    /// A newly created account (final nonce 1) observed via the oversized sync path yields a
693    /// creation patch carrying the supplied code, rather than failing to build.
694    #[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    /// An existing account whose on-chain code commitment differs from the local one upgraded its
705    /// code. The patch carries the new code and keeps `Update` operations for storage.
706    #[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    /// An existing account whose code commitment did not change gets a patch without code.
728    #[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    /// A newly created account (final nonce 1, full-state) emits each map slot as a `Create`, which
737    /// the store applies by starting the slot from an empty map.
738    #[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    /// An update to an existing account (final nonce > 1) emits map slots as `Update`, never
751    /// `Create`, so the sync path never asks the store to re-create a populated map.
752    #[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    // TRANSACTION INCLUSION TESTS
765    // --------------------------------------------------------------------------------------------
766
767    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    /// An included transaction with an unknown ID is external, even when it shares the account and
806    /// the initial and final states with a local pending transaction.
807    #[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}