diff --git a/CHANGELOG.md b/CHANGELOG.md index 0594f2f3f9..46be84c4e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ - Added `MockChain::add_pending_batch()` to allow submitting user batches directly ([#2565](https://github.com/0xMiden/protocol/pull/2565)). - Added `create_fungible_key` for construction of fungible asset keys ([#2575](https://github.com/0xMiden/protocol/pull/2575)). - Implemented the `on_before_asset_added_to_account` asset callback ([#2571](https://github.com/0xMiden/protocol/pull/2571)). +- Implemented the `on_before_asset_added_to_note` asset callback ([#2595](https://github.com/0xMiden/protocol/pull/2595)). - Added `InputNoteCommitment::from_parts()` for construction of input note commitments from a nullifier and optional note header ([#2588](https://github.com/0xMiden/protocol/pull/2588)). - Added `bool` schema type to the type registry and updated ACL auth component to use it for boolean config fields ([#2591](https://github.com/0xMiden/protocol/pull/2591)). - Added `component_metadata()` to all account components to expose their metadata ([#2596](https://github.com/0xMiden/protocol/pull/2596)). diff --git a/crates/miden-protocol/asm/kernels/transaction/api.masm b/crates/miden-protocol/asm/kernels/transaction/api.masm index 19c75a36c9..0ee3b5d49a 100644 --- a/crates/miden-protocol/asm/kernels/transaction/api.masm +++ b/crates/miden-protocol/asm/kernels/transaction/api.masm @@ -1126,9 +1126,6 @@ pub proc output_note_add_asset exec.memory::assert_native_account # => [ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] - movup.8 - # => [note_idx, ASSET_KEY, ASSET_VALUE, pad(7)] - exec.output_note::add_asset # => [pad(16)] end diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/account.masm b/crates/miden-protocol/asm/kernels/transaction/lib/account.masm index a14c436e33..d0f405de0c 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/account.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/account.masm @@ -3,6 +3,7 @@ use $kernel::account_id use $kernel::asset_vault use $kernel::callbacks use $kernel::callbacks::ON_BEFORE_ASSET_ADDED_TO_ACCOUNT_PROC_ROOT_SLOT +use $kernel::callbacks::ON_BEFORE_ASSET_ADDED_TO_NOTE_PROC_ROOT_SLOT use $kernel::constants::ACCOUNT_PROCEDURE_DATA_LENGTH use $kernel::constants::EMPTY_SMT_ROOT use $kernel::constants::STORAGE_SLOT_TYPE_MAP @@ -2064,6 +2065,14 @@ pub proc has_callbacks # check if the on_before_asset_added_to_account callback slot exists and is non-empty push.ON_BEFORE_ASSET_ADDED_TO_ACCOUNT_PROC_ROOT_SLOT[0..2] exec.has_non_empty_slot + # => [has_account_callback] + + # check if the on_before_asset_added_to_note callback slot exists and is non-empty + push.ON_BEFORE_ASSET_ADDED_TO_NOTE_PROC_ROOT_SLOT[0..2] + exec.has_non_empty_slot + # => [has_note_callback, has_account_callback] + + or # => [has_callbacks] end diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/account_delta.masm b/crates/miden-protocol/asm/kernels/transaction/lib/account_delta.masm index f1a02531af..b2b44e81c3 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/account_delta.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/account_delta.masm @@ -428,19 +428,19 @@ proc update_non_fungible_asset_delta # convert was_added to a boolean # was_added is 1 if the asset was added and 0 - 1 if it was removed eq.1 - # => [[was_added, 0, 0, 0], [asset_id_suffix, asset_id_prefix, faucet_id_suffix, faucet_id_prefix], ASSET_VALUE, ...] + # => [[was_added, 0, 0, 0], [asset_id_suffix, asset_id_prefix, faucet_id_suffix_and_metadata, faucet_id_prefix], ASSET_VALUE, ...] # replace asset_id_prefix with was_added and drop the remaining word swap.5 dropw - # => [[asset_id_suffix, was_added, faucet_id_suffix, faucet_id_prefix], ASSET_VALUE, ...] + # => [[asset_id_suffix, was_added, faucet_id_suffix_and_metadata, faucet_id_prefix], ASSET_VALUE, ...] # replace asset_id_suffix with domain drop push.DOMAIN_ASSET - # => [[domain, was_added, faucet_id_suffix, faucet_id_prefix], ASSET_VALUE, ...] + # => [[domain, was_added, faucet_id_suffix_and_metadata, faucet_id_prefix], ASSET_VALUE, ...] # drop previous RATE elements swapdw dropw dropw - # => [[domain, was_added, faucet_id_suffix, faucet_id_prefix], ASSET_VALUE, CAPACITY] + # => [[domain, was_added, faucet_id_suffix_and_metadata, faucet_id_prefix], ASSET_VALUE, CAPACITY] exec.poseidon2::permute # => [RATE0, RATE1, CAPACITY] diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/callbacks.masm b/crates/miden-protocol/asm/kernels/transaction/lib/callbacks.masm index 8739235d3b..c54522f0d5 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/callbacks.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/callbacks.masm @@ -13,6 +13,10 @@ const CALLBACK_PROC_ROOT_LOC = 0 # is stored. pub const ON_BEFORE_ASSET_ADDED_TO_ACCOUNT_PROC_ROOT_SLOT = word("miden::protocol::faucet::callback::on_before_asset_added_to_account") +# The name of the storage slot where the procedure root for the on_before_asset_added_to_note callback +# is stored. +pub const ON_BEFORE_ASSET_ADDED_TO_NOTE_PROC_ROOT_SLOT = word("miden::protocol::faucet::callback::on_before_asset_added_to_note") + # PROCEDURES # ================================================================================================== @@ -37,7 +41,12 @@ pub proc on_before_asset_added_to_account # => [callbacks_enabled, ASSET_KEY, ASSET_VALUE] if.true - exec.on_before_asset_added_to_account_raw + # set custom_data = 0 + push.0 movdn.8 + # => [ASSET_KEY, ASSET_VALUE, custom_data = 0] + + push.ON_BEFORE_ASSET_ADDED_TO_ACCOUNT_PROC_ROOT_SLOT[0..2] + exec.invoke_callback # => [PROCESSED_ASSET_VALUE] else # drop asset key @@ -47,50 +56,73 @@ pub proc on_before_asset_added_to_account # => [PROCESSED_ASSET_VALUE] end -#! Executes the `on_before_asset_added_to_account` callback by starting a foreign context against -#! the faucet, reading the callback procedure root from the faucet's storage, and invoking it via -#! `dyncall`. +#! Invokes the `on_before_asset_added_to_note` callback on the faucet that issued the asset, +#! if the asset has callbacks enabled. +#! +#! The callback invocation is skipped in these cases: +#! - If the global callback flag in the asset key is `Disabled`. +#! - If the faucet does not have the callback storage slot. +#! - If the callback storage slot contains the empty word. +#! +#! Inputs: [ASSET_KEY, ASSET_VALUE, note_idx] +#! Outputs: [PROCESSED_ASSET_VALUE] +#! +#! Where: +#! - ASSET_KEY is the vault key of the asset being added. +#! - ASSET_VALUE is the value of the asset being added. +#! - note_idx is the index of the output note the asset is being added to. +#! - PROCESSED_ASSET_VALUE is the asset value returned by the callback, or the original +#! ASSET_VALUE if callbacks are disabled. +pub proc on_before_asset_added_to_note + exec.asset::key_to_callbacks_enabled + # => [callbacks_enabled, ASSET_KEY, ASSET_VALUE, note_idx] + + if.true + push.ON_BEFORE_ASSET_ADDED_TO_NOTE_PROC_ROOT_SLOT[0..2] + exec.invoke_callback + # => [PROCESSED_ASSET_VALUE] + else + # drop asset key and note index + dropw movup.4 drop + # => [ASSET_VALUE] + end + # => [PROCESSED_ASSET_VALUE] +end + +#! Invokes a callback by starting a foreign context against the faucet, reading the callback +#! procedure root from the provided slot ID in the faucet's storage, and invoking it via `dyncall`. #! #! If the faucet does not have the callback storage slot, or if the slot contains the empty word, #! the callback is skipped and the original ASSET_VALUE is returned. #! -#! Inputs: [ASSET_KEY, ASSET_VALUE] +#! custom_data should be set to 0 for the account callback and to note_idx for the note callback. +#! +#! Inputs: [slot_id_suffix, slot_id_prefix, ASSET_KEY, ASSET_VALUE, custom_data] #! Outputs: [PROCESSED_ASSET_VALUE] #! #! Where: +#! - slot_id* is the ID of the slot that contains the callback procedure root. #! - ASSET_KEY is the vault key of the asset being added. #! - ASSET_VALUE is the value of the asset being added. #! - PROCESSED_ASSET_VALUE is the asset value returned by the callback, or the original #! ASSET_VALUE if no callback is configured. @locals(4) -proc on_before_asset_added_to_account_raw +proc invoke_callback exec.start_foreign_callback_context - # => [ASSET_KEY, ASSET_VALUE] - - # try to find the callback procedure root in the faucet's storage - push.ON_BEFORE_ASSET_ADDED_TO_ACCOUNT_PROC_ROOT_SLOT[0..2] - exec.account::find_item - # => [is_found, PROC_ROOT, ASSET_KEY, ASSET_VALUE] - - movdn.4 exec.word::testz not - # => [is_non_empty_word, PROC_ROOT, is_found, ASSET_KEY, ASSET_VALUE] - - # invoke the callback if is_found && is_non_empty_word - movup.5 and - # => [should_invoke, PROC_ROOT, ASSET_KEY, ASSET_VALUE] + # => [should_invoke, PROC_ROOT, ASSET_KEY, ASSET_VALUE, custom_data] # only invoke the callback if the procedure root is not the empty word if.true # prepare for dyncall by storing procedure root in local memory loc_storew_le.CALLBACK_PROC_ROOT_LOC dropw - # => [ASSET_KEY, ASSET_VALUE] + # => [ASSET_KEY, ASSET_VALUE, custom_data] - # pad the stack to 16 for the call (excluding the proc root ptr) - padw padw swapdw - locaddr.CALLBACK_PROC_ROOT_LOC - # => [callback_proc_root_ptr, ASSET_KEY, ASSET_VALUE, pad(8)] + # pad the stack to 16 for the call + repeat.7 push.0 movdn.9 end + # => [ASSET_KEY, ASSET_VALUE, custom_data, pad(7)] # invoke the callback + locaddr.CALLBACK_PROC_ROOT_LOC dyncall # => [PROCESSED_ASSET_VALUE, pad(12)] @@ -98,26 +130,68 @@ proc on_before_asset_added_to_account_raw swapdw dropw dropw swapw dropw # => [PROCESSED_ASSET_VALUE] else - # drop proc root and asset key - dropw dropw + # drop proc root, asset key and custom_data + dropw dropw movup.4 drop # => [ASSET_VALUE] end # => [PROCESSED_ASSET_VALUE] - exec.tx::end_foreign_context + exec.end_foreign_callback_context # => [PROCESSED_ASSET_VALUE] end #! Prepares the invocation of a faucet callback by starting a foreign context against the faucet -#! identified by the asset key's faucet ID. +#! identified by the asset key's faucet ID, looking up the callback procedure root from the +#! faucet's storage, and computing whether the callback should be invoked. #! -#! Inputs: [ASSET_KEY, ASSET_VALUE] -#! Outputs: [ASSET_KEY, ASSET_VALUE] +#! The callback should be invoked if the storage slot exists and contains a non-empty procedure +#! root. +#! +#! Inputs: [slot_id_suffix, slot_id_prefix, ASSET_KEY, ASSET_VALUE] +#! Outputs: [should_invoke, PROC_ROOT, ASSET_KEY, ASSET_VALUE] +#! +#! Where: +#! - slot_id_suffix and slot_id_prefix identify the storage slot containing the callback procedure root. +#! - ASSET_KEY is the vault key of the asset being added. +#! - ASSET_VALUE is the value of the asset being added. +#! - should_invoke is 1 if the callback should be invoked, 0 otherwise. +#! - PROC_ROOT is the procedure root of the callback, or the empty word if not found. proc start_foreign_callback_context + # move slot IDs past ASSET_KEY and ASSET_VALUE + movdn.9 movdn.9 + # => [ASSET_KEY, ASSET_VALUE, slot_id_suffix, slot_id_prefix] + exec.asset::key_to_faucet_id - # => [faucet_id_suffix, faucet_id_prefix, ASSET_KEY, ASSET_VALUE] + # => [faucet_id_suffix, faucet_id_prefix, ASSET_KEY, ASSET_VALUE, slot_id_suffix, slot_id_prefix] # start a foreign context against the faucet exec.tx::start_foreign_context - # => [ASSET_KEY, ASSET_VALUE] + # => [ASSET_KEY, ASSET_VALUE, slot_id_suffix, slot_id_prefix] + + # bring slot IDs back to top + movup.9 movup.9 + # => [slot_id_suffix, slot_id_prefix, ASSET_KEY, ASSET_VALUE] + + # try to find the callback procedure root in the faucet's storage + exec.account::find_item + # => [is_found, PROC_ROOT, ASSET_KEY, ASSET_VALUE] + + movdn.4 exec.word::testz not + # => [is_non_empty_word, PROC_ROOT, is_found, ASSET_KEY, ASSET_VALUE] + + # should_invoke = is_found && is_non_empty_word + movup.5 and + # => [should_invoke, PROC_ROOT, ASSET_KEY, ASSET_VALUE] +end + +#! Ends a foreign callback context. +#! +#! This pops the top of the account stack, making the previous account the active account. +#! +#! This wrapper exists only for uniformity with start_foreign_callback_context. +#! +#! Inputs: [] +#! Outputs: [] +proc end_foreign_callback_context + exec.tx::end_foreign_context end diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/output_note.masm b/crates/miden-protocol/asm/kernels/transaction/lib/output_note.masm index c96dfc5a51..c8a80d5f06 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/output_note.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/output_note.masm @@ -1,5 +1,6 @@ use $kernel::account use $kernel::asset +use $kernel::callbacks use $kernel::fungible_asset use $kernel::memory use $kernel::note @@ -178,13 +179,13 @@ end #! Adds the asset to the note specified by the index. #! -#! Inputs: [note_idx, ASSET_KEY, ASSET_VALUE] +#! Inputs: [ASSET_KEY, ASSET_VALUE, note_idx] #! Outputs: [] #! #! Where: -#! - note_idx is the index of the note to which the asset is added. #! - ASSET_KEY is the vault key of the asset to add. #! - ASSET_VALUE is the value of the asset to add. +#! - note_idx is the index of the note to which the asset is added. #! #! Panics if: #! - the note index points to a non-existent output note. @@ -194,30 +195,40 @@ end #! - the total number of ASSETs exceeds the maximum of 256. pub proc add_asset # check if the note exists, it must be within [0, num_of_notes] - dup exec.memory::get_num_output_notes lte assert.err=ERR_NOTE_INVALID_INDEX - # => [note_idx, ASSET_KEY, ASSET_VALUE] - - # get a pointer to the memory address of the note at which the asset will be stored - exec.memory::get_output_note_ptr - # => [note_ptr, ASSET_KEY, ASSET_VALUE] - - # duplicate note ptr - dup movdn.9 movdn.9 - # => [ASSET_KEY, ASSET_VALUE, note_ptr, note_ptr] + dup.8 exec.memory::get_num_output_notes lte assert.err=ERR_NOTE_INVALID_INDEX + # => [ASSET_KEY, ASSET_VALUE, note_idx] # validate the asset exec.asset::validate - # => [ASSET_KEY, ASSET_VALUE, note_ptr, note_ptr] + # => [ASSET_KEY, ASSET_VALUE, note_idx] - # emit event to signal that a new asset is going to be added to the note. + # emit event to signal that a new asset is going to be added to the note emit.NOTE_BEFORE_ADD_ASSET_EVENT - # => [ASSET_KEY, ASSET_VALUE, note_ptr] + # => [ASSET_KEY, ASSET_VALUE, note_idx] + + # prepare the stack for the callback + swapw dupw.1 + # => [ASSET_KEY, ASSET_VALUE, ASSET_KEY, note_idx] + + dup.12 movdn.8 + # => [ASSET_KEY, ASSET_VALUE, note_idx, ASSET_KEY, note_idx] + + # invoke the callback + exec.callbacks::on_before_asset_added_to_note + swapw + # => [ASSET_KEY, PROCESSED_ASSET_VALUE, note_idx] + + movup.8 exec.memory::get_output_note_ptr dup + # => [note_ptr, note_ptr, ASSET_KEY, PROCESSED_ASSET_VALUE] + + movdn.9 movdn.9 + # => [ASSET_KEY, PROCESSED_ASSET_VALUE, note_ptr, note_ptr] # add the asset to the note exec.add_asset_raw # => [note_ptr] - # emit event to signal that a new asset was added to the note. + # emit event to signal that a new asset was added to the note emit.NOTE_AFTER_ADD_ASSET_EVENT # => [note_ptr] diff --git a/crates/miden-protocol/asm/protocol/asset.masm b/crates/miden-protocol/asm/protocol/asset.masm index 6d78ddad69..ce2ecfb614 100644 --- a/crates/miden-protocol/asm/protocol/asset.masm +++ b/crates/miden-protocol/asm/protocol/asset.masm @@ -66,10 +66,11 @@ end #! Creates a non fungible asset for the specified non-fungible faucet. #! -#! Inputs: [faucet_id_suffix, faucet_id_prefix, DATA_HASH] +#! Inputs: [enable_callbacks, faucet_id_suffix, faucet_id_prefix, DATA_HASH] #! Outputs: [ASSET_KEY, ASSET_VALUE] #! #! Where: +#! - enable_callbacks is a flag (0 or 1) indicating whether asset callbacks are enabled. #! - faucet_id_{suffix,prefix} are the suffix and prefix felts of the faucet to create the asset #! for. #! - DATA_HASH is the data hash of the non-fungible asset to create. @@ -78,13 +79,14 @@ end #! #! Panics if: #! - the provided faucet ID is not a non-fungible faucet. +#! - enable_callbacks is not 0 or 1. #! #! Invocation: exec pub proc create_non_fungible_asset # assert the faucet is a non-fungible faucet - dup.1 exec.account_id::is_non_fungible_faucet + dup.2 exec.account_id::is_non_fungible_faucet assert.err=ERR_NON_FUNGIBLE_ASSET_PROVIDED_FAUCET_ID_IS_INVALID - # => [faucet_id_suffix, faucet_id_prefix, DATA_HASH] + # => [enable_callbacks, faucet_id_suffix, faucet_id_prefix, DATA_HASH] # SAFETY: faucet ID was validated exec.::miden::protocol::util::asset::create_non_fungible_asset_unchecked diff --git a/crates/miden-protocol/asm/protocol/faucet.masm b/crates/miden-protocol/asm/protocol/faucet.masm index ea1135d036..15648cc698 100644 --- a/crates/miden-protocol/asm/protocol/faucet.masm +++ b/crates/miden-protocol/asm/protocol/faucet.masm @@ -47,9 +47,13 @@ end #! #! Invocation: exec pub proc create_non_fungible_asset - # get the id of the faucet the transaction is being executed against + # fetch the id of the faucet the transaction is being executed against exec.active_account::get_id - # => [faucet_id_suffix, faucet_id_prefix, DATA_HASH] + # => [id_suffix, id_prefix, DATA_HASH] + + # check whether the faucet has callbacks defined + exec.has_callbacks + # => [has_callbacks, id_suffix, id_prefix, DATA_HASH] # build the non-fungible asset exec.asset::create_non_fungible_asset diff --git a/crates/miden-protocol/asm/shared_utils/util/asset.masm b/crates/miden-protocol/asm/shared_utils/util/asset.masm index fcf1af1421..50e20e69a7 100644 --- a/crates/miden-protocol/asm/shared_utils/util/asset.masm +++ b/crates/miden-protocol/asm/shared_utils/util/asset.masm @@ -253,22 +253,33 @@ end #! #! WARNING: Does not validate its inputs. #! -#! Inputs: [faucet_id_suffix, faucet_id_prefix, DATA_HASH] +#! Inputs: [enable_callbacks, faucet_id_suffix, faucet_id_prefix, DATA_HASH] #! Outputs: [ASSET_KEY, ASSET_VALUE] #! #! Where: +#! - enable_callbacks is a flag (0 or 1) indicating whether asset callbacks are enabled. #! - faucet_id_{suffix,prefix} are the suffix and prefix felts of the faucet to create the asset #! for. #! - DATA_HASH is the data hash of the non-fungible asset to create. #! - ASSET_KEY is the vault key of the created non-fungible asset. #! - ASSET_VALUE is the value of the created non-fungible asset, which is identical to DATA_HASH. #! +#! Panics if: +#! - enable_callbacks is not 0 or 1. +#! #! Invocation: exec pub proc create_non_fungible_asset_unchecked + exec.create_metadata + # => [asset_metadata, faucet_id_suffix, faucet_id_prefix, DATA_HASH] + + # merge the asset metadata into the lower 8 bits of the suffix + add + # => [faucet_id_suffix_and_metadata, faucet_id_prefix, DATA_HASH] + # copy hashes at indices 0 and 1 in the data hash word to the corresponding index in the key # word dup.3 dup.3 - # => [[hash0, hash1, faucet_id_suffix, faucet_id_prefix], DATA_HASH] + # => [hash0, hash1, faucet_id_suffix_and_metadata, faucet_id_prefix, DATA_HASH] # => [ASSET_KEY, ASSET_VALUE] end diff --git a/crates/miden-protocol/src/account/delta/vault.rs b/crates/miden-protocol/src/account/delta/vault.rs index d6e466e64d..7586337f54 100644 --- a/crates/miden-protocol/src/account/delta/vault.rs +++ b/crates/miden-protocol/src/account/delta/vault.rs @@ -504,11 +504,12 @@ impl NonFungibleAssetDelta { NonFungibleDeltaAction::Add => ONE, }; + let key_word = asset.vault_key().to_word(); elements.extend_from_slice(&[ DOMAIN_ASSET, was_added, - asset.faucet_id().suffix(), - asset.faucet_id().prefix().as_felt(), + key_word[2], // faucet_id_suffix_and_metadata + key_word[3], // faucet_id_prefix ]); elements.extend_from_slice(asset.to_value_word().as_elements()); } diff --git a/crates/miden-protocol/src/asset/asset_callbacks.rs b/crates/miden-protocol/src/asset/asset_callbacks.rs index 21c3e05ea9..3edf662f2f 100644 --- a/crates/miden-protocol/src/asset/asset_callbacks.rs +++ b/crates/miden-protocol/src/asset/asset_callbacks.rs @@ -13,19 +13,28 @@ static ON_BEFORE_ASSET_ADDED_TO_ACCOUNT_SLOT_NAME: LazyLock = .expect("storage slot name should be valid") }); +static ON_BEFORE_ASSET_ADDED_TO_NOTE_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::protocol::faucet::callback::on_before_asset_added_to_note") + .expect("storage slot name should be valid") +}); + // ASSET CALLBACKS // ================================================================================================ -/// Configures the callback procedure root for the `on_before_asset_added_to_account` callback. +/// Configures the callback procedure roots for asset callbacks. /// /// ## Storage Layout /// /// - [`Self::on_before_asset_added_to_account_slot()`]: Stores the procedure root of the /// `on_before_asset_added_to_account` callback. This storage slot is only added if the callback /// procedure root is not the empty word. +/// - [`Self::on_before_asset_added_to_note_slot()`]: Stores the procedure root of the +/// `on_before_asset_added_to_note` callback. This storage slot is only added if the callback +/// procedure root is not the empty word. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct AssetCallbacks { on_before_asset_added_to_account: Word, + on_before_asset_added_to_note: Word, } impl AssetCallbacks { @@ -43,19 +52,37 @@ impl AssetCallbacks { self } + /// Sets the `on_before_asset_added_to_note` callback procedure root. + pub fn on_before_asset_added_to_note(mut self, proc_root: Word) -> Self { + self.on_before_asset_added_to_note = proc_root; + self + } + // PUBLIC ACCESSORS // -------------------------------------------------------------------------------------------- - /// Returns the [`StorageSlotName`] where the callback procedure root is stored. + /// Returns the [`StorageSlotName`] where the `on_before_asset_added_to_account` callback + /// procedure root is stored. pub fn on_before_asset_added_to_account_slot() -> &'static StorageSlotName { &ON_BEFORE_ASSET_ADDED_TO_ACCOUNT_SLOT_NAME } + /// Returns the [`StorageSlotName`] where the `on_before_asset_added_to_note` callback + /// procedure root is stored. + pub fn on_before_asset_added_to_note_slot() -> &'static StorageSlotName { + &ON_BEFORE_ASSET_ADDED_TO_NOTE_SLOT_NAME + } + /// Returns the procedure root of the `on_before_asset_added_to_account` callback. pub fn on_before_asset_added_to_account_proc_root(&self) -> Word { self.on_before_asset_added_to_account } + /// Returns the procedure root of the `on_before_asset_added_to_note` callback. + pub fn on_before_asset_added_to_note_proc_root(&self) -> Word { + self.on_before_asset_added_to_note + } + pub fn into_storage_slots(self) -> Vec { let mut slots = Vec::new(); @@ -66,6 +93,13 @@ impl AssetCallbacks { )); } + if !self.on_before_asset_added_to_note.is_empty() { + slots.push(StorageSlot::with_value( + AssetCallbacks::on_before_asset_added_to_note_slot().clone(), + self.on_before_asset_added_to_note, + )); + } + slots } } diff --git a/crates/miden-protocol/src/asset/mod.rs b/crates/miden-protocol/src/asset/mod.rs index 4d52e6ab1b..4bdec21c38 100644 --- a/crates/miden-protocol/src/asset/mod.rs +++ b/crates/miden-protocol/src/asset/mod.rs @@ -122,6 +122,16 @@ impl Asset { Self::from_key_value(vault_key, value) } + /// Returns a copy of this asset with the given [`AssetCallbackFlag`]. + pub fn with_callbacks(self, callbacks: AssetCallbackFlag) -> Self { + match self { + Asset::Fungible(fungible_asset) => fungible_asset.with_callbacks(callbacks).into(), + Asset::NonFungible(non_fungible_asset) => { + non_fungible_asset.with_callbacks(callbacks).into() + }, + } + } + /// Returns true if this asset is the same as the specified asset. /// /// Two assets are defined to be the same if their vault keys match. diff --git a/crates/miden-protocol/src/asset/nonfungible.rs b/crates/miden-protocol/src/asset/nonfungible.rs index 77333f4fab..c6fcec2297 100644 --- a/crates/miden-protocol/src/asset/nonfungible.rs +++ b/crates/miden-protocol/src/asset/nonfungible.rs @@ -3,7 +3,7 @@ use alloc::vec::Vec; use core::fmt; use super::vault::AssetVaultKey; -use super::{AccountType, Asset, AssetError, Word}; +use super::{AccountType, Asset, AssetCallbackFlag, AssetError, Word}; use crate::Hasher; use crate::account::AccountId; use crate::asset::vault::AssetId; @@ -24,10 +24,14 @@ use crate::utils::serde::{ /// /// [`NonFungibleAsset`] itself does not contain the actual asset data. The container for this data /// is [`NonFungibleAssetDetails`]. +/// +/// The non-fungible asset can have callbacks to the faucet enabled or disabled, depending on +/// [`AssetCallbackFlag`]. See [`AssetCallbacks`](crate::asset::AssetCallbacks) for more details. #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub struct NonFungibleAsset { faucet_id: AccountId, value: Word, + callbacks: AssetCallbackFlag, } impl NonFungibleAsset { @@ -36,8 +40,9 @@ impl NonFungibleAsset { /// The serialized size of a [`NonFungibleAsset`] in bytes. /// - /// An account ID (15 bytes) plus a word (32 bytes). - pub const SERIALIZED_SIZE: usize = AccountId::SERIALIZED_SIZE + Word::SERIALIZED_SIZE; + /// An account ID (15 bytes) plus a word (32 bytes) plus a callbacks flag (1 byte). + pub const SERIALIZED_SIZE: usize = + AccountId::SERIALIZED_SIZE + Word::SERIALIZED_SIZE + AssetCallbackFlag::SERIALIZED_SIZE; // CONSTRUCTORS // -------------------------------------------------------------------------------------------- @@ -64,7 +69,11 @@ impl NonFungibleAsset { return Err(AssetError::NonFungibleFaucetIdTypeMismatch(faucet_id)); } - Ok(Self { faucet_id, value }) + Ok(Self { + faucet_id, + value, + callbacks: AssetCallbackFlag::default(), + }) } /// Creates a non-fungible asset from the provided key and value. @@ -84,7 +93,10 @@ impl NonFungibleAsset { }); } - Self::from_parts(key.faucet_id(), value) + let mut asset = Self::from_parts(key.faucet_id(), value)?; + asset.callbacks = key.callback_flag(); + + Ok(asset) } /// Creates a non-fungible asset from the provided key and value. @@ -101,6 +113,12 @@ impl NonFungibleAsset { Self::from_key_value(vault_key, value) } + /// Returns a copy of this asset with the given [`AssetCallbackFlag`]. + pub fn with_callbacks(mut self, callbacks: AssetCallbackFlag) -> Self { + self.callbacks = callbacks; + self + } + // ACCESSORS // -------------------------------------------------------------------------------------------- @@ -112,7 +130,7 @@ impl NonFungibleAsset { let asset_id_prefix = self.value[1]; let asset_id = AssetId::new(asset_id_suffix, asset_id_prefix); - AssetVaultKey::new_native(asset_id, self.faucet_id) + AssetVaultKey::new(asset_id, self.faucet_id, self.callbacks) .expect("constructors should ensure account ID is of type non-fungible faucet") } @@ -121,6 +139,11 @@ impl NonFungibleAsset { self.faucet_id } + /// Returns the [`AssetCallbackFlag`] of this asset. + pub fn callbacks(&self) -> AssetCallbackFlag { + self.callbacks + } + /// Returns the asset's key encoded to a [`Word`]. pub fn to_key_word(&self) -> Word { self.vault_key().to_word() @@ -154,10 +177,11 @@ impl Serializable for NonFungibleAsset { // easily distinguishable during deserialization. target.write(self.faucet_id()); target.write(self.value); + target.write(self.callbacks); } fn get_size_hint(&self) -> usize { - Self::SERIALIZED_SIZE + self.faucet_id.get_size_hint() + self.value.get_size_hint() + self.callbacks.get_size_hint() } } @@ -178,8 +202,10 @@ impl NonFungibleAsset { source: &mut R, ) -> Result { let value: Word = source.read()?; + let callbacks: AssetCallbackFlag = source.read()?; NonFungibleAsset::from_parts(faucet_id, value) + .map(|asset| asset.with_callbacks(callbacks)) .map_err(|err| DeserializationError::InvalidValue(err.to_string())) } } diff --git a/crates/miden-testing/src/kernel_tests/tx/test_callbacks.rs b/crates/miden-testing/src/kernel_tests/tx/test_callbacks.rs index f9f606004b..a2b827a536 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_callbacks.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_callbacks.rs @@ -6,6 +6,7 @@ use alloc::vec::Vec; use miden_protocol::account::auth::AuthScheme; use miden_protocol::account::component::AccountComponentMetadata; use miden_protocol::account::{ + Account, AccountBuilder, AccountComponent, AccountComponentCode, @@ -17,17 +18,25 @@ use miden_protocol::account::{ StorageSlot, StorageSlotName, }; -use miden_protocol::asset::{Asset, AssetCallbackFlag, AssetCallbacks, FungibleAsset}; +use miden_protocol::asset::{ + Asset, + AssetCallbackFlag, + AssetCallbacks, + FungibleAsset, + NonFungibleAsset, + NonFungibleAssetDetails, +}; use miden_protocol::block::account_tree::AccountIdKey; use miden_protocol::errors::MasmError; -use miden_protocol::note::NoteType; +use miden_protocol::note::{NoteTag, NoteType}; use miden_protocol::utils::sync::LazyLock; use miden_protocol::{Felt, Word}; use miden_standards::account::faucets::BasicFungibleFaucet; use miden_standards::code_builder::CodeBuilder; use miden_standards::procedure_digest; +use miden_standards::testing::account_component::MockFaucetComponent; -use crate::{AccountState, Auth, assert_transaction_executor_error}; +use crate::{AccountState, Auth, MockChain, MockChainBuilder, assert_transaction_executor_error}; // CONSTANTS // ================================================================================================ @@ -44,38 +53,67 @@ use miden::core::word const BLOCK_LIST_MAP_SLOT = word("miden::testing::callbacks::block_list") const ERR_ACCOUNT_BLOCKED = "the account is blocked and cannot receive this asset" -#! Callback invoked when an asset with callbacks enabled is added to an account's vault. +#! Asserts that the native account is not in the block list. #! -#! Checks whether the receiving account is in the block list. If so, panics. +#! Inputs: [] +#! Outputs: [] #! -#! Inputs: [ASSET_KEY, ASSET_VALUE, pad(8)] -#! Outputs: [ASSET_VALUE, pad(12)] +#! Panics if the native account is in the block list. #! -#! Invocation: call -pub proc on_before_asset_added_to_account - # Get the native account ID (the account receiving the asset) +#! Invocation: exec +proc assert_native_account_not_blocked + # Get the native account ID exec.native_account::get_id - # => [native_acct_suffix, native_acct_prefix, ASSET_KEY, ASSET_VALUE, pad(8)] + # => [native_acct_suffix, native_acct_prefix] # Build account ID map key: [0, 0, suffix, prefix] push.0.0 - # => [0, 0, native_acct_suffix, native_acct_prefix, ASSET_KEY, ASSET_VALUE, pad(8)] - # => [ACCOUNT_ID_KEY, ASSET_KEY, ASSET_VALUE, pad(8)] + # => [ACCOUNT_ID_KEY] # Look up in block list storage map push.BLOCK_LIST_MAP_SLOT[0..2] exec.active_account::get_map_item - # => [IS_BLOCKED, ASSET_KEY, ASSET_VALUE, pad(8)] + # => [IS_BLOCKED] # If IS_BLOCKED is non-zero, account is blocked. exec.word::eqz assert.err=ERR_ACCOUNT_BLOCKED - # => [ASSET_KEY, ASSET_VALUE, pad(10)] + # => [] +end + +#! Callback invoked when an asset with callbacks enabled is added to an account's vault. +#! +#! Checks whether the receiving account is in the block list. If so, panics. +#! +#! Inputs: [ASSET_KEY, ASSET_VALUE, pad(8)] +#! Outputs: [ASSET_VALUE, pad(12)] +#! +#! Invocation: call +pub proc on_before_asset_added_to_account + exec.assert_native_account_not_blocked + # => [ASSET_KEY, ASSET_VALUE, pad(8)] # drop unused asset key dropw # => [ASSET_VALUE, pad(12)] end + +#! Callback invoked when an asset with callbacks enabled is added to an output note. +#! +#! Checks whether the native account (the note creator) is in the block list. If so, panics. +#! +#! Inputs: [ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] +#! Outputs: [ASSET_VALUE, pad(12)] +#! +#! Invocation: call +pub proc on_before_asset_added_to_note + exec.assert_native_account_not_blocked + # => [ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] + + # drop unused asset key + dropw + # => [ASSET_VALUE, note_idx, pad(7)] +end "#; /// The expected error when a blocked account tries to receive an asset with callbacks. @@ -101,6 +139,13 @@ procedure_digest!( || { BLOCK_LIST_COMPONENT_CODE.as_library() } ); +procedure_digest!( + BLOCK_LIST_ON_BEFORE_ASSET_ADDED_TO_NOTE, + BlockList::NAME, + BlockList::ON_BEFORE_ASSET_ADDED_TO_NOTE_PROC_NAME, + || { BLOCK_LIST_COMPONENT_CODE.as_library() } +); + // BLOCK LIST // ================================================================================================ @@ -118,15 +163,22 @@ impl BlockList { const ON_BEFORE_ASSET_ADDED_TO_ACCOUNT_PROC_NAME: &str = "on_before_asset_added_to_account"; + const ON_BEFORE_ASSET_ADDED_TO_NOTE_PROC_NAME: &str = "on_before_asset_added_to_note"; + /// Creates a new [`BlockList`] with the given set of blocked accounts. fn new(blocked_accounts: BTreeSet) -> Self { Self { blocked_accounts } } - /// Returns the digest of the `distribute` account procedure. + /// Returns the digest of the `on_before_asset_added_to_account` procedure. pub fn on_before_asset_added_to_account_digest() -> Word { *BLOCK_LIST_ON_BEFORE_ASSET_ADDED_TO_ACCOUNT } + + /// Returns the digest of the `on_before_asset_added_to_note` procedure. + pub fn on_before_asset_added_to_note_digest() -> Word { + *BLOCK_LIST_ON_BEFORE_ASSET_ADDED_TO_NOTE + } } impl From for AccountComponent { @@ -154,11 +206,14 @@ impl From for AccountComponent { .on_before_asset_added_to_account( BlockList::on_before_asset_added_to_account_digest(), ) + .on_before_asset_added_to_note(BlockList::on_before_asset_added_to_note_digest()) .into_storage_slots(), ); - let metadata = - AccountComponentMetadata::new(BlockList::NAME, [AccountType::FungibleFaucet]) - .with_description("block list callback component for testing"); + let metadata = AccountComponentMetadata::new( + BlockList::NAME, + [AccountType::FungibleFaucet, AccountType::NonFungibleFaucet], + ) + .with_description("block list callback component for testing"); AccountComponent::new(BLOCK_LIST_COMPONENT_CODE.clone(), storage_slots, metadata) .expect("block list should satisfy the requirements of a valid account component") @@ -168,11 +223,87 @@ impl From for AccountComponent { // TESTS // ================================================================================================ +/// Tests that consuming a callbacks-enabled asset succeeds even when the issuing faucet does not +/// have the callback storage slot or when the callback storage slot contains the empty word. +#[rstest::rstest] +#[case::fungible_empty_storage(AccountType::FungibleFaucet, true)] +#[case::fungible_no_storage(AccountType::FungibleFaucet, false)] +#[case::non_fungible_empty_storage(AccountType::NonFungibleFaucet, true)] +#[case::non_fungible_no_storage(AccountType::NonFungibleFaucet, false)] +#[tokio::test] +async fn test_faucet_without_callback_slot_skips_callback( + #[case] account_type: AccountType, + #[case] has_empty_callback_proc_root: bool, +) -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; + + // Create a faucet WITHOUT any AssetCallbacks component. + let mut account_builder = AccountBuilder::new([45u8; 32]) + .storage_mode(AccountStorageMode::Public) + .account_type(account_type) + .with_component(MockFaucetComponent); + + // If callback proc roots should be empty, add the empty storage slots. + if has_empty_callback_proc_root { + let name = "miden::testing::callbacks"; + let slots = AssetCallbacks::new().into_storage_slots(); + let component = AccountComponent::new( + CodeBuilder::new().compile_component_code(name, "pub proc dummy nop end")?, + slots, + AccountComponentMetadata::mock(name), + )?; + account_builder = account_builder.with_component(component); + } + + let faucet = builder.add_account_from_builder( + Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + }, + account_builder, + AccountState::Exists, + )?; + + // Create a P2ID note with a callbacks-enabled asset from this faucet. + // The faucet does not have the callback slot, but the asset has callbacks enabled. + let asset = match account_type { + AccountType::FungibleFaucet => Asset::from(FungibleAsset::new(faucet.id(), 100)?), + AccountType::NonFungibleFaucet => Asset::from(NonFungibleAsset::new( + &NonFungibleAssetDetails::new(faucet.id(), vec![1])?, + )?), + _ => unreachable!("test only uses faucet account types"), + } + .with_callbacks(AssetCallbackFlag::Enabled); + + let note = + builder.add_p2id_note(faucet.id(), target_account.id(), &[asset], NoteType::Public)?; + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + + // Consuming the note should succeed: the callback is gracefully skipped because the + // faucet does not define the callback storage slot. + mock_chain + .build_tx_context(target_account.id(), &[note.id()], &[])? + .foreign_accounts(vec![faucet_inputs]) + .build()? + .execute() + .await?; + + Ok(()) +} + +// ON_ASSET_ADDED_TO_ACCOUNT TESTS +// ================================================================================================ + /// Tests that the `on_before_asset_added_to_account` callback receives the correct inputs. #[tokio::test] async fn test_on_before_asset_added_to_account_callback_receives_correct_inputs() -> anyhow::Result<()> { - let mut builder = crate::MockChain::builder(); + let mut builder = MockChain::builder(); // Create wallet first so we know its ID before building the faucet. let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; @@ -182,15 +313,11 @@ async fn test_on_before_asset_added_to_account_callback_receives_correct_inputs( let amount: u64 = 100; // MASM callback that asserts the inputs match expected values. - let component_name = "miden::testing::callbacks::input_validator"; - let proc_name = "on_before_asset_added_to_account"; - let callback_masm = format!( + let account_callback_masm = format!( r#" - const ERR_WRONG_VALUE = "callback received unexpected asset value element" - #! Inputs: [ASSET_KEY, ASSET_VALUE, pad(8)] #! Outputs: [ASSET_VALUE, pad(12)] - pub proc {proc_name} + pub proc on_before_asset_added_to_account # Assert native account ID can be retrieved via native_account::get_id exec.::miden::protocol::native_account::get_id # => [native_account_suffix, native_account_prefix, ASSET_KEY, ASSET_VALUE, pad(8)] @@ -220,42 +347,7 @@ async fn test_on_before_asset_added_to_account_callback_receives_correct_inputs( "# ); - // Compile the callback code and extract the procedure root. - let callback_code = - CodeBuilder::default().compile_component_code(component_name, callback_masm.as_str())?; - - let proc_root = callback_code - .as_library() - .get_procedure_root_by_path(format!("{component_name}::{proc_name}").as_str()) - .expect("callback should contain the procedure"); - - // Build the faucet with BasicFungibleFaucet + callback component. - let basic_faucet = BasicFungibleFaucet::new("CBK".try_into()?, 8, Felt::new(1_000_000))?; - - let callback_storage_slots = AssetCallbacks::new() - .on_before_asset_added_to_account(proc_root) - .into_storage_slots(); - - let callback_metadata = - AccountComponentMetadata::new(component_name, [AccountType::FungibleFaucet]) - .with_description("input validation callback component for testing"); - - let callback_component = - AccountComponent::new(callback_code, callback_storage_slots, callback_metadata)?; - - let account_builder = AccountBuilder::new([43u8; 32]) - .storage_mode(AccountStorageMode::Public) - .account_type(AccountType::FungibleFaucet) - .with_component(basic_faucet) - .with_component(callback_component); - - let faucet = builder.add_account_from_builder( - Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - }, - account_builder, - AccountState::Exists, - )?; + let faucet = add_faucet_with_callbacks(&mut builder, Some(&account_callback_masm), None)?; // Create a P2ID note with a callbacks-enabled fungible asset. let fungible_asset = @@ -283,55 +375,43 @@ async fn test_on_before_asset_added_to_account_callback_receives_correct_inputs( Ok(()) } -/// Tests that a blocked account cannot receive assets with callbacks enabled. -/// -/// Flow: -/// 1. Create a faucet with BasicFungibleFaucet + BlockList components -/// 2. Create a wallet that is in the block list -/// 3. Create a P2ID note with a callbacks-enabled asset from the faucet to the wallet -/// 4. Attempt to consume the note on the blocked wallet -/// 5. Assert that the transaction fails with ERR_ACCOUNT_BLOCKED +/// Tests that a blocked account cannot receive an asset with callbacks enabled. +#[rstest::rstest] +#[case::fungible( + AccountType::FungibleFaucet, + |faucet_id| { + Ok(FungibleAsset::new(faucet_id, 100)?.with_callbacks(AssetCallbackFlag::Enabled).into()) + } +)] +#[case::non_fungible( + AccountType::NonFungibleFaucet, + |faucet_id| { + let details = NonFungibleAssetDetails::new(faucet_id, vec![1, 2, 3, 4])?; + Ok(NonFungibleAsset::new(&details)?.with_callbacks(AssetCallbackFlag::Enabled).into()) + } +)] #[tokio::test] -async fn test_blocked_account_cannot_receive_asset() -> anyhow::Result<()> { - let mut builder = crate::MockChain::builder(); +async fn test_blocked_account_cannot_receive_asset( + #[case] account_type: AccountType, + #[case] create_asset: impl FnOnce(AccountId) -> anyhow::Result, +) -> anyhow::Result<()> { + let mut builder = MockChain::builder(); let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; + let faucet = add_faucet_with_block_list(&mut builder, account_type, [target_account.id()])?; - let block_list = BlockList::new(BTreeSet::from_iter([target_account.id()])); - let basic_faucet = BasicFungibleFaucet::new("BLK".try_into()?, 8, Felt::new(1_000_000))?; - - let account_builder = AccountBuilder::new([42u8; 32]) - .storage_mode(AccountStorageMode::Public) - .account_type(AccountType::FungibleFaucet) - .with_component(basic_faucet) - .with_component(block_list); - - let faucet = builder.add_account_from_builder( - Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - }, - account_builder, - AccountState::Exists, - )?; - - // Create a P2ID note with a callbacks-enabled asset - let fungible_asset = - FungibleAsset::new(faucet.id(), 100)?.with_callbacks(AssetCallbackFlag::Enabled); let note = builder.add_p2id_note( faucet.id(), target_account.id(), - &[Asset::Fungible(fungible_asset)], + &[create_asset(faucet.id())?], NoteType::Public, )?; let mut mock_chain = builder.build()?; mock_chain.prove_next_block()?; - // Get foreign account inputs for the faucet so the callback's foreign context can access it let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; - // Try to consume the note on the blocked wallet - should fail because the callback - // checks the block list and panics. let result = mock_chain .build_tx_context(target_account.id(), &[note.id()], &[])? .foreign_accounts(vec![faucet_inputs]) @@ -344,50 +424,185 @@ async fn test_blocked_account_cannot_receive_asset() -> anyhow::Result<()> { Ok(()) } -/// Tests that consuming a callbacks-enabled asset succeeds even when the issuing faucet does not -/// have the callback storage slot. +// ON_ASSET_ADDED_TO_NOTE TESTS +// ================================================================================================ + +/// Tests that a blocked account cannot add a callbacks-enabled asset to an output note. +#[rstest::rstest] +#[case::fungible( + AccountType::FungibleFaucet, + |faucet_id| { + Ok(FungibleAsset::new(faucet_id, 100)?.with_callbacks(AssetCallbackFlag::Enabled).into()) + } +)] +#[case::non_fungible( + AccountType::NonFungibleFaucet, + |faucet_id| { + let details = NonFungibleAssetDetails::new(faucet_id, vec![1, 2, 3, 4])?; + Ok(NonFungibleAsset::new(&details)?.with_callbacks(AssetCallbackFlag::Enabled).into()) + } +)] #[tokio::test] -async fn test_faucet_without_callback_slot_skips_callback() -> anyhow::Result<()> { - let mut builder = crate::MockChain::builder(); +async fn test_blocked_account_cannot_add_asset_to_note( + #[case] account_type: AccountType, + #[case] create_asset: impl FnOnce(AccountId) -> anyhow::Result, +) -> anyhow::Result<()> { + let mut builder = MockChain::builder(); let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; + let faucet = add_faucet_with_block_list(&mut builder, account_type, [target_account.id()])?; + let asset = create_asset(faucet.id())?; - let basic_faucet = BasicFungibleFaucet::new("NCB".try_into()?, 8, Felt::new(1_000_000))?; + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; - // Create a faucet WITHOUT any AssetCallbacks component. - let account_builder = AccountBuilder::new([45u8; 32]) - .storage_mode(AccountStorageMode::Public) - .account_type(AccountType::FungibleFaucet) - .with_component(basic_faucet); + // Build a tx script that creates a private output note and adds the callbacks-enabled asset. + // We use a private note to avoid the public note details requirement in the advice provider. + let recipient = Word::from([0u32, 1, 2, 3]); + let script_code = format!( + r#" + use miden::protocol::output_note + + begin + push.{recipient} + push.{note_type} + push.{tag} + exec.output_note::create + + push.{asset_value} + push.{asset_key} + exec.output_note::add_asset + end + "#, + recipient = recipient, + note_type = NoteType::Private as u8, + tag = NoteTag::default(), + asset_value = asset.to_value_word(), + asset_key = asset.to_key_word(), + ); - let faucet = builder.add_account_from_builder( - Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - }, - account_builder, - AccountState::Exists, - )?; + let tx_script = CodeBuilder::with_mock_libraries().compile_tx_script(&script_code)?; - // Create a P2ID note with a callbacks-enabled asset from this faucet. - // The faucet does not have the callback slot, but the asset has callbacks enabled. + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + + let result = mock_chain + .build_tx_context(target_account.id(), &[], &[])? + .tx_script(tx_script) + .foreign_accounts(vec![faucet_inputs]) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_ACCOUNT_BLOCKED); + + Ok(()) +} + +/// Tests that the `on_before_asset_added_to_note` callback receives the correct inputs. +/// +/// Creates two output notes so that the asset is added to note at index 1, verifying that +/// `note_idx` is correctly passed to the callback (using 1 instead of the default element of 0). +#[tokio::test] +async fn test_on_before_asset_added_to_note_callback_receives_correct_inputs() -> anyhow::Result<()> +{ + let mut builder = MockChain::builder(); + + // Create wallet first so we know its ID before building the faucet. + let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; + let wallet_id_suffix = target_account.id().suffix().as_canonical_u64(); + let wallet_id_prefix = target_account.id().prefix().as_u64(); + + let amount: u64 = 100; + + // MASM callback that asserts the inputs match expected values. + let note_callback_masm = format!( + r#" + const ERR_WRONG_NOTE_IDX = "callback received unexpected note_idx" + + #! Inputs: [ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] + #! Outputs: [ASSET_VALUE, pad(12)] + pub proc on_before_asset_added_to_note + # Assert native account ID can be retrieved via native_account::get_id + exec.::miden::protocol::native_account::get_id + # => [native_account_suffix, native_account_prefix, ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] + push.{wallet_id_suffix} assert_eq.err="callback received unexpected native account ID suffix" + push.{wallet_id_prefix} assert_eq.err="callback received unexpected native account ID prefix" + # => [ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] + + # Assert note_idx == 1 (we create two notes, adding the asset to the second one) + dup.8 push.1 assert_eq.err=ERR_WRONG_NOTE_IDX + # => [ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] + + # duplicate the asset value for returning + dupw.1 swapw + # => [ASSET_KEY, ASSET_VALUE, ASSET_VALUE, note_idx, pad(7)] + + # build the expected asset + push.{amount} + exec.::miden::protocol::active_account::get_id + push.1 + # => [enable_callbacks, active_account_id_suffix, active_account_id_prefix, amount, ASSET_KEY, ASSET_VALUE, ASSET_VALUE, note_idx, pad(7)] + exec.::miden::protocol::asset::create_fungible_asset + # => [EXPECTED_ASSET_KEY, EXPECTED_ASSET_VALUE, ASSET_KEY, ASSET_VALUE, ASSET_VALUE, note_idx, pad(7)] + + movupw.2 + assert_eqw.err="callback received unexpected asset key" + # => [EXPECTED_ASSET_VALUE, ASSET_VALUE, ASSET_VALUE, note_idx, pad(7)] + + assert_eqw.err="callback received unexpected asset value" + # => [ASSET_VALUE, note_idx, pad(7)] + end + "# + ); + + let faucet = add_faucet_with_callbacks(&mut builder, None, Some(¬e_callback_masm))?; + + // Create a P2ID note with a callbacks-enabled fungible asset. + // Consuming this note adds the asset to the wallet's vault. let fungible_asset = - FungibleAsset::new(faucet.id(), 100)?.with_callbacks(AssetCallbackFlag::Enabled); - let note = builder.add_p2id_note( - faucet.id(), - target_account.id(), - &[Asset::Fungible(fungible_asset)], - NoteType::Public, - )?; + FungibleAsset::new(faucet.id(), amount)?.with_callbacks(AssetCallbackFlag::Enabled); + let asset = Asset::Fungible(fungible_asset); + let note = + builder.add_p2id_note(faucet.id(), target_account.id(), &[asset], NoteType::Public)?; let mut mock_chain = builder.build()?; mock_chain.prove_next_block()?; + // Build a tx script that creates two output notes and moves the asset from vault to the + // second note (note_idx=1), so we can verify that the callback receives the correct + // note_idx. + let script_code = format!( + r#" + use mock::util + + begin + # Create note 0 (just to consume index 0) + exec.util::create_default_note drop + # => [] + + # Create note 1 + push.{asset_value} + push.{asset_key} + # => [ASSET_KEY, ASSET_VALUE] + exec.util::create_default_note_with_moved_asset + # => [] + + dropw dropw + end + "#, + asset_value = asset.to_value_word(), + asset_key = asset.to_key_word(), + ); + + let tx_script = CodeBuilder::with_mock_libraries().compile_tx_script(&script_code)?; + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; - // Consuming the note should succeed: the callback is gracefully skipped because the - // faucet does not define the callback storage slot. + // Execute the transaction: consume the P2ID note (asset enters vault), then move the asset + // to output note 1. Should succeed because all callback assertions pass. mock_chain .build_tx_context(target_account.id(), &[note.id()], &[])? + .tx_script(tx_script) .foreign_accounts(vec![faucet_inputs]) .build()? .execute() @@ -395,3 +610,99 @@ async fn test_faucet_without_callback_slot_skips_callback() -> anyhow::Result<() Ok(()) } + +// HELPERS +// ================================================================================================ + +/// Builds a fungible faucet with the block list callback component and adds it to the builder. +/// +/// The block list component registers both the account and note callbacks. When a +/// callbacks-enabled asset is added to an account or note, the callback checks whether the +/// native account is in the block list and panics if so. +fn add_faucet_with_block_list( + builder: &mut MockChainBuilder, + account_type: AccountType, + blocked_accounts: impl IntoIterator, +) -> anyhow::Result { + let block_list = BlockList::new(blocked_accounts.into_iter().collect()); + + if !account_type.is_faucet() { + anyhow::bail!("account type must be of type faucet") + } + + let account_builder = AccountBuilder::new([42u8; 32]) + .storage_mode(AccountStorageMode::Public) + .account_type(account_type) + .with_component(MockFaucetComponent) + .with_component(block_list); + + builder.add_account_from_builder( + Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + }, + account_builder, + AccountState::Exists, + ) +} + +/// Builds a fungible faucet with custom callback MASM code and adds it to the builder. +/// +/// `account_callback_masm` and `note_callback_masm` are optional MASM source for the +/// `on_before_asset_added_to_account` and `on_before_asset_added_to_note` procedures. Each +/// string should contain a complete `pub proc ... end` block including any constants needed. +fn add_faucet_with_callbacks( + builder: &mut MockChainBuilder, + account_callback_masm: Option<&str>, + note_callback_masm: Option<&str>, +) -> anyhow::Result { + let component_name = "miden::testing::callbacks::input_validator"; + + let masm_source = + format!("{}\n{}", account_callback_masm.unwrap_or(""), note_callback_masm.unwrap_or(""),); + + let callback_code = + CodeBuilder::default().compile_component_code(component_name, &masm_source)?; + + let mut callbacks = AssetCallbacks::new(); + + if account_callback_masm.is_some() { + let path = format!("{component_name}::on_before_asset_added_to_account"); + let proc_root = callback_code + .as_library() + .get_procedure_root_by_path(path.as_str()) + .expect("account callback procedure should exist"); + callbacks = callbacks.on_before_asset_added_to_account(proc_root); + } + + if note_callback_masm.is_some() { + let path = format!("{component_name}::on_before_asset_added_to_note"); + let proc_root = callback_code + .as_library() + .get_procedure_root_by_path(path.as_str()) + .expect("note callback procedure should exist"); + callbacks = callbacks.on_before_asset_added_to_note(proc_root); + } + + let basic_faucet = BasicFungibleFaucet::new("SYM".try_into()?, 8, Felt::new(1_000_000))?; + + let callback_storage_slots = callbacks.into_storage_slots(); + let callback_metadata = + AccountComponentMetadata::new(component_name, [AccountType::FungibleFaucet]) + .with_description("callback component for testing"); + let callback_component = + AccountComponent::new(callback_code, callback_storage_slots, callback_metadata)?; + + let account_builder = AccountBuilder::new([42; 32]) + .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::FungibleFaucet) + .with_component(basic_faucet) + .with_component(callback_component); + + builder.add_account_from_builder( + Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + }, + account_builder, + AccountState::Exists, + ) +} diff --git a/crates/miden-tx/src/host/tx_event.rs b/crates/miden-tx/src/host/tx_event.rs index 63256b2655..b84a6491df 100644 --- a/crates/miden-tx/src/host/tx_event.rs +++ b/crates/miden-tx/src/host/tx_event.rs @@ -406,10 +406,10 @@ impl TransactionEvent { TransactionEventId::NoteAfterCreated => None, TransactionEventId::NoteBeforeAddAsset => { - // Expected stack state: [event, ASSET_KEY, ASSET_VALUE, note_ptr] + // Expected stack state: [event, ASSET_KEY, ASSET_VALUE, note_idx] let asset_key = process.get_stack_word(1); let asset_value = process.get_stack_word(5); - let note_ptr = process.get_stack_item(9); + let note_idx = process.get_stack_item(9); let asset = Asset::from_key_value_words(asset_key, asset_value).map_err(|source| { @@ -418,7 +418,7 @@ impl TransactionEvent { source, } })?; - let note_idx = note_ptr_to_idx(note_ptr)? as usize; + let note_idx = note_idx.as_canonical_u64() as usize; Some(TransactionEvent::NoteBeforeAddAsset { note_idx, asset }) },