Skip to main content

miden_protocol/account/patch/
mod.rs

1mod vault;
2
3mod storage;
4mod update_details;
5use alloc::string::ToString;
6use alloc::vec::Vec;
7
8pub use storage::{
9    AccountStoragePatch,
10    StorageMapPatch,
11    StorageMapPatchEntries,
12    StoragePatchOperation,
13    StorageSlotPatch,
14    StorageValuePatch,
15};
16pub use update_details::AccountUpdateDetails;
17pub use vault::AccountVaultPatch;
18
19use crate::account::{Account, AccountCode, AccountId, AccountStorage};
20use crate::asset::AssetVault;
21use crate::crypto::SequentialCommit;
22use crate::errors::{AccountError, AccountPatchError};
23use crate::utils::serde::{
24    ByteReader,
25    ByteWriter,
26    Deserializable,
27    DeserializationError,
28    Serializable,
29};
30use crate::{Felt, Word};
31
32/// An [`AccountPatch`] describes the new absolute state of an account after one or more
33/// transactions, in contrast to an [`AccountDelta`](crate::account::AccountDelta), which describes
34/// the relative change.
35///
36/// For example, where a delta might say "remove 50 USDC from the vault", a patch says "the new
37/// USDC balance is 100". This means a patch can be applied to compute the new account state
38/// without loading the previous state and without invoking any custom asset compose logic (e.g.
39/// merge/split procedures defined by the issuing faucet).
40///
41/// ## Full and Partial State Patches
42///
43/// The presence of the code in a patch signals if the patch is a _full state_ or _partial state_
44/// patch. A full state patch must be converted into an [`Account`] object, while a partial state
45/// patch must be applied to an existing [`Account`]. Because a full state patch reconstructs the
46/// account from empty storage, its storage patch may only create slots, never update or remove
47/// them; [`AccountPatch::new`] enforces this. A full state patch can only be the base of a
48/// [`merge`](AccountPatch::merge), never the incoming patch (see its docs for the permutation
49/// rules).
50///
51/// The patch represents updates to the account as follows:
52/// - storage: an [`AccountStoragePatch`] containing the new values of changed storage slots and map
53///   entries. Storage updates are already absolute per changed entry, so no dedicated patch type is
54///   required for storage.
55/// - vault: an [`AccountVaultPatch`] containing the new values of changed vault entries.
56/// - nonce: the new (absolute) nonce of the account, in contrast to
57///   [`AccountDelta::nonce_delta`](crate::account::AccountDelta::nonce_delta) which stores the
58///   increment.
59/// - code: an [`AccountCode`] for new accounts and `None` for others, with the same semantics as in
60///   [`AccountDelta`](crate::account::AccountDelta).
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct AccountPatch {
63    /// The ID of the account to which this patch applies.
64    account_id: AccountId,
65    /// The new values of changed storage slots and map entries.
66    storage: AccountStoragePatch,
67    /// The new values of changed vault entries.
68    vault: AccountVaultPatch,
69    /// The code of a new account (`Some`) or `None` for existing accounts.
70    code: Option<AccountCode>,
71    /// The new (absolute) nonce of the account.
72    ///
73    /// Should be set to `None` if the nonce wasn't updated.
74    final_nonce: Option<Felt>,
75}
76
77impl AccountPatch {
78    // CONSTANTS
79    // --------------------------------------------------------------------------------------------
80
81    /// Domain separator for the account patch commitment header.
82    const DOMAIN: Felt = Felt::new_unchecked(2);
83
84    // CONSTRUCTOR
85    // --------------------------------------------------------------------------------------------
86
87    /// Returns a new [`AccountPatch`] instantiated from the provided components.
88    ///
89    /// `final_nonce` must be `Some(non_zero_nonce)` if `storage` or `vault` contain any updates,
90    /// and can be `None` only for empty patches.
91    ///
92    /// # Errors
93    ///
94    /// Returns an error if:
95    /// - `final_nonce` is `Some(Felt::ZERO)`. The tx kernel guarantees that an updated nonce is at
96    ///   least one, so a zero nonce is never a valid post-tx-state. Empty patches must be
97    ///   constructed with `None` instead.
98    /// - `storage` or `vault` contain updates or code is present but `final_nonce` is `None`. The
99    ///   tx kernel mandates that the nonce is incremented whenever account state changes.
100    /// - `final_nonce` is 1 but `code` is not `Some`. Such a patch describes a new account and
101    ///   should be convertible into a full [`Account`], so account code is required.
102    pub fn new(
103        account_id: AccountId,
104        storage: AccountStoragePatch,
105        vault: AccountVaultPatch,
106        code: Option<AccountCode>,
107        final_nonce: Option<Felt>,
108    ) -> Result<Self, AccountPatchError> {
109        // New nonce should never be zero as the tx kernel requires that the nonce must be
110        // incremented to at least 1 in the account-creating transaction.
111        // Patches that do not change the account (and the nonce) should pass `None`.
112        if final_nonce.is_some_and(|final_nonce| final_nonce == Felt::ZERO) {
113            return Err(AccountPatchError::FinalNonceIsZero);
114        }
115
116        // If account storage or vault were updated or code is present, the patch represents a state
117        // change and so the nonce cannot be zero. The tx kernel mandates this (except it does not
118        // consider code yet).
119        if (!storage.is_empty() || !vault.is_empty() || code.is_some()) && final_nonce.is_none() {
120            return Err(AccountPatchError::StateChangeRequiresNonceUpdate);
121        }
122
123        // Code must be provided for new accounts to be able to reconstruct the full Account.
124        // New accounts are defined with nonce 0, but here we have the post-creation
125        // final nonce, so we define new accounts as having final_nonce = 1.
126        if final_nonce.is_some_and(|final_nonce| final_nonce == Felt::ONE) && code.is_none() {
127            return Err(AccountPatchError::CodeMustBeProvidedForNewAccounts);
128        }
129
130        // A full state patch (carrying code) must reconstruct the account from empty storage, so it
131        // may only create slots. An `Update` or `Remove` assumes the slot already exists and would
132        // make reconstruction impossible.
133        //
134        // It is not required that the vault patch contains no remove operations, since valid
135        // patches could be merged that add and remove an asset and so even a full state
136        // patch can validly end up with remove operations.
137        if code.is_some() && storage.contains_non_create_ops() {
138            return Err(AccountPatchError::FullStatePatchContainsNonCreateStorageOp);
139        }
140
141        Ok(Self {
142            account_id,
143            storage,
144            vault,
145            code,
146            final_nonce,
147        })
148    }
149
150    /// Returns an empty patch for the provided account ID.
151    pub fn empty(account_id: AccountId) -> Self {
152        AccountPatch::new(
153            account_id,
154            AccountStoragePatch::default(),
155            AccountVaultPatch::default(),
156            None,
157            None,
158        )
159        .expect("empty patch should be valid")
160    }
161
162    // PUBLIC MUTATORS
163    // --------------------------------------------------------------------------------------------
164
165    /// Merges the `other` [`AccountPatch`] into this one with patch semantics: entries present in
166    /// `other` overwrite their counterparts in `self`, and `other.final_nonce`, if present,
167    /// becomes the new final nonce.
168    ///
169    /// Both patches must apply to the same account, and `other.final_nonce` must be exactly one
170    /// greater than `self.final_nonce` whenever both are set. The exact `+1` requirement reflects
171    /// the tx kernel invariants that (a) a state-changing transaction must increment the nonce,
172    /// and (b) the nonce can be incremented at most once per transaction. As a consequence
173    /// the patch of the next transaction always lands at `self.final_nonce + 1`. The same nonce in
174    /// both patches represents a fork and a nonce delta larger than 1 means a missed transaction.
175    ///
176    /// ## Full and Partial State
177    ///
178    /// The patches' full/partial state determines whether the merge is allowed. In short, the
179    /// incoming patch (`other`) must never be a full state patch. In more detail:
180    /// - `full_state + partial_state`: allowed. The full state (account-creation) patch is the base
181    ///   and later partial patches layer on top of it.
182    /// - `partial_state + partial_state`: allowed. Both are incremental updates.
183    /// - `partial_state + full_state`: disallowed. A full state patch describes the account's
184    ///   initial state, so it cannot follow an earlier (partial) patch.
185    /// - `full_state + full_state`: disallowed. An account is created once, so two creation patches
186    ///   cannot both apply.
187    ///
188    /// Empty patches are neutral and handled before this rule: merging into an empty `self` adopts
189    /// `other`, and merging an empty `other` is a no-op.
190    ///
191    /// # Errors
192    ///
193    /// Returns an error if:
194    /// - the two patches apply to different accounts.
195    /// - both patches carry a final nonce and the nonce in `other` is not exactly one greater than
196    ///   the nonce in `self`.
197    /// - the incoming patch (`other`) is a full state patch (see permutations above).
198    /// - a storage slot is used as different slot types in the two patches.
199    pub fn merge(&mut self, other: Self) -> Result<(), AccountPatchError> {
200        if self.account_id != other.account_id {
201            return Err(AccountPatchError::AccountIdMismatch {
202                expected: self.account_id,
203                actual: other.account_id,
204            });
205        }
206
207        match (self.final_nonce, other.final_nonce) {
208            // Both patches are empty, nothing to merge.
209            (None, None) => return Ok(()),
210
211            // `self` is empty, so `other` becomes the merged result.
212            (None, Some(_)) => {
213                *self = other;
214                return Ok(());
215            },
216
217            // `other` is empty, nothing to merge.
218            (Some(_), None) => return Ok(()),
219
220            (Some(current), Some(new)) => {
221                if new != current + Felt::ONE {
222                    return Err(AccountPatchError::NonceMustIncrementByOne { current, new });
223                }
224                self.final_nonce = Some(new);
225            },
226        }
227
228        // A full state patch describes account creation and can only be the merge base (`self`),
229        // never the incoming patch.
230        if other.is_full_state() {
231            return Err(AccountPatchError::MergeIncomingFullStatePatch);
232        }
233
234        self.storage.merge(other.storage)?;
235        self.vault.merge(other.vault);
236
237        // A full state `self` contains all of its slots as `Create`, so merging a partial patch
238        // only ever updates a created slot (staying `Create`) or removes it (dropping the patch
239        // entirely), preserving the invariant that full state patches contain only `Create`s.
240        // Check that we have either a partial patch or only storage creates.
241        debug_assert!(
242            !self.is_full_state() || !self.storage.contains_non_create_ops(),
243            "merging should never add storage updates or removals to a full state patch",
244        );
245
246        Ok(())
247    }
248
249    // PUBLIC ACCESSORS
250    // --------------------------------------------------------------------------------------------
251
252    /// Returns the account ID to which this patch applies.
253    pub fn id(&self) -> AccountId {
254        self.account_id
255    }
256
257    /// Returns the storage updates of this patch.
258    pub fn storage(&self) -> &AccountStoragePatch {
259        &self.storage
260    }
261
262    /// Returns the vault updates of this patch.
263    pub fn vault(&self) -> &AccountVaultPatch {
264        &self.vault
265    }
266
267    /// Returns a reference to the account code of this patch, if present.
268    pub fn code(&self) -> Option<&AccountCode> {
269        self.code.as_ref()
270    }
271
272    /// Returns the new (absolute) nonce of the account after this patch is applied, or `None` if
273    /// the nonce wasn't updated.
274    pub fn final_nonce(&self) -> Option<Felt> {
275        self.final_nonce
276    }
277
278    /// Returns `true` if this patch is a "full state" patch, `false` otherwise, i.e. if it is a
279    /// "partial state" patch.
280    ///
281    /// See the type-level docs for more on this distinction.
282    pub fn is_full_state(&self) -> bool {
283        // TODO(code_upgrades): Change this to another detection mechanism once we have code upgrade
284        // support, at which point the presence of code may not be enough of an indication that a
285        // patch can be converted to a full account.
286        //
287        // The presence of code alone is sufficient to identify a full state patch: the constructor
288        // enforces that `code.is_some()` implies `final_nonce.is_some()` and that the storage patch
289        // contains only `Create` ops, and `merge` preserves both, so a code-carrying patch always
290        // reconstructs a full account.
291        self.code.is_some()
292    }
293
294    /// Returns true if this account patch does not contain any vault or storage updates and the
295    /// nonce wasn't updated.
296    pub fn is_empty(&self) -> bool {
297        // The check can be implemented by checking only the nonce, since the constructor validates
298        // that non-empty storage or vault patches must increment the nonce.
299        self.final_nonce.is_none()
300    }
301
302    /// Computes the commitment to the account patch.
303    ///
304    /// This is very similar to
305    /// [`AccountDelta::to_commitment`](crate::account::AccountDelta::to_commitment). See its docs
306    /// for the rationale, security aspects, and other details. The only differences between
307    /// these are:
308    /// - the patch includes the new nonce rather than the nonce delta.
309    /// - The patch includes the new absolute asset values ([`AccountVaultPatch`]) while the delta
310    ///   includes the relative asset changes
311    ///   ([`AccountVaultDelta`](crate::account::AccountVaultDelta)).
312    ///
313    /// ## Computation
314    ///
315    /// The patch commitment is a sequential hash over a vector of field elements which starts out
316    /// empty and is appended to in the following way. Whenever sorting is expected, it is that
317    /// of a [`Word`].
318    ///
319    /// - Append `[[domain = 2, final_nonce, account_id_suffix, account_id_prefix], EMPTY_WORD]`,
320    ///   where `account_id_{prefix,suffix}` are the prefix and suffix felts of the native account
321    ///   id, `final_nonce` is the new nonce of the account, and `domain = 2` identifies the header
322    ///   as the start of an account patch commitment (distinguishing it from a delta commitment,
323    ///   which uses `domain = 1`).
324    /// - Asset Patch
325    ///   - For each asset whose value has changed compared to the initial state of the transaction,
326    ///     including if it was removed, sorted by its asset ID:
327    ///     - Append `[ASSET_ID, ASSET_VALUE_OR_EMPTY_WORD]` which are the key and either the value
328    ///       of the asset (for updates) or the empty word (for removals).
329    ///     - Append `[[domain = 4, num_changed_assets, 0, 0], 0, 0, 0, 0]`, where
330    ///       `num_changed_assets` is the number of assets that were appended. Note that this is a
331    ///       distinct domain from the delta asset domain (`3`), so an asset delta and an asset
332    ///       patch can never produce the same commitment.
333    /// - Storage Slots are sorted by slot ID and are iterated in this order. `patch_op` is the
334    ///   [`StoragePatchOperation`](crate::account::StoragePatchOperation) of the slot patch and
335    ///   `slot_id_{suffix, prefix}` is the identifier of the slot. For each slot, depending on its
336    ///   slot type:
337    ///   - Value Slot
338    ///     - Append `[[domain = 5, patch_op, slot_id_suffix, slot_id_prefix], NEW_VALUE]` where
339    ///       `NEW_VALUE` is the new value of the slot.
340    ///   - Map Slot
341    ///     - For each key-value pair, sorted by key, whose new value is different from the previous
342    ///       value in the map:
343    ///       - Append `[KEY, NEW_VALUE]`.
344    ///     - The map trailer is constructed as `[[domain = 6, patch_op, slot_id_suffix,
345    ///       slot_id_prefix], [num_changed_entries, 0, 0, 0]]`, where `num_changed_entries` is the
346    ///       number of key-value pairs appended above. Whether the trailer is included depends on
347    ///       `patch_op`:
348    ///         - For
349    ///           [`StoragePatchOperation::Create`](crate::account::StoragePatchOperation::Create),
350    ///           the trailer is always included, since the slot's creation must be committed to even
351    ///           when the map is created empty (`num_changed_entries == 0`).
352    ///         - For
353    ///           [`StoragePatchOperation::Update`](crate::account::StoragePatchOperation::Update),
354    ///           the trailer is included only if `num_changed_entries != 0`. An update that changes
355    ///           no entries is a no-op and is omitted entirely.
356    ///         - For
357    ///           [`StoragePatchOperation::Remove`](crate::account::StoragePatchOperation::Remove),
358    ///           the trailer is always included with `num_changed_entries` set to zero, since the
359    ///           number of removed entries is unknown.
360    ///
361    /// Headers for storage map slots and asset patches are appended rather than prepended since the
362    /// tx kernel cannot efficiently get the number of changed entries before the iteration.
363    pub fn to_commitment(&self) -> Word {
364        <Self as SequentialCommit>::to_commitment(self)
365    }
366}
367
368impl TryFrom<&AccountPatch> for Account {
369    type Error = AccountError;
370
371    /// Converts an [`AccountPatch`] into an [`Account`].
372    ///
373    /// Conceptually, this applies the patch onto an empty account. Only patches that fully
374    /// describe an account (i.e. carry account code and a final nonce) can be converted; see
375    /// [`AccountPatch`] for details.
376    ///
377    /// # Errors
378    ///
379    /// Returns an error if:
380    /// - The patch does not carry account code or a final nonce.
381    /// - Applying the vault patch to an empty vault fails.
382    /// - Applying the storage patch to empty storage fails.
383    fn try_from(patch: &AccountPatch) -> Result<Self, Self::Error> {
384        if !patch.is_full_state() {
385            return Err(AccountError::PartialStatePatchToAccount);
386        }
387
388        // The constructor guarantees that a full state patch carries both code and a final nonce.
389        let code = patch.code().cloned().expect("full state patch must carry code");
390        let nonce = patch.final_nonce().expect("full state patch must carry final nonce");
391
392        let mut vault = AssetVault::default();
393        vault.apply_patch(patch.vault()).map_err(AccountError::AssetVaultUpdateError)?;
394
395        // A full state patch consists of `Create` slot patches, so applying it to empty storage
396        // reconstructs the account's full storage.
397        let mut storage = AccountStorage::default();
398        storage.apply_patch(patch.storage())?;
399
400        Account::new(patch.id(), vault, storage, code, nonce, None)
401    }
402}
403
404impl SequentialCommit for AccountPatch {
405    type Commitment = Word;
406
407    /// Reduces the patch to a sequence of field elements.
408    ///
409    /// See [AccountPatch::to_commitment()] for more details.
410    fn to_elements(&self) -> Vec<Felt> {
411        // The commitment to an empty patch is defined as the empty word.
412        if self.is_empty() {
413            return Vec::new();
414        }
415
416        // Minor optimization: At least 8 elements are always added.
417        let mut elements = Vec::with_capacity(8);
418
419        // ID and Nonce
420        let final_nonce = self.final_nonce.expect("non-empty patches should have a new nonce set");
421        elements.extend_from_slice(&[
422            Self::DOMAIN,
423            final_nonce,
424            self.account_id.suffix(),
425            self.account_id.prefix().as_felt(),
426        ]);
427        elements.extend_from_slice(Word::empty().as_elements());
428
429        // Vault patch
430        self.vault.append_patch_elements(&mut elements);
431
432        // Storage Patch
433        self.storage.append_patch_elements(&mut elements);
434
435        debug_assert!(
436            elements.len() % (2 * crate::WORD_SIZE) == 0,
437            "expected elements to contain an even number of words, but it contained {} elements",
438            elements.len()
439        );
440
441        elements
442    }
443}
444
445impl Serializable for AccountPatch {
446    fn write_into<W: ByteWriter>(&self, target: &mut W) {
447        self.account_id.write_into(target);
448        self.storage.write_into(target);
449        self.vault.write_into(target);
450        self.code.write_into(target);
451        self.final_nonce.write_into(target);
452    }
453
454    fn get_size_hint(&self) -> usize {
455        self.account_id.get_size_hint()
456            + self.storage.get_size_hint()
457            + self.vault.get_size_hint()
458            + self.code.get_size_hint()
459            + self.final_nonce.get_size_hint()
460    }
461}
462
463impl Deserializable for AccountPatch {
464    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
465        let account_id = AccountId::read_from(source)?;
466        let storage = AccountStoragePatch::read_from(source)?;
467        let vault = AccountVaultPatch::read_from(source)?;
468        let code = <Option<AccountCode>>::read_from(source)?;
469        let final_nonce = <Option<Felt>>::read_from(source)?;
470
471        Self::new(account_id, storage, vault, code, final_nonce)
472            .map_err(|err| DeserializationError::InvalidValue(err.to_string()))
473    }
474}
475
476// TESTS
477// ================================================================================================
478
479#[cfg(test)]
480mod tests {
481    use assert_matches::assert_matches;
482    use miden_core::serde::Deserializable;
483    use rstest::rstest;
484
485    use super::{AccountPatch, AccountVaultPatch};
486    use crate::account::{
487        Account,
488        AccountCode,
489        AccountId,
490        AccountStoragePatch,
491        StorageMapKey,
492        StorageMapPatch,
493        StorageSlotName,
494        StorageSlotPatch,
495        StorageValuePatch,
496    };
497    use crate::asset::{Asset, FungibleAsset, NonFungibleAsset};
498    use crate::errors::{AccountError, AccountPatchError};
499    use crate::testing::account_id::{
500        ACCOUNT_ID_PRIVATE_SENDER,
501        ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
502    };
503    use crate::utils::serde::Serializable;
504    use crate::{Felt, Word};
505
506    fn patch_id() -> AccountId {
507        AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap()
508    }
509
510    #[test]
511    fn account_patch_serde() -> anyhow::Result<()> {
512        let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER).unwrap();
513        let asset_0 = FungibleAsset::mock(100);
514        let asset_1 = FungibleAsset::new(ACCOUNT_ID_PRIVATE_SENDER.try_into()?, 500_000)?.into();
515        let asset_2 = NonFungibleAsset::mock(&[10]);
516        let asset_3 = NonFungibleAsset::mock(&[20]);
517        let vault_patch = AccountVaultPatch::with_assets([asset_0, asset_1, asset_2, asset_3]);
518
519        let storage_patch = AccountStoragePatch::from_iters(
520            [StorageSlotName::mock(1)],
521            [
522                (StorageSlotName::mock(2), Word::from([1, 1, 1, 1u32])),
523                (StorageSlotName::mock(3), Word::from([1, 1, 0, 1u32])),
524            ],
525            [(
526                StorageSlotName::mock(4),
527                StorageMapPatch::from_iters(
528                    [
529                        StorageMapKey::from_array([1, 1, 1, 0]),
530                        StorageMapKey::from_array([0, 1, 1, 1]),
531                    ],
532                    [(StorageMapKey::from_array([1, 1, 1, 1]), Word::from([1, 1, 1, 1u32]))],
533                ),
534            )],
535        );
536
537        assert_eq!(storage_patch.to_bytes().len(), storage_patch.get_size_hint());
538        assert_eq!(vault_patch.to_bytes().len(), vault_patch.get_size_hint());
539
540        let account_patch =
541            AccountPatch::new(account_id, storage_patch, vault_patch, None, Some(Felt::from(5u8)))?;
542        assert_eq!(AccountPatch::read_from_bytes(&account_patch.to_bytes())?, account_patch);
543        assert_eq!(account_patch.to_bytes().len(), account_patch.get_size_hint());
544
545        Ok(())
546    }
547
548    /// A `final_nonce` set to `Some(Felt::ZERO)` is rejected: the tx kernel guarantees the nonce of
549    /// an updated account is at least one, so empty patches must pass `None` instead.
550    #[test]
551    fn account_patch_final_nonce_is_zero() -> anyhow::Result<()> {
552        let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?;
553
554        let error = AccountPatch::new(
555            account_id,
556            AccountStoragePatch::new(),
557            AccountVaultPatch::default(),
558            None,
559            Some(Felt::ZERO),
560        )
561        .unwrap_err();
562
563        assert_matches!(error, AccountPatchError::FinalNonceIsZero);
564
565        Ok(())
566    }
567
568    /// A patch that updates storage, the vault, or carries code but leaves `final_nonce` as `None`
569    /// is rejected, since any account state change requires the nonce to be incremented.
570    #[rstest::rstest]
571    #[case::non_empty_storage(
572        AccountStoragePatch::from_iters([StorageSlotName::mock(1)], [], []),
573        AccountVaultPatch::default(),
574        None,
575    )]
576    #[case::non_empty_vault(
577        AccountStoragePatch::new(),
578        AccountVaultPatch::with_assets([FungibleAsset::mock(100)]),
579        None,
580    )]
581    #[case::present_code(
582        AccountStoragePatch::new(),
583        AccountVaultPatch::default(),
584        Some(AccountCode::mock())
585    )]
586    #[test]
587    fn account_patch_with_state_change_requires_nonce_update(
588        #[case] storage: AccountStoragePatch,
589        #[case] vault: AccountVaultPatch,
590        #[case] code: Option<AccountCode>,
591    ) -> anyhow::Result<()> {
592        let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?;
593
594        let error = AccountPatch::new(account_id, storage, vault, code, None).unwrap_err();
595        assert_matches!(error, AccountPatchError::StateChangeRequiresNonceUpdate);
596
597        Ok(())
598    }
599
600    /// A patch for a newly created account (`final_nonce = Some(Felt::ONE)`) must include the
601    /// account code, since otherwise the full account cannot be reconstructed from the patch.
602    #[test]
603    fn account_patch_new_account_requires_code() -> anyhow::Result<()> {
604        let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?;
605
606        let error = AccountPatch::new(
607            account_id,
608            AccountStoragePatch::new(),
609            AccountVaultPatch::default(),
610            None,
611            Some(Felt::ONE),
612        )
613        .unwrap_err();
614        assert_matches!(error, AccountPatchError::CodeMustBeProvidedForNewAccounts);
615
616        // With the code provided, the same patch should succeed.
617        AccountPatch::new(
618            account_id,
619            AccountStoragePatch::new(),
620            AccountVaultPatch::default(),
621            Some(AccountCode::mock()),
622            Some(Felt::ONE),
623        )?;
624
625        Ok(())
626    }
627
628    /// A patch carrying account code and a final nonce can be converted to an [`Account`] and back,
629    /// preserving all components.
630    #[test]
631    fn account_patch_roundtrip() -> anyhow::Result<()> {
632        let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?;
633        let code = AccountCode::mock();
634        let asset = FungibleAsset::mock(42);
635
636        let slot_name = StorageSlotName::mock(4);
637        let slot_value = Word::from([1, 2, 3, 4u32]);
638
639        // A full state patch is composed of `Create` slot patches.
640        let storage_patch = AccountStoragePatch::from_entries([(
641            slot_name.clone(),
642            StorageSlotPatch::Value(StorageValuePatch::Create { value: slot_value }),
643        )])?;
644
645        let patch = AccountPatch::new(
646            account_id,
647            storage_patch,
648            AccountVaultPatch::with_assets([asset]),
649            Some(code.clone()),
650            Some(Felt::ONE),
651        )?;
652
653        let account = Account::try_from(&patch)?;
654
655        assert_eq!(account.id(), account_id);
656        assert_eq!(account.code(), &code);
657        assert_eq!(account.nonce(), Felt::ONE);
658        assert_eq!(account.storage().get_item(&slot_name)?, slot_value);
659        assert_eq!(account.vault().get(asset.id()), Some(asset));
660
661        // Roundtrip back to a patch should reproduce the original.
662        let roundtripped_patch = AccountPatch::try_from(account)?;
663        assert_eq!(roundtripped_patch, patch);
664
665        Ok(())
666    }
667
668    /// A patch lacking code cannot be converted to an [`Account`], whether or not a final nonce
669    /// is present.
670    #[rstest::rstest]
671    #[case::missing_code(Some(Felt::from(2_u32)))]
672    #[case::empty_patch(None)]
673    #[test]
674    fn account_try_from_partial_patch_fails(
675        #[case] final_nonce: Option<Felt>,
676    ) -> anyhow::Result<()> {
677        let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?;
678
679        let patch = AccountPatch::new(
680            account_id,
681            AccountStoragePatch::new(),
682            AccountVaultPatch::default(),
683            None,
684            final_nonce,
685        )?;
686        assert_matches!(
687            Account::try_from(&patch).unwrap_err(),
688            AccountError::PartialStatePatchToAccount
689        );
690
691        Ok(())
692    }
693
694    /// A full state patch (carrying code) must only contain `Create` storage ops, since an `Update`
695    /// or `Remove` could not be applied to the empty storage of a new account.
696    #[rstest]
697    #[case::update(
698        AccountStoragePatch::builder().update_value(StorageSlotName::mock(1), Word::empty()).build()
699    )]
700    #[case::remove(
701        AccountStoragePatch::builder().remove_value(StorageSlotName::mock(1)).build()
702    )]
703    fn account_patch_new_rejects_full_state_with_non_create_op(
704        #[case] storage: AccountStoragePatch,
705    ) -> anyhow::Result<()> {
706        let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?;
707
708        let error = AccountPatch::new(
709            account_id,
710            storage,
711            AccountVaultPatch::default(),
712            Some(AccountCode::mock()),
713            Some(Felt::ONE),
714        )
715        .unwrap_err();
716        assert_matches!(error, AccountPatchError::FullStatePatchContainsNonCreateStorageOp);
717
718        Ok(())
719    }
720
721    /// A full state patch whose storage only creates slots can be reconstructed into an account.
722    #[test]
723    fn account_patch_full_state_with_create_reconstructs() -> anyhow::Result<()> {
724        let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?;
725        let code = AccountCode::mock();
726        let created_slot = StorageSlotName::mock(1);
727        let created_value = Word::from([7u32, 0, 0, 0]);
728
729        let storage = AccountStoragePatch::builder()
730            .create_value(created_slot.clone(), created_value)
731            .build();
732
733        let patch = AccountPatch::new(
734            account_id,
735            storage,
736            AccountVaultPatch::default(),
737            Some(code.clone()),
738            Some(Felt::ONE),
739        )?;
740
741        assert!(patch.is_full_state());
742
743        let account = Account::try_from(&patch)?;
744        assert_eq!(account.code(), &code);
745        assert_eq!(account.storage().get_item(&created_slot)?, created_value);
746
747        Ok(())
748    }
749
750    // MERGE TESTS
751    // ============================================================================================
752
753    /// Returns a full-state patch with a single created value slot and the provided final
754    /// nonce.
755    fn full_patch(account_id: AccountId, final_nonce: u32) -> anyhow::Result<AccountPatch> {
756        let storage_patch = AccountStoragePatch::builder()
757            .create_value(StorageSlotName::mock(1), Word::from([1u32, 0, 0, 0]))
758            .build();
759
760        AccountPatch::new(
761            account_id,
762            storage_patch,
763            AccountVaultPatch::default(),
764            Some(AccountCode::mock()),
765            Some(Felt::from(final_nonce)),
766        )
767        .map_err(Into::into)
768    }
769
770    /// Returns a partial-state patch with a single updated value slot and the provided final
771    /// nonce.
772    fn partial_patch(account_id: AccountId, final_nonce: u32) -> anyhow::Result<AccountPatch> {
773        let storage = AccountStoragePatch::from_iters(
774            [],
775            [(StorageSlotName::mock(1), Word::from([1u32, 0, 0, 0]))],
776            [],
777        );
778        AccountPatch::new(
779            account_id,
780            storage,
781            AccountVaultPatch::default(),
782            None,
783            Some(Felt::from(final_nonce)),
784        )
785        .map_err(Into::into)
786    }
787
788    #[test]
789    fn account_patch_merge_rejects_id_mismatch() -> anyhow::Result<()> {
790        let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?;
791        let other_account_id =
792            AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE)?;
793
794        let mut patch = partial_patch(account_id, 2)?;
795        let other = partial_patch(other_account_id, 3)?;
796
797        assert_matches!(
798            patch.merge(other).unwrap_err(),
799            AccountPatchError::AccountIdMismatch { expected, actual } => {
800                assert_eq!(expected, account_id);
801                assert_eq!(actual, other_account_id);
802            }
803        );
804
805        Ok(())
806    }
807
808    /// A full state patch describes account creation and can only be the merge base, never the
809    /// incoming patch, so merging it into a partial or full patch is rejected.
810    #[rstest]
811    #[case::partial_state_plus_full_state(
812        partial_patch(patch_id(), 3)?
813    )]
814    #[case::full_state_plus_full_state(
815        full_patch(patch_id(), 3)?
816    )]
817    fn account_patch_merge_rejects_incoming_full_state(
818        #[case] mut patch: AccountPatch,
819    ) -> anyhow::Result<()> {
820        let other = full_patch(patch_id(), 4)?;
821        assert_matches!(
822            patch.merge(other).unwrap_err(),
823            AccountPatchError::MergeIncomingFullStatePatch
824        );
825
826        Ok(())
827    }
828
829    #[rstest::rstest]
830    #[case::equal(3, 3)]
831    #[case::smaller(3, 2)]
832    #[case::gap(3, 5)]
833    fn account_patch_merge_rejects_non_incrementing_nonce(
834        #[case] self_nonce: u32,
835        #[case] other_nonce: u32,
836    ) -> anyhow::Result<()> {
837        let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?;
838        let mut patch = partial_patch(account_id, self_nonce)?;
839        let other = partial_patch(account_id, other_nonce)?;
840
841        assert_matches!(
842            patch.merge(other).unwrap_err(),
843            AccountPatchError::NonceMustIncrementByOne { current, new } => {
844                assert_eq!(current, Felt::from(self_nonce));
845                assert_eq!(new, Felt::from(other_nonce));
846            }
847        );
848
849        Ok(())
850    }
851
852    #[test]
853    fn account_patch_merge_rejects_storage_slot_type_conflict() -> anyhow::Result<()> {
854        let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?;
855        let shared_slot = StorageSlotName::mock(7);
856
857        let value_storage = AccountStoragePatch::from_iters(
858            [],
859            [(shared_slot.clone(), Word::from([9u32, 0, 0, 0]))],
860            [],
861        );
862        let map_storage = AccountStoragePatch::from_iters(
863            [],
864            [],
865            [(shared_slot.clone(), StorageMapPatch::from_iters([], []))],
866        );
867
868        let mut patch = AccountPatch::new(
869            account_id,
870            value_storage,
871            AccountVaultPatch::default(),
872            None,
873            Some(Felt::from(2u32)),
874        )?;
875        let other = AccountPatch::new(
876            account_id,
877            map_storage,
878            AccountVaultPatch::default(),
879            None,
880            Some(Felt::from(3u32)),
881        )?;
882
883        assert_matches!(
884            patch.merge(other).unwrap_err(),
885            AccountPatchError::StorageSlotUsedAsDifferentTypes(slot) => {
886                assert_eq!(slot, shared_slot);
887            }
888        );
889
890        Ok(())
891    }
892
893    #[test]
894    fn account_patch_merge_overrides_vault_entry() -> anyhow::Result<()> {
895        let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?;
896        let asset_initial: Asset = FungibleAsset::mock(100);
897        let asset_updated: Asset = FungibleAsset::mock(250);
898        assert_eq!(asset_initial.id(), asset_updated.id());
899
900        let mut patch = AccountPatch::new(
901            account_id,
902            AccountStoragePatch::new(),
903            AccountVaultPatch::with_assets([asset_initial]),
904            None,
905            Some(Felt::from(2u32)),
906        )?;
907        let other = AccountPatch::new(
908            account_id,
909            AccountStoragePatch::new(),
910            AccountVaultPatch::with_assets([asset_updated]),
911            None,
912            Some(Felt::from(3u32)),
913        )?;
914
915        patch.merge(other)?;
916
917        assert_eq!(patch.vault().num_assets(), 1);
918        assert_eq!(
919            patch.vault().as_map().get(&asset_updated.id()).copied(),
920            Some(asset_updated.to_value_word())
921        );
922
923        Ok(())
924    }
925
926    #[test]
927    fn account_patch_merge_overrides_storage_value() -> anyhow::Result<()> {
928        let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?;
929        let slot_name = StorageSlotName::mock(1);
930        let initial_value = Word::from([1u32, 0, 0, 0]);
931        let updated_value = Word::from([2u32, 0, 0, 0]);
932
933        let mut patch = AccountPatch::new(
934            account_id,
935            AccountStoragePatch::from_iters([], [(slot_name.clone(), initial_value)], []),
936            AccountVaultPatch::default(),
937            None,
938            Some(Felt::from(2u32)),
939        )?;
940        let other = AccountPatch::new(
941            account_id,
942            AccountStoragePatch::from_iters([], [(slot_name.clone(), updated_value)], []),
943            AccountVaultPatch::default(),
944            None,
945            Some(Felt::from(3u32)),
946        )?;
947
948        patch.merge(other)?;
949
950        assert_eq!(patch.storage().num_slots(), 1);
951        assert_eq!(patch.storage().updated_value(&slot_name), Some(updated_value));
952
953        Ok(())
954    }
955
956    #[test]
957    fn account_patch_merge_extends_storage_map() -> anyhow::Result<()> {
958        let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?;
959        let map_slot = StorageSlotName::mock(1);
960        let key_self = StorageMapKey::from_array([1, 0, 0, 0]);
961        let value_self = Word::from([10u32, 0, 0, 0]);
962        let key_other = StorageMapKey::from_array([2, 0, 0, 0]);
963        let value_other = Word::from([20u32, 0, 0, 0]);
964
965        let mut patch = AccountPatch::new(
966            account_id,
967            AccountStoragePatch::from_iters(
968                [],
969                [],
970                [(map_slot.clone(), StorageMapPatch::from_iters([], [(key_self, value_self)]))],
971            ),
972            AccountVaultPatch::default(),
973            None,
974            Some(Felt::from(2u32)),
975        )?;
976        let other = AccountPatch::new(
977            account_id,
978            AccountStoragePatch::from_iters(
979                [],
980                [],
981                [(map_slot.clone(), StorageMapPatch::from_iters([], [(key_other, value_other)]))],
982            ),
983            AccountVaultPatch::default(),
984            None,
985            Some(Felt::from(3u32)),
986        )?;
987
988        patch.merge(other)?;
989
990        assert_eq!(patch.storage().num_slots(), 1);
991        assert_eq!(patch.storage().updated_map(&map_slot).unwrap().num_entries(), 2);
992        assert_eq!(patch.storage().updated_map_item(&map_slot, &key_self), Some(value_self));
993        assert_eq!(patch.storage().updated_map_item(&map_slot, &key_other), Some(value_other));
994
995        Ok(())
996    }
997
998    /// A full state patch as the merge base, with a partial patch updating one of its created
999    /// slots, stays a full state patch carrying only `Create` ops.
1000    #[test]
1001    fn account_patch_merge_full_base_with_partial_stays_full_state() -> anyhow::Result<()> {
1002        let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?;
1003        let code = AccountCode::mock();
1004        let slot_name = StorageSlotName::mock(1);
1005        let created_value = Word::from([1u32, 0, 0, 0]);
1006        let updated_value = Word::from([2u32, 0, 0, 0]);
1007
1008        // Full state base: a created value slot, code and the account-creation nonce of 1.
1009        let mut patch = AccountPatch::new(
1010            account_id,
1011            AccountStoragePatch::builder()
1012                .create_value(slot_name.clone(), created_value)
1013                .build(),
1014            AccountVaultPatch::default(),
1015            Some(code.clone()),
1016            Some(Felt::ONE),
1017        )?;
1018
1019        // Partial patch updating the same slot in the next transaction.
1020        let other = AccountPatch::new(
1021            account_id,
1022            AccountStoragePatch::builder()
1023                .update_value(slot_name.clone(), updated_value)
1024                .build(),
1025            AccountVaultPatch::default(),
1026            None,
1027            Some(Felt::from(2u32)),
1028        )?;
1029
1030        patch.merge(other)?;
1031
1032        assert!(patch.is_full_state());
1033        assert_eq!(patch.code(), Some(&code));
1034        assert_eq!(patch.final_nonce(), Some(Felt::from(2u32)));
1035        assert!(!patch.storage().contains_non_create_ops());
1036        assert_eq!(patch.storage().created_value(&slot_name), Some(updated_value));
1037
1038        Ok(())
1039    }
1040
1041    /// A + B_empty = A
1042    #[test]
1043    fn account_patch_merge_empty_other_is_noop() -> anyhow::Result<()> {
1044        let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?;
1045        let mut patch = partial_patch(account_id, 4)?;
1046        let snapshot = patch.clone();
1047
1048        let empty = AccountPatch::empty(account_id);
1049
1050        patch.merge(empty)?;
1051        assert_eq!(patch, snapshot);
1052
1053        Ok(())
1054    }
1055
1056    /// A_empty + B = B
1057    #[test]
1058    fn account_patch_merge_empty_self_adopts_other() -> anyhow::Result<()> {
1059        let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?;
1060        let mut empty = AccountPatch::empty(account_id);
1061        let other = partial_patch(account_id, 7)?;
1062        let expected = other.clone();
1063
1064        empty.merge(other)?;
1065        assert_eq!(empty, expected);
1066
1067        Ok(())
1068    }
1069}