Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- Added `create_mint_note` and `create_burn_note` helper functions for creating standardized MINT and BURN notes ([#2061](https://github.com/0xMiden/miden-base/pull/2061)).
- Skip value slot normalization for new account's deltas ([#2075](https://github.com/0xMiden/miden-base/pull/2075)).
- Skip value and map slot normalization for new account's deltas ([#2075](https://github.com/0xMiden/miden-base/pull/2075), [#2077](https://github.com/0xMiden/miden-base/pull/2077)).

## 0.12.1 (2025-11-06)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,11 @@ proc.update_map_slot_delta.4
loc_load.3 neq.0
# => [is_num_changed_entries_non_zero, RATE, RATE, PERM]

# if the account is new (nonce == 0) include the map header even if it is an empty map
# in order to have the delta commit to this initial storage slot.
exec.memory::get_init_nonce eq.0 or
# => [should_include_map_header, RATE, RATE, PERM]

if.true
# drop the previous RATE elements
dropw dropw
Expand Down
2 changes: 1 addition & 1 deletion crates/miden-lib/src/transaction/kernel_procedures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ pub const KERNEL_PROCEDURES: [Word; 52] = [
// account_has_non_fungible_asset
word!("0xfaad11de0c026551df15231790c2364cc598e891444bf826da01b524b1a8ca8f"),
// account_compute_delta_commitment
word!("0x42269a8b9160ae865b8da796ef02c8730ee857f95b219c1398dcc3ec17010f13"),
word!("0xbb3ff91c5791cf0062df00954ebea8d97a79084d053e7c3a4555391faebdc819"),
// account_get_num_procedures
word!("0x53b5ec38b7841948762c258010e6e07ad93963bcaac2d83813f8edb6710dc720"),
// account_get_procedure_root
Expand Down
56 changes: 34 additions & 22 deletions crates/miden-objects/src/account/delta/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ impl AccountDelta {

/// Computes the commitment to the account delta.
///
/// # Computation
/// ## Computation
///
/// The delta is a sequential hash over a vector of field elements which starts out empty and
/// is appended to in the following way. Whenever sorting is expected, it is that of a
Expand Down Expand Up @@ -213,19 +213,27 @@ impl AccountDelta {
/// - For each key-value pair, sorted by key, whose new value is different from the previous
/// value in the map:
/// - Append `[KEY, NEW_VALUE]`.
/// - Append `[[domain = 3, slot_idx, num_changed_entries, 0], 0, 0, 0, 0]`, except if
/// `num_changed_entries` is 0, where slot_idx is the index of the slot and
/// `num_changed_entries` is the number of changed key-value pairs in the map.
/// - Append `[[domain = 3, slot_idx, num_changed_entries, 0], 0, 0, 0, 0]`.
/// - For partial state deltas, the map header must only be included if
/// `num_changed_entries` is not zero.
/// - For full state deltas, the map header must always be included.
Comment on lines +216 to +219
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't remember whether full state deltas ALWAYS implies the account is new? Or can we have full state delta for an existing account, too?
I'm bringing this up because there's a small inconsistency in the naming for this fix vs. for #2075.
Here, we make special adjustments "for full state deltas" (always include the map header, analogous to always including the value entry even if empty).
In #2075, we make special adjustments "for new accounts".

So if full state deltas =ALWAYS= new account, then maybe we could refer to new account vs. existing accounts here, too?
lmk if this makes sense or whether I missed the point completely :D

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, full state delta always means new account - so, we should have probably used "new account" here as well. But I also think it is fine as is because we'll rework it relatively soon with #2078.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree the terminology is inconsistent here, mostly a result of doing these PRs as "quick fixes" and in sequence instead of as one unified feature.

So if full state deltas =ALWAYS= new account, then maybe we could refer to new account vs. existing accounts here, too?

I added your comments to #2078 (comment), thanks for raising it! We could refer to new/existing accounts, but as mentioned in the issue comment, full state and partial state is more well-defined in the delta context than new/existing account, so this seems a bit better. Also open to a rename of the full / partial state notion, but can't think of anything better immediately.

///
/// # Rationale
/// ## Rationale
///
/// The rationale for this layout is that hashing in the VM should be as efficient as possible
/// and minimize the number of branches to be as efficient as possible. Every high-level section
/// in this bullet point list should add an even number of words since the hasher operates
/// on double words. In the VM, each permutation is done immediately, so adding an uneven
/// number of words in a given step will result in more difficulty in the MASM implementation.
///
/// # Security
/// ### New Accounts
///
/// The delta for new accounts (a full state delta) must commit to all the storage slots of the
/// account, even if the storage slots have a default value (e.g. the empty word for value slots
/// or an empty storage map). This ensures the full state delta commits to the exact storage
/// slots that are contained in the account.
///
/// ## Security
///
/// The general concern with the commitment is that two distinct deltas must never hash to the
/// same commitment. E.g. a commitment of a delta that changes a key-value pair in a storage
Expand All @@ -249,7 +257,7 @@ impl AccountDelta {
/// - Two distinct storage map slots use the same domain but are disambiguated due to
/// inclusion of the slot index.
///
/// **Domain Separators**
/// ### Domain Separators
///
/// As an example for ambiguity, consider these two deltas:
///
Expand All @@ -276,38 +284,42 @@ impl AccountDelta {
/// importance of placing the domain separators in the same index within each word's layout
/// which makes it easy to see that this value cannot be crafted to be the same.
///
/// **Number of Changed Entries**
/// ### Number of Changed Entries
///
/// As an example for ambiguity, consider these two deltas:
///
/// ```text
/// [
/// EMPTY_WORD, ID_AND_NONCE,
/// ID_AND_NONCE, EMPTY_WORD,
/// [/* no fungible asset delta */],
/// [[domain = 1, was_added = 1, 0, 0], NON_FUNGIBLE_ASSET],
/// [/* no storage delta */],
/// [/* no non-fungible asset delta */],
/// [domain = 3, slot_idx = 0, num_changed_entries = 0, 0, 0, 0, 0, 0]
/// [domain = 3, slot_idx = 1, num_changed_entries = 0, 0, 0, 0, 0, 0]
/// ]
/// ```
///
/// ```text
/// [
/// ID_AND_NONCE, EMPTY_WORD,
/// ID_AND_NONCE, EMPTY_WORD,
/// [/* no fungible asset delta */],
/// [/* no non-fungible asset delta */],
/// [KEY0, VALUE0],
/// [KEY1, VALUE1],
/// [domain = 3, slot_idx = 0, num_changed_entries = 2, 0, 0, 0, 0, 0]
/// [domain = 3, slot_idx = 1, num_changed_entries = 1, 0, 0, 0, 0, 0]
/// ]
/// ```
///
/// The keys and values of map slots are user-controllable so `KEY0` and `VALUE0` can be crafted
/// to match `NON_FUNGIBLE_ASSET` and its metadata. Including the header of the map slot
/// additionally hashes the map domain into the delta, but if the header was included whenever
/// _any_ value in the map has changed, it would cause ambiguity about whether `KEY0`/`VALUE0`
/// are in fact map keys or a non-fungible asset (or any asset or a value storage slot more
/// generally). Including `num_changed_entries` disambiguates this situation, by ensuring
/// that the delta commitment is different when, e.g. 1) a non-fungible asset and one key-value
/// pair have changed and 2) when two key-value pairs have changed.
/// The keys and values of map slots are user-controllable so `KEY0` and `VALUE0` could be
/// crafted to match the first map header in the first delta. So, _without_ having
/// `num_changed_entries` included in the commitment, these deltas would be ambiguous. A delta
/// with two empty maps could have the same commitment as a delta with one map entry where one
/// key-value pair has changed.
///
/// #### New Accounts
///
/// The number of changed entries of a storage map can be validly zero when an empty storage map
/// is added to a new account. In such cases, the number of changed key-value pairs is 0, but
/// the map must still be committed to, in order to differentiate between a slot being an empty
/// map or not being present at all.
pub fn to_commitment(&self) -> Word {
<Self as SequentialCommit>::to_commitment(self)
}
Expand Down
11 changes: 7 additions & 4 deletions crates/miden-objects/src/account/delta/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ impl AccountStorageDelta {
self.maps.entry(slot_index).or_default().insert(key, new_value);
}

/// Inserts an empty storage map delta for the provided slot index.
///
/// This is useful for full state deltas to represent an empty map in the delta.
pub fn insert_empty_map_delta(&mut self, slot_index: u8) {
self.maps.entry(slot_index).or_default();
}
Comment on lines +101 to +103
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the logic of this method align with its name?
If we want to insert an empty map, should we always use default(), rather than first trying to access by slot index?


/// Merges another delta into this one, overwriting any existing values.
pub fn merge(&mut self, other: Self) -> Result<(), AccountDeltaError> {
self.values.extend(other.values);
Expand Down Expand Up @@ -162,10 +169,6 @@ impl AccountStorageDelta {
},
None => {
if let Some(map_delta) = self.maps().get(&slot_idx) {
if map_delta.is_empty() {
continue;
}

for (key, value) in map_delta.entries() {
elements.extend_from_slice(key.inner().as_elements());
elements.extend_from_slice(value.as_elements());
Expand Down
31 changes: 31 additions & 0 deletions crates/miden-testing/src/kernel_tests/tx/test_account_delta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,37 @@ async fn delta_for_new_account_retains_empty_value_storage_slots() -> anyhow::Re
Ok(())
}

/// Tests that creating a new account with a slot whose map is empty is correctly included in the
/// delta.
#[tokio::test]
async fn delta_for_new_account_retains_empty_map_storage_slots() -> anyhow::Result<()> {
let mut account = AccountBuilder::new(rand::random())
.account_type(AccountType::RegularAccountUpdatableCode)
.storage_mode(AccountStorageMode::Network)
.with_component(MockAccountComponent::with_slots(vec![StorageSlot::empty_map()]))
.with_auth_component(Auth::IncrNonce)
.build()?;

let tx = TransactionContextBuilder::new(account.clone()).build()?.execute().await?;

let proven_tx = LocalTransactionProver::default().prove_dummy(tx.clone())?;

let AccountUpdateDetails::Delta(delta) = proven_tx.account_update().details() else {
panic!("expected delta");
};

assert_eq!(delta.storage().maps().len(), 1);
assert!(delta.storage().maps().get(&0).unwrap().is_empty());

let recreated_account = Account::try_from(delta)?;
// The recreated account should match the original account with the nonce incremented (and the
// seed removed).
account.increment_nonce(Felt::ONE)?;
assert_eq!(recreated_account, account);

Ok(())
}

/// Tests that adding a fungible asset with amount zero to the account vault works and does not
/// result in an account delta entry.
#[tokio::test]
Expand Down
10 changes: 7 additions & 3 deletions crates/miden-tx/src/host/storage_delta_tracker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ impl StorageDeltaTracker {
.find(|map| map.root() == *value)
.expect("storage map should be present in partial storage");

// Make sure each map is represented by at least an empty storage map delta.
storage_delta_tracker.delta.insert_empty_map_delta(slot_idx);

storage_map.entries().for_each(|(key, value)| {
storage_delta_tracker.set_map_item(
slot_idx,
Expand Down Expand Up @@ -153,7 +156,7 @@ impl StorageDeltaTracker {
// On the key-value level: Keep only the key-value pairs whose new value is different from
// the initial value.
// On the map level: Keep only the maps that are non-empty after its key-value pairs have
// been normalized.
// been normalized, or if the account is new.
map_slots.retain(|slot_idx, map_delta| {
let init_map = init_maps.get(slot_idx);

Expand All @@ -166,8 +169,9 @@ impl StorageDeltaTracker {
});
}

// Only retain the map delta if it still contains values after normalization.
!map_delta.is_empty()
// Only retain the map delta if the account is new or if it still contains values after
// normalization.
self.is_account_new || !map_delta.is_empty()
});

AccountStorageDelta::from_parts(value_slots, map_slots)
Expand Down