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}