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