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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@
- Fixed `output_note::add_asset` and `output_note::set_attachment` to no longer accept invalid note indices ([#2824](https://github.com/0xMiden/protocol/pull/2824)).
- Fixed auth components to use initial storage state for authentication ([#2677](https://github.com/0xMiden/protocol/issues/2677)).
- Renamed the AggLayer faucet registry flag constant for clarity ([#2812](https://github.com/0xMiden/protocol/issues/2812)).
## 0.14.6 (2026-05-05)

- Fixed asset callback against native account panicking ([#2868](https://github.com/0xMiden/protocol/pull/2868)).

## 0.14.5 (2026-04-23)

Expand Down
112 changes: 82 additions & 30 deletions crates/miden-protocol/asm/kernels/transaction/lib/callbacks.masm
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use $kernel::tx
use $kernel::asset
use $kernel::account
use $kernel::account_id
use miden::core::word

# CONSTANTS
Expand Down Expand Up @@ -108,90 +109,141 @@ end
#! ASSET_VALUE if no callback is configured.
@locals(4)
proc invoke_callback
exec.start_foreign_callback_context
# => [should_invoke, PROC_ROOT, ASSET_KEY, ASSET_VALUE, custom_data]
exec.maybe_start_faucet_callback_context
# => [was_foreign_context_started, should_invoke_callback, PROC_ROOT, ASSET_KEY, ASSET_VALUE, custom_data]

# store was_foreign_context_started flag for later
movdn.14
# => [should_invoke_callback, PROC_ROOT, ASSET_KEY, ASSET_VALUE, custom_data, was_foreign_context_started]

# 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, custom_data]
# => [ASSET_KEY, ASSET_VALUE, custom_data, was_foreign_context_started]

# pad the stack to 16 for the call
repeat.7 push.0 movdn.9 end
# => [ASSET_KEY, ASSET_VALUE, custom_data, pad(7)]
# => [ASSET_KEY, ASSET_VALUE, custom_data, pad(7), was_foreign_context_started]

# invoke the callback
locaddr.CALLBACK_PROC_ROOT_LOC
dyncall
# => [PROCESSED_ASSET_VALUE, pad(12)]
# => [PROCESSED_ASSET_VALUE, pad(12), was_foreign_context_started]

# truncate the stack after the call
swapdw dropw dropw swapw dropw
# => [PROCESSED_ASSET_VALUE]
# => [PROCESSED_ASSET_VALUE, was_foreign_context_started]
else
# drop proc root, asset key and custom_data
dropw dropw movup.4 drop
# => [ASSET_VALUE]
# => [ASSET_VALUE, was_foreign_context_started]
end
# => [PROCESSED_ASSET_VALUE]
# => [PROCESSED_ASSET_VALUE, was_foreign_context_started]

movup.4
# => [was_foreign_context_started, PROCESSED_ASSET_VALUE]

exec.end_foreign_callback_context
exec.maybe_end_faucet_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, looking up the callback procedure root from the
#! faucet's storage, and computing whether the callback should be invoked.
#! Prepares the invocation of a faucet callback.
#!
#! The account on which the callback should be invoked is identified by the asset key's faucet ID.
#! If it is the active account, no foreign context is started, if it is, a foreign context is
#! started against the faucet.
#!
#! The callback should be invoked if the storage slot exists and contains a non-empty procedure
#! root.
#! The callback procedure root is fetched from the faucet's storage and a flag is computed that
#! signals whether the callback should be invoked. 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]
#! Outputs: [was_foreign_context_started, should_invoke_callback, 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.
#! - should_invoke_callback 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
#! - was_foreign_context_started is 1 if the faucet is not the active account, 0 otherwise.
proc maybe_start_faucet_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, slot_id_suffix, slot_id_prefix]

# start a foreign context against the faucet
exec.tx::start_foreign_context
# => [ASSET_KEY, ASSET_VALUE, slot_id_suffix, slot_id_prefix]
# check if the faucet is the active account
dup.1 dup.1 exec.should_start_foreign_context
# => [should_start_foreign_context, faucet_id_suffix, faucet_id_prefix, ASSET_KEY, ASSET_VALUE,
# slot_id_suffix, slot_id_prefix]

# store the flag for return as was_foreign_context_started
dup movdn.13
# => [should_start_foreign_context, faucet_id_suffix, faucet_id_prefix, ASSET_KEY, ASSET_VALUE,
# slot_id_suffix, slot_id_prefix, was_foreign_context_started]

if.true
# if the faucet is not the active account, start a context against the faucet
exec.tx::start_foreign_context
# => [ASSET_KEY, ASSET_VALUE, slot_id_suffix, slot_id_prefix, was_foreign_context_started]
else
# if the faucet is the active account, the account context is already the issuing faucet's context
drop drop
# => [ASSET_KEY, ASSET_VALUE, slot_id_suffix, slot_id_prefix, was_foreign_context_started]
end

# bring slot IDs back to top
movup.9 movup.9
# => [slot_id_suffix, slot_id_prefix, ASSET_KEY, ASSET_VALUE]
# => [slot_id_suffix, slot_id_prefix, ASSET_KEY, ASSET_VALUE, was_foreign_context_started]

# try to find the callback procedure root in the faucet's storage
exec.account::find_item
# => [is_found, PROC_ROOT, ASSET_KEY, ASSET_VALUE]
# => [is_found, PROC_ROOT, ASSET_KEY, ASSET_VALUE, was_foreign_context_started]

movdn.4 exec.word::testz not
# => [is_non_empty_word, PROC_ROOT, is_found, ASSET_KEY, ASSET_VALUE]
# => [is_non_empty_word, PROC_ROOT, is_found, ASSET_KEY, ASSET_VALUE, was_foreign_context_started]

# should_invoke = is_found && is_non_empty_word
# should_invoke_callback = is_found && is_non_empty_word
movup.5 and
# => [should_invoke, PROC_ROOT, ASSET_KEY, ASSET_VALUE]
# => [should_invoke_callback, PROC_ROOT, ASSET_KEY, ASSET_VALUE, was_foreign_context_started]

movup.13
# => [was_foreign_context_started, should_invoke_callback, PROC_ROOT, ASSET_KEY, ASSET_VALUE]
end

#! Ends a foreign callback context.
#! Ends a callback context against the faucet if a foreign context was previously started.
#!
#! 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.
#! This wrapper exists only for uniformity with maybe_start_faucet_callback_context.
#!
#! Inputs: []
#! Inputs: [was_foreign_context_started]
#! Outputs: []
proc end_foreign_callback_context
exec.tx::end_foreign_context
#!
#! Where:
#! - was_foreign_context_started is 1 if a foreign context was started, 0 otherwise.
proc maybe_end_faucet_callback_context
# if a foreign context was started, end it
if.true
exec.tx::end_foreign_context
end
end

#! Returns 1 if the provided faucet ID is not the ID of the active account and a foreign context
#! should be started, 0 otherwise.
#!
#! Inputs: [faucet_id_suffix, faucet_id_prefix]
#! Outputs: [should_start_foreign_context]
proc should_start_foreign_context
exec.account::get_id
# => [active_account_id_suffix, active_account_id_prefix, faucet_id_suffix, faucet_id_prefix]

# start a foreign context if the IDs are *not* equal
exec.account_id::is_equal
not
# => [should_start_foreign_context]
end
80 changes: 80 additions & 0 deletions crates/miden-testing/src/kernel_tests/tx/test_callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ use miden_protocol::utils::sync::LazyLock;
use miden_protocol::{Felt, Word};
use miden_standards::account::faucets::BasicFungibleFaucet;
use miden_standards::account::metadata::{FungibleTokenMetadataBuilder, TokenName};
use miden_standards::account::policies::{
BurnPolicyConfig,
MintPolicyConfig,
PolicyAuthority,
TokenPolicyManager,
};
use miden_standards::code_builder::CodeBuilder;
use miden_standards::procedure_digest;
use miden_standards::testing::account_component::MockFaucetComponent;
Expand Down Expand Up @@ -612,6 +618,75 @@ async fn test_on_before_asset_added_to_note_callback_receives_correct_inputs() -
Ok(())
}

/// Tests that consuming a callbacks-enabled asset succeeds when the issuing faucet is itself the
/// target of the callback.
///
/// This is a regression test for https://github.com/0xMiden/protocol/issues/2864.
#[tokio::test]
async fn test_faucet_with_callback_calls_itself() -> anyhow::Result<()> {
let mut builder = MockChain::builder();

let account_callback_masm = r#"
#! Inputs: [ASSET_KEY, ASSET_VALUE, pad(8)]
#! Outputs: [ASSET_VALUE, pad(12)]
pub proc on_before_asset_added_to_account
dropw
# => [ASSET_VALUE, pad(12)]
end
"#;

let note_callback_masm = r#"
#! Inputs: [ASSET_KEY, ASSET_VALUE, note_idx, pad(7)]
#! Outputs: [ASSET_VALUE, pad(12)]
pub proc on_before_asset_added_to_note
dropw movup.4 drop
# => [ASSET_VALUE, pad(12)]
end
"#;

// Build an account that has both callbacks set to no-ops.
let faucet = add_faucet_with_callbacks(
&mut builder,
Some(account_callback_masm),
Some(note_callback_masm),
)?;

let recipient = Word::from([0, 1, 2, 3u32]);
let tag = 0u32;
let amount = 100u64;

let tx_script_code = format!(
"
begin
push.{recipient}
push.{note_type}
push.{tag}
push.{amount}
# => [amount, tag, note_type, RECIPIENT, pad(9)]

call.::miden::standards::faucets::basic_fungible::mint_and_send
# => [note_idx, pad(15)]

# truncate the stack
dropw dropw dropw dropw
end
",
note_type = NoteType::Private as u8,
);

let tx_script = CodeBuilder::default().compile_tx_script(tx_script_code)?;

let mock_chain = builder.build()?;
mock_chain
.build_tx_context(faucet.id(), &[], &[])?
.tx_script(tx_script)
.build()?
.execute()
.await?;

Ok(())
}

// HELPERS
// ================================================================================================

Expand Down Expand Up @@ -704,6 +779,11 @@ fn add_faucet_with_callbacks(
.account_type(AccountType::FungibleFaucet)
.with_component(faucet_metadata)
.with_component(BasicFungibleFaucet)
.with_components(TokenPolicyManager::new(
PolicyAuthority::AuthControlled,
MintPolicyConfig::AllowAll,
BurnPolicyConfig::AllowAll,
))
.with_component(callback_component);

builder.add_account_from_builder(
Expand Down
Loading