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    AccountDelta,
7    AccountHeader,
8    AccountId,
9    AccountStorage,
10    AccountStorageDelta,
11    AccountVaultDelta,
12    StorageMapKey,
13    StorageSlotName,
14};
15use miden_protocol::asset::{Asset, AssetVault, AssetVaultKey};
16use miden_protocol::block::{BlockHeader, BlockNumber};
17use miden_protocol::crypto::merkle::mmr::{InOrderIndex, MmrPeaks};
18use miden_protocol::errors::{AccountDeltaError, AccountError};
19use miden_protocol::note::{NoteId, Nullifier};
20use miden_protocol::transaction::TransactionId;
21use miden_protocol::{Felt, Word};
22
23use super::SyncSummary;
24use crate::note::{NoteUpdateTracker, NoteUpdateType};
25use crate::rpc::domain::account_vault::AccountVaultUpdate;
26use crate::rpc::domain::storage_map::StorageMapUpdate;
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 — no delta computation needed.
345/// - [`PublicAccountUpdate::Delta`] carries a [`PublicAccountDelta`] payload (new header plus
346///   incremental updates from `sync_storage_maps` and `sync_account_vault`, used when any part of
347///   the account is oversized). The store calls [`PublicAccountDelta::compute_account_delta`] to
348///   derive the [`AccountDelta`] to apply.
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 must be reconstructed by
354    /// replaying the carried incremental updates against the locally-stored state.
355    Delta(PublicAccountDelta),
356}
357
358impl PublicAccountUpdate {
359    /// Returns the account ID for this update.
360    pub fn id(&self) -> AccountId {
361        match self {
362            Self::Full(account) => account.id(),
363            Self::Delta(delta) => delta.id(),
364        }
365    }
366
367    /// Returns the account nonce that this update advances the local state to.
368    pub fn nonce(&self) -> Felt {
369        match self {
370            Self::Full(account) => account.nonce(),
371            Self::Delta(delta) => delta.new_header().nonce(),
372        }
373    }
374}
375
376/// Incremental delta payload for a public account update.
377///
378/// Carries the new account header plus the per-block updates fetched from the node's incremental
379/// endpoints (`sync_storage_maps` and `sync_account_vault`). The store derives the
380/// [`AccountDelta`] to apply by replaying these updates against its locally-stored account state
381/// via [`Self::compute_account_delta`].
382#[derive(Debug, Clone)]
383pub struct PublicAccountDelta {
384    /// The new account header after applying these updates.
385    new_header: AccountHeader,
386    /// First block of the synced range (the client's previous sync height).
387    block_from: BlockNumber,
388    /// Last block of the synced range (the block at which `new_header` is observed).
389    block_to: BlockNumber,
390    /// New value-slot values from the `get_account` storage header. Value slots are always
391    /// small enough to fit in the response.
392    value_slot_updates: Vec<(StorageSlotName, Word)>,
393    /// Per-block storage map updates from `sync_storage_maps`.
394    storage_map_updates: Vec<StorageMapUpdate>,
395    /// Per-block vault updates from `sync_account_vault`.
396    vault_updates: Vec<AccountVaultUpdate>,
397}
398
399impl PublicAccountDelta {
400    /// Creates a new [`PublicAccountDelta`].
401    pub fn new(
402        new_header: AccountHeader,
403        block_from: BlockNumber,
404        block_to: BlockNumber,
405        value_slot_updates: Vec<(StorageSlotName, Word)>,
406        storage_map_updates: Vec<StorageMapUpdate>,
407        vault_updates: Vec<AccountVaultUpdate>,
408    ) -> Self {
409        Self {
410            new_header,
411            block_from,
412            block_to,
413            value_slot_updates,
414            storage_map_updates,
415            vault_updates,
416        }
417    }
418
419    /// Returns the account ID this delta applies to.
420    pub fn id(&self) -> AccountId {
421        self.new_header.id()
422    }
423
424    /// Returns the new account header that this delta advances the local state to.
425    pub fn new_header(&self) -> &AccountHeader {
426        &self.new_header
427    }
428
429    /// Returns the first block of the synced range.
430    pub fn block_from(&self) -> BlockNumber {
431        self.block_from
432    }
433
434    /// Returns the names of the value slots referenced by this delta. The store can use this to
435    /// load only the slots needed by [`Self::compute_account_delta`] instead of the full storage.
436    pub fn value_slot_names(&self) -> Vec<StorageSlotName> {
437        self.value_slot_updates.iter().map(|(name, _)| name.clone()).collect()
438    }
439
440    /// Returns the last block of the synced range.
441    pub fn block_to(&self) -> BlockNumber {
442        self.block_to
443    }
444
445    /// Computes the [`AccountDelta`] implied by this payload by replaying the carried
446    /// incremental updates against the locally-stored account state.
447    // TODO #2171:
448    // skip building AccountDelta; have the store accept raw RPC updates directly.
449    pub fn compute_account_delta(
450        &self,
451        local_header: &AccountHeader,
452        local_storage: &AccountStorage,
453        local_vault: &AssetVault,
454    ) -> Result<AccountDelta, AccountDeltaError> {
455        let old_nonce = local_header.nonce().as_canonical_u64();
456        let new_nonce = self.new_header.nonce().as_canonical_u64();
457        if new_nonce <= old_nonce {
458            return Err(AccountDeltaError::AccountDeltaApplicationFailed {
459                account_id: self.new_header.id(),
460                source: AccountError::other(format!(
461                    "node returned non-monotonic account nonce: local {old_nonce} >= new {new_nonce}"
462                )),
463            });
464        }
465
466        let storage_delta = replay_storage_updates(
467            local_storage,
468            &self.value_slot_updates,
469            &self.storage_map_updates,
470        )?;
471        let vault_delta = replay_vault_updates(local_vault, &self.vault_updates)?;
472
473        let nonce_delta = Felt::new(new_nonce - old_nonce).expect(
474            "new_nonce was checked to be higher than old_nonce; should return a valid nonce",
475        );
476
477        AccountDelta::new(self.new_header.id(), storage_delta, vault_delta, nonce_delta)
478    }
479}
480
481// DELTA REPLAY HELPERS
482// ================================================================================================
483
484/// Computes a storage delta by replaying incremental updates onto the locally-stored state.
485fn replay_storage_updates(
486    local_storage: &AccountStorage,
487    value_slot_updates: &[(StorageSlotName, Word)],
488    storage_map_updates: &[StorageMapUpdate],
489) -> Result<AccountStorageDelta, AccountDeltaError> {
490    let mut storage_delta = AccountStorageDelta::new();
491
492    // Value slots: emit only the slots whose new value differs from local.
493    for (slot_name, new_value) in value_slot_updates {
494        let local_value = local_storage.get_item(slot_name).ok();
495        if local_value.as_ref() != Some(new_value) {
496            storage_delta.set_item(slot_name.clone(), *new_value)?;
497        }
498    }
499
500    // Map slots: dedup updates per (slot, key) keeping the latest value by block number.
501    let mut by_slot: BTreeMap<StorageSlotName, BTreeMap<StorageMapKey, Word>> = BTreeMap::new();
502    let mut sorted: Vec<&StorageMapUpdate> = storage_map_updates.iter().collect();
503    sorted.sort_by_key(|u| u.block_num);
504    for update in sorted {
505        by_slot
506            .entry(update.slot_name.clone())
507            .or_default()
508            .insert(update.key, update.value);
509    }
510    for (slot_name, entries) in by_slot {
511        for (key, value) in entries {
512            storage_delta.set_map_item(slot_name.clone(), key, value)?;
513        }
514    }
515
516    Ok(storage_delta)
517}
518
519/// Computes a vault delta by replaying incremental updates onto the locally-stored vault.
520fn replay_vault_updates(
521    local_vault: &AssetVault,
522    vault_updates: &[AccountVaultUpdate],
523) -> Result<AccountVaultDelta, AccountDeltaError> {
524    let mut vault_delta = AccountVaultDelta::default();
525
526    let mut final_vault: BTreeMap<AssetVaultKey, Asset> =
527        local_vault.assets().map(|asset| (asset.vault_key(), asset)).collect();
528
529    let mut sorted: Vec<&AccountVaultUpdate> = vault_updates.iter().collect();
530    sorted.sort_by_key(|u| u.block_num);
531    for update in sorted {
532        match update.asset {
533            Some(asset) => {
534                final_vault.insert(update.vault_key, asset);
535            },
536            None => {
537                final_vault.remove(&update.vault_key);
538            },
539        }
540    }
541
542    let local_assets: BTreeMap<AssetVaultKey, Asset> =
543        local_vault.assets().map(|a| (a.vault_key(), a)).collect();
544    for (key, final_asset) in &final_vault {
545        match local_assets.get(key) {
546            None => {
547                vault_delta.add_asset(*final_asset)?;
548            },
549            Some(local_asset) if local_asset != final_asset => {
550                vault_delta.remove_asset(*local_asset)?;
551                vault_delta.add_asset(*final_asset)?;
552            },
553            _ => {},
554        }
555    }
556    for (key, local_asset) in &local_assets {
557        if !final_vault.contains_key(key) {
558            vault_delta.remove_asset(*local_asset)?;
559        }
560    }
561
562    Ok(vault_delta)
563}
564
565// ACCOUNT UPDATES
566// ================================================================================================
567
568/// Contains account changes to apply to the store after a sync request.
569#[derive(Debug, Clone, Default)]
570#[allow(clippy::struct_field_names)]
571pub struct AccountUpdates {
572    /// Updated public accounts, either as full state replacements or incremental deltas.
573    updated_public_accounts: Vec<PublicAccountUpdate>,
574    /// Account commitments received from the network that don't match the currently
575    /// locally-tracked state of the private accounts.
576    ///
577    /// These updates may represent a stale account commitment (meaning that the latest local state
578    /// hasn't been committed). If this is not the case, the account may be locked until the state
579    /// is restored manually.
580    mismatched_private_accounts: Vec<(AccountId, Word)>,
581}
582
583impl AccountUpdates {
584    /// Creates a new instance of `AccountUpdates`.
585    pub fn new(
586        updated_public_accounts: Vec<PublicAccountUpdate>,
587        mismatched_private_accounts: Vec<(AccountId, Word)>,
588    ) -> Self {
589        Self {
590            updated_public_accounts,
591            mismatched_private_accounts,
592        }
593    }
594
595    /// Returns the updated public accounts.
596    pub fn updated_public_accounts(&self) -> &[PublicAccountUpdate] {
597        &self.updated_public_accounts
598    }
599
600    /// Returns the mismatched private accounts.
601    pub fn mismatched_private_accounts(&self) -> &[(AccountId, Word)] {
602        &self.mismatched_private_accounts
603    }
604
605    pub fn extend(&mut self, other: AccountUpdates) {
606        self.updated_public_accounts.extend(other.updated_public_accounts);
607        self.mismatched_private_accounts.extend(other.mismatched_private_accounts);
608    }
609}
610
611// TESTS
612// ================================================================================================
613
614#[cfg(test)]
615mod tests {
616    use alloc::vec;
617
618    use miden_protocol::account::{StorageMapKey, StorageSlot};
619    use miden_protocol::asset::{Asset, AssetVault, FungibleAsset};
620    use miden_protocol::testing::account_id::{
621        ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
622        ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
623    };
624
625    use super::*;
626
627    fn account_id() -> AccountId {
628        ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE.try_into().unwrap()
629    }
630
631    fn faucet_id() -> AccountId {
632        ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap()
633    }
634
635    fn slot_name(name: &str) -> StorageSlotName {
636        StorageSlotName::new(name).unwrap()
637    }
638
639    fn map_key(n: u64) -> StorageMapKey {
640        StorageMapKey::from_raw(word(n))
641    }
642
643    fn word(n: u64) -> Word {
644        Word::from([
645            Felt::new_unchecked(n),
646            Felt::new_unchecked(0),
647            Felt::new_unchecked(0),
648            Felt::new_unchecked(0),
649        ])
650    }
651
652    fn fungible(amount: u64) -> Asset {
653        Asset::Fungible(FungibleAsset::new(faucet_id(), amount).unwrap())
654    }
655
656    fn header_with_nonce(nonce: u64) -> AccountHeader {
657        AccountHeader::new(
658            account_id(),
659            Felt::new(nonce).expect("test nonce must be a valid Felt"),
660            Word::default(),
661            Word::default(),
662            Word::default(),
663        )
664    }
665
666    fn empty_payload(new_header: AccountHeader) -> PublicAccountDelta {
667        PublicAccountDelta::new(
668            new_header,
669            BlockNumber::from(0u32),
670            BlockNumber::from(1u32),
671            vec![],
672            vec![],
673            vec![],
674        )
675    }
676
677    // REPLAY STORAGE UPDATES
678    // --------------------------------------------------------------------------------------------
679
680    #[test]
681    fn replay_storage_empty_inputs_returns_empty_delta() {
682        let storage = AccountStorage::new(vec![]).unwrap();
683        let delta = replay_storage_updates(&storage, &[], &[]).unwrap();
684        assert!(delta.is_empty());
685    }
686
687    #[test]
688    fn replay_storage_value_slot_changed_emits_delta() {
689        let value_slot = slot_name("miden::test::value");
690        let storage =
691            AccountStorage::new(vec![StorageSlot::with_value(value_slot.clone(), word(1))])
692                .unwrap();
693
694        let delta =
695            replay_storage_updates(&storage, &[(value_slot.clone(), word(2))], &[]).unwrap();
696
697        let entry = delta.get(&value_slot).expect("delta should contain value slot");
698        assert_eq!(entry.clone().unwrap_value(), word(2));
699    }
700
701    #[test]
702    fn replay_storage_value_slot_unchanged_is_skipped() {
703        let value_slot = slot_name("miden::test::value");
704        let storage =
705            AccountStorage::new(vec![StorageSlot::with_value(value_slot.clone(), word(1))])
706                .unwrap();
707
708        let delta =
709            replay_storage_updates(&storage, &[(value_slot.clone(), word(1))], &[]).unwrap();
710
711        assert!(delta.is_empty());
712    }
713
714    #[test]
715    fn replay_storage_map_dedup_keeps_latest_block_per_key() {
716        let map_slot = slot_name("miden::test::map");
717        let storage =
718            AccountStorage::new(vec![StorageSlot::with_empty_map(map_slot.clone())]).unwrap();
719
720        let key = map_key(42);
721        let updates = vec![
722            StorageMapUpdate {
723                block_num: BlockNumber::from(1u32),
724                slot_name: map_slot.clone(),
725                key,
726                value: word(100),
727            },
728            StorageMapUpdate {
729                block_num: BlockNumber::from(3u32),
730                slot_name: map_slot.clone(),
731                key,
732                value: word(300),
733            },
734            StorageMapUpdate {
735                block_num: BlockNumber::from(2u32),
736                slot_name: map_slot.clone(),
737                key,
738                value: word(200),
739            },
740        ];
741
742        let delta = replay_storage_updates(&storage, &[], &updates).unwrap();
743
744        let map_delta = delta.get(&map_slot).expect("delta should contain map slot").clone();
745        let map = map_delta.unwrap_map();
746        assert_eq!(map.entries().len(), 1);
747        assert_eq!(*map.entries().values().next().unwrap(), word(300));
748    }
749
750    #[test]
751    fn replay_storage_map_multiple_keys_in_same_slot_all_kept() {
752        let map_slot = slot_name("miden::test::map");
753        let storage =
754            AccountStorage::new(vec![StorageSlot::with_empty_map(map_slot.clone())]).unwrap();
755
756        let updates = vec![
757            StorageMapUpdate {
758                block_num: BlockNumber::from(1u32),
759                slot_name: map_slot.clone(),
760                key: map_key(1),
761                value: word(100),
762            },
763            StorageMapUpdate {
764                block_num: BlockNumber::from(2u32),
765                slot_name: map_slot.clone(),
766                key: map_key(2),
767                value: word(200),
768            },
769        ];
770
771        let delta = replay_storage_updates(&storage, &[], &updates).unwrap();
772        let map = delta.get(&map_slot).unwrap().clone().unwrap_map();
773        assert_eq!(map.entries().len(), 2);
774    }
775
776    // REPLAY VAULT UPDATES
777    // --------------------------------------------------------------------------------------------
778
779    #[test]
780    fn replay_vault_empty_inputs_returns_empty_delta() {
781        let vault = AssetVault::new(&[]).unwrap();
782        let delta = replay_vault_updates(&vault, &[]).unwrap();
783        assert!(delta.is_empty());
784    }
785
786    #[test]
787    fn replay_vault_added_asset_emits_add() {
788        let vault = AssetVault::new(&[]).unwrap();
789        let asset = fungible(100);
790        let updates = vec![AccountVaultUpdate {
791            block_num: BlockNumber::from(1u32),
792            asset: Some(asset),
793            vault_key: asset.vault_key(),
794        }];
795
796        let delta = replay_vault_updates(&vault, &updates).unwrap();
797        let added: Vec<_> = delta.added_assets().collect();
798        assert_eq!(added, vec![asset]);
799        assert_eq!(delta.removed_assets().count(), 0);
800    }
801
802    #[test]
803    fn replay_vault_removed_asset_emits_remove() {
804        let asset = fungible(100);
805        let vault = AssetVault::new(&[asset]).unwrap();
806        let updates = vec![AccountVaultUpdate {
807            block_num: BlockNumber::from(1u32),
808            asset: None,
809            vault_key: asset.vault_key(),
810        }];
811
812        let delta = replay_vault_updates(&vault, &updates).unwrap();
813        let removed: Vec<_> = delta.removed_assets().collect();
814        assert_eq!(removed, vec![asset]);
815        assert_eq!(delta.added_assets().count(), 0);
816    }
817
818    #[test]
819    fn replay_vault_replace_asset_emits_net_diff() {
820        let asset_a = fungible(100);
821        let asset_b = fungible(150);
822        let vault = AssetVault::new(&[asset_a]).unwrap();
823        let updates = vec![AccountVaultUpdate {
824            block_num: BlockNumber::from(1u32),
825            asset: Some(asset_b),
826            vault_key: asset_b.vault_key(),
827        }];
828
829        let delta = replay_vault_updates(&vault, &updates).unwrap();
830        let added: Vec<_> = delta.added_assets().collect();
831        assert_eq!(added, vec![fungible(50)]);
832        assert_eq!(delta.removed_assets().count(), 0);
833    }
834
835    #[test]
836    fn replay_vault_dedup_keeps_latest_block_per_key() {
837        let vault = AssetVault::new(&[]).unwrap();
838        let asset_v1 = fungible(100);
839        let asset_v2 = fungible(200);
840        let asset_v3 = fungible(300);
841        let key = asset_v1.vault_key();
842
843        let updates = vec![
844            AccountVaultUpdate {
845                block_num: BlockNumber::from(1u32),
846                asset: Some(asset_v1),
847                vault_key: key,
848            },
849            AccountVaultUpdate {
850                block_num: BlockNumber::from(3u32),
851                asset: Some(asset_v3),
852                vault_key: key,
853            },
854            AccountVaultUpdate {
855                block_num: BlockNumber::from(2u32),
856                asset: Some(asset_v2),
857                vault_key: key,
858            },
859        ];
860
861        let delta = replay_vault_updates(&vault, &updates).unwrap();
862        let added: Vec<_> = delta.added_assets().collect();
863        assert_eq!(added, vec![asset_v3]);
864    }
865
866    #[test]
867    fn replay_vault_added_then_removed_is_noop() {
868        let vault = AssetVault::new(&[]).unwrap();
869        let asset = fungible(100);
870        let key = asset.vault_key();
871
872        let updates = vec![
873            AccountVaultUpdate {
874                block_num: BlockNumber::from(1u32),
875                asset: Some(asset),
876                vault_key: key,
877            },
878            AccountVaultUpdate {
879                block_num: BlockNumber::from(2u32),
880                asset: None,
881                vault_key: key,
882            },
883        ];
884
885        let delta = replay_vault_updates(&vault, &updates).unwrap();
886        assert!(delta.is_empty());
887    }
888
889    // COMPUTE ACCOUNT DELTA
890    // --------------------------------------------------------------------------------------------
891
892    #[test]
893    fn compute_delta_happy_path_emits_nonce_delta() {
894        let local_header = header_with_nonce(1);
895        let local_storage = AccountStorage::new(vec![]).unwrap();
896        let local_vault = AssetVault::new(&[]).unwrap();
897        let payload = empty_payload(header_with_nonce(4));
898
899        let delta = payload
900            .compute_account_delta(&local_header, &local_storage, &local_vault)
901            .unwrap();
902
903        assert_eq!(delta.nonce_delta(), Felt::new_unchecked(3));
904        assert!(delta.storage().is_empty());
905        assert!(delta.vault().is_empty());
906    }
907
908    #[test]
909    fn compute_delta_rejects_equal_nonce() {
910        let local_header = header_with_nonce(5);
911        let local_storage = AccountStorage::new(vec![]).unwrap();
912        let local_vault = AssetVault::new(&[]).unwrap();
913        let payload = empty_payload(header_with_nonce(5));
914
915        let err = payload
916            .compute_account_delta(&local_header, &local_storage, &local_vault)
917            .unwrap_err();
918
919        assert!(matches!(
920            err,
921            AccountDeltaError::AccountDeltaApplicationFailed {
922                source: AccountError::Other { .. },
923                ..
924            }
925        ));
926    }
927
928    #[test]
929    fn compute_delta_rejects_decreasing_nonce() {
930        let local_header = header_with_nonce(10);
931        let local_storage = AccountStorage::new(vec![]).unwrap();
932        let local_vault = AssetVault::new(&[]).unwrap();
933        let payload = empty_payload(header_with_nonce(9));
934
935        let err = payload
936            .compute_account_delta(&local_header, &local_storage, &local_vault)
937            .unwrap_err();
938
939        assert!(matches!(
940            err,
941            AccountDeltaError::AccountDeltaApplicationFailed {
942                source: AccountError::Other { .. },
943                ..
944            }
945        ));
946    }
947}