Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
671eeea
feat(protocol): derive the asset callback flag from installed callbac…
onurinanc Aug 21, 2026
172424b
feat(protocol): require an enabled asset callback flag when a callbac…
onurinanc Aug 21, 2026
ebca364
docs(protocol): record the callback slot invariant in the account upg…
onurinanc Aug 21, 2026
c78a467
refactor: drop manual asset callback flag wiring from account factories
onurinanc Aug 21, 2026
a184449
chore: add changelog entry for the asset callback slot invariant
onurinanc Aug 21, 2026
396da34
changelog
onurinanc Aug 21, 2026
6ed08a6
remove comments
onurinanc Aug 21, 2026
4806c4e
refactor(protocol): keep the builder's asset callback field typed as …
onurinanc Aug 21, 2026
53e5f10
fix comments
onurinanc Aug 21, 2026
e4fa0b6
fix comments
onurinanc Aug 21, 2026
4ae9bbd
Merge remote-tracking branch 'origin' into refactor-callback-fix
onurinanc Aug 21, 2026
708a1b2
merge
onurinanc Aug 24, 2026
bcff8f8
refactor(protocol): address review comments on the asset callback inv…
onurinanc Aug 24, 2026
912ef1d
docs(standards): document why TokenPolicyManager installs callback sl…
onurinanc Aug 24, 2026
434685a
test: simplify the callback flag prologue test to a full transaction …
onurinanc Aug 24, 2026
83a7f69
docs(protocol): separate asset callback dispatch from callback presence
onurinanc Aug 24, 2026
e5db4dd
docs(standards): drop the AccountBuilder implementation detail from t…
onurinanc Aug 24, 2026
baf1ffc
fix(agglayer): require the reserved asset callback slots on AggLayer …
onurinanc Aug 24, 2026
78a1ce1
Merge remote-tracking branch 'origin' into refactor-callback-fix
onurinanc Aug 26, 2026
10e46b2
refactor(protocol): rename AccountStorage::has_callbacks to has_callb…
onurinanc Aug 26, 2026
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 @@ -30,6 +30,7 @@
- [BREAKING] AggLayer bridge and faucet account builders now take a concrete `BasicConstantFeePolicy` and fee faucet ID, constructing their `FeePolicyManager` internally ([#3583](https://github.com/0xMiden/protocol/pull/3583)).
- [BREAKING] The transaction kernel no longer requires assets with `AssetComposition::None` to have the non-fungible asset layout ([#3624](https://github.com/0xMiden/protocol/pull/3624)).
- [BREAKING] Refactored `Asset` into a struct holding `AssetId` and `AssetValue` ([#3625](https://github.com/0xMiden/protocol/pull/3625)).
- [BREAKING] Refactored the presence of an asset callback slot imply an enabled asset callback flag: the transaction kernel rejects new accounts that violate this and `AccountBuilder` derives the flag from the installed callback slots, replacing `with_asset_callbacks` with `enable_asset_callbacks` ([#3658](https://github.com/0xMiden/protocol/pull/3658)).
- Moved the transaction kernel API procedures into the kernel's `api` submodule, leaving `exec_kernel_proc` as the only `syscall`-invocable kernel procedure ([#3646](https://github.com/0xMiden/protocol/pull/3646)).
- Documented that standard note scripts claim only the assets remaining in a note at consumption time ([#3650](https://github.com/0xMiden/protocol/pull/3650)).
- [BREAKING] Moved the kernel data section of the transaction kernel memory to address `0`, so that `exec_kernel_proc` becomes reusable across multiple kernels ([#3655](https://github.com/0xMiden/protocol/pull/3655)).
Expand Down
4 changes: 0 additions & 4 deletions bin/bench-transaction/src/context_setups/network_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ use miden_protocol::account::{
AccountComponent,
AccountId,
AccountType,
AssetCallbackFlag,
RoleSymbol,
};
use miden_protocol::asset::AssetAmount;
Expand Down Expand Up @@ -101,7 +100,6 @@ pub fn tx_consume_faucet_policy_config_note_network() -> Result<MockTransaction>
.with_component(faucet)
.with_component(Ownable2Step::new(owner.id()))
.with_component(Authority::OwnerControlled)
.with_asset_callbacks(AssetCallbackFlag::from(token_policy_manager.has_transfer_policy()))
.with_components(token_policy_manager)
.with_assets([super::fee_funding_asset()?]);
let account = builder.add_account_from_builder(
Expand Down Expand Up @@ -221,7 +219,6 @@ pub fn tx_consume_min_burn_amount_config_note_network() -> Result<MockTransactio
.with_component(faucet)
.with_component(Ownable2Step::new(owner.id()))
.with_component(Authority::OwnerControlled)
.with_asset_callbacks(AssetCallbackFlag::from(token_policy_manager.has_transfer_policy()))
.with_components(token_policy_manager)
.with_assets([super::fee_funding_asset()?]);
let account = builder.add_account_from_builder(
Expand Down Expand Up @@ -362,7 +359,6 @@ fn tx_consume_list_config_note_network(list: ListKind) -> Result<MockTransaction
.with_component(faucet)
.with_component(Ownable2Step::new(owner.id()))
.with_component(Authority::OwnerControlled)
.with_asset_callbacks(AssetCallbackFlag::from(token_policy_manager.has_transfer_policy()))
.with_components(token_policy_manager)
.with_component(list.manager())
.with_assets([super::fee_funding_asset()?]);
Expand Down
3 changes: 1 addition & 2 deletions bin/bench-transaction/src/context_setups/network_faucet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use std::sync::Arc;

use anyhow::Result;
use miden_protocol::Felt;
use miden_protocol::account::{Account, AccountBuilder, AccountId, AccountType, AssetCallbackFlag};
use miden_protocol::account::{Account, AccountBuilder, AccountId, AccountType};
use miden_protocol::asset::{AssetId, FungibleAsset, NonFungibleAsset, TokenSymbol};
use miden_protocol::crypto::merkle::smt::SmtProof;
use miden_protocol::crypto::rand::FeltRng;
Expand Down Expand Up @@ -98,7 +98,6 @@ fn add_fee_funded_network_non_fungible_faucet(

let account_builder = AccountBuilder::new(builder.rng_mut().random())
.account_type(AccountType::Public)
.with_asset_callbacks(AssetCallbackFlag::from(token_policy_manager.has_transfer_policy()))
.with_component(faucet)
.with_components(AccessControl::Ownable2Step { owner: owner_account_id })
.with_components(token_policy_manager)
Expand Down
14 changes: 11 additions & 3 deletions crates/miden-agglayer/src/faucet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use miden_protocol::account::{
StorageSlot,
StorageSlotName,
};
use miden_protocol::asset::{AssetAmount, TokenSymbol};
use miden_protocol::asset::{AssetAmount, AssetCallbacks, TokenSymbol};
use miden_protocol::errors::AccountIdError;
use miden_protocol::note::NoteScriptRoot;
use miden_standards::account::access::{Authority, Ownable2Step};
Expand Down Expand Up @@ -294,7 +294,7 @@ impl AggLayerFaucet {

/// Returns a vector of all [`AggLayerFaucet`] storage slot names.
fn slot_names() -> Vec<&'static StorageSlotName> {
vec![
let mut slot_names = vec![
FungibleFaucet::token_config_slot(),
Ownable2Step::slot_name(),
Authority::authority_slot(),
Expand All @@ -304,7 +304,15 @@ impl AggLayerFaucet {
TokenPolicyManager::allowed_burn_policies_slot(),
TokenPolicyManager::allowed_send_policies_slot(),
TokenPolicyManager::allowed_receive_policies_slot(),
]
];

// The faucet registers send and receive transfer policies, so its policy manager installs
// the protocol-reserved asset callback slots. Their presence is what makes the account ID
// carry an enabled asset callback flag, so requiring them certifies that the faucet's
// transfer policies can be invoked at all.
slot_names.extend(AssetCallbacks::slot_names());

slot_names
}
}

Expand Down
32 changes: 29 additions & 3 deletions crates/miden-agglayer/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
extern crate alloc;

use miden_core::{Felt, Word};
use miden_protocol::account::{AccountBuilder, AccountComponent, AccountId, AssetCallbackFlag};
use miden_protocol::account::{AccountBuilder, AccountComponent, AccountId};
use miden_protocol::assembly::Path;
use miden_protocol::asset::TokenSymbol;
use miden_protocol::note::NoteScript;
Expand Down Expand Up @@ -235,7 +235,6 @@ impl AggLayerFaucet {
.active_receive_policy(TransferPolicy::allow_all())
.build();

let asset_callbacks = AssetCallbackFlag::from(token_policy_manager.has_transfer_policy());
let rbac = RoleBasedAccessControl::builder()
.role(RoleConfig::new(RoleBasedAccessControl::admin_role()).with_member(faucet_admin))
.role(RoleConfig::new(AggLayerFaucet::fee_manager_role()).with_member(fee_manager))
Expand All @@ -244,7 +243,6 @@ impl AggLayerFaucet {

NetworkAccount::builder(seed.into(), AggLayerFaucet::allowed_notes(), fee_policy_manager)
.expect("faucet note allowlist is non-empty")
.with_asset_callbacks(asset_callbacks)
.with_component(agglayer_component)
.with_component(Ownable2Step::new(bridge_account_id))
.with_component(rbac)
Expand All @@ -261,6 +259,8 @@ impl AggLayerFaucet {

#[cfg(test)]
mod tests {
use miden_protocol::account::AssetCallbackFlag;
use miden_protocol::asset::AssetCallbacks;
use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE;
use miden_standards::tx_script::ExpirationTransactionScript;

Expand All @@ -270,6 +270,32 @@ mod tests {
create_existing_bridge_account_with_roles,
};

/// The agglayer faucet registers send and receive transfer policies, so its policy manager
/// installs the protocol-reserved asset callback slots and its account ID must carry an enabled
/// asset callback flag. Without the flag the kernel would never invoke those policies.
#[test]
fn agglayer_faucet_has_asset_callbacks_enabled() {
let id = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap();

let faucet = create_existing_agglayer_faucet(
Word::default(),
"AGG",
6,
Felt::from(1000u32),
Felt::ZERO,
id,
id,
);

for slot_name in AssetCallbacks::slot_names() {
assert!(
faucet.storage().get(slot_name).is_some(),
"faucet should install the {slot_name} callback slot"
);
}
assert_eq!(faucet.id().asset_callback_flag(), AssetCallbackFlag::Enabled);
}

/// Both agglayer network accounts allowlist the canonical [`ExpirationTransactionScript`],
/// which the network transaction builder attaches to every network transaction.
#[test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ use miden::tx_kernel_core::asset_vault
use miden::tx_kernel_core::asset
use {ASSET_SIZE} from miden::tx_kernel_core::asset
use {ACCOUNT_VERSION_1} from miden::tx_kernel_core::account
use {ON_BEFORE_ASSET_ADDED_TO_ACCOUNT_PROC_ROOT_SLOT, ON_BEFORE_ASSET_ADDED_TO_NOTE_PROC_ROOT_SLOT}
from miden::tx_kernel_core::callbacks
use {EMPTY_SMT_ROOT, MAX_ASSETS_PER_NOTE, MAX_INPUT_NOTES_PER_TX, MAX_NOTE_STORAGE_ITEMS, NOTE_TREE_DEPTH}
from miden::tx_kernel_core::constants
use miden::tx_kernel_core::memory
Expand Down Expand Up @@ -67,6 +69,9 @@ const ERR_PROLOGUE_NEW_ACCOUNT_NONCE_MUST_BE_ZERO = "new account must have a zer
const ERR_PROLOGUE_NEW_ACCOUNT_UNSUPPORTED_VERSION =
"new account metadata has an unsupported version"

const ERR_PROLOGUE_CALLBACK_SLOT_REQUIRES_ENABLED_ASSET_CALLBACK_FLAG =
"an account whose storage contains an asset callback slot must have the asset callback flag enabled"

const ERR_PROLOGUE_NUMBER_OF_NOTE_STORAGE_ITEMS_EXCEEDED_LIMIT =
"number of note storage items exceeded the maximum limit of 1024"

Expand Down Expand Up @@ -307,6 +312,7 @@ end
#! - assert that the account nonce is set to 0.
#! - read the account seed from the advice provider and assert it satisfies seed requirements.
#! - assert that the storage slots and the account procedures are sorted and unique.
#! - assert that the asset callback flag is enabled if an asset callback slot is present.
#!
#! Validating storage and procedures for new accounts is sufficient because the storage and code
#! commitments of an existing account are bound to its committed state, which was produced by the
Expand Down Expand Up @@ -357,6 +363,51 @@ proc validate_new_account
# ---------------------------------------------------------------------------------------------
exec.account::validate_procedures
# => []

# Assert the asset callback flag is consistent with the installed asset callback slots.
# ---------------------------------------------------------------------------------------------
exec.validate_asset_callbacks
# => []
end

#! Validates that an account whose storage contains an asset callback slot has the asset callback
#! flag of its account ID enabled.
#!
#! The kernel decides whether to invoke an account's asset callbacks solely from the asset callback
#! flag encoded in its account ID, and that flag is immutable once the ID is ground. A callback slot
#! installed on an account whose flag is disabled would therefore look correctly configured while
#! never being invoked, silently and permanently disabling whatever the callback enforces.
#!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think not requiring slots when the account ID flag is enabled actually makes sense. This retains the ability to add new types of callback slots that are introduced in a later protocol version.

I'd add something like this here:

`has_callback_slot` must imply `has_callbacks`, but not vice versa. That is, the callback flag
can be enabled without callback slots present. This is allowed so that an account retains the
ability to add a callback slot via an account upgrade later, which is particularly useful if new
types of callbacks are introduced.

#! `has_callback_slot` must imply `has_callbacks`, but not vice versa. That is, the callback flag
#! can be enabled without callback slots present. This is allowed so that an account retains the
#! ability to add a callback slot via an account upgrade later, which is particularly useful if new
#! types of callbacks are introduced.
#!
#! Inputs: []
#! Outputs: []
#!
#! Panics if:
#! - the account's storage contains an asset callback slot but its asset callback flag is disabled.
proc validate_asset_callbacks
push.ON_BEFORE_ASSET_ADDED_TO_ACCOUNT_PROC_ROOT_SLOT[0..2] exec.account::has_storage_slot
# => [has_account_callback_slot]

push.ON_BEFORE_ASSET_ADDED_TO_NOTE_PROC_ROOT_SLOT[0..2] exec.account::has_storage_slot
# => [has_note_callback_slot, has_account_callback_slot]

or
# => [has_callback_slot]

exec.memory::get_native_account_id drop exec.account_id::asset_callback_flag
# => [has_callbacks, has_callback_slot]

# the flag must be enabled if a callback slot is present, so reject the case where a slot is
# present but the flag is disabled
not and
# => [is_inconsistent]

assertz.err=ERR_PROLOGUE_CALLBACK_SLOT_REQUIRES_ENABLED_ASSET_CALLBACK_FLAG
# => []
end

#! Saves the account data to memory and validates it.
Expand Down
5 changes: 5 additions & 0 deletions crates/miden-protocol/asm/kernels/transaction/lib/api.masm
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,11 @@ end
pub proc account_upgrade
# TODO(code_upgrades): Account upgrades must ensure the same conditions hold for an upgraded
# account as validated in account::{validate_storage, validate_procedures}.
# The same applies to the asset callback rule validated in
# prologue::validate_asset_callbacks: an upgrade must reject adding an asset callback slot to an
# account whose asset callback flag is disabled. Such a callback is not invalid, it simply could
# never be invoked, since the flag is immutable, which leaves the account looking configured
# while nothing is enforced - the state the creation-time check rejects.
# check that this procedure was executed against the native account
exec.memory::assert_native_account
# => [CODE_UPGRADE_COMMITMENT, STORAGE_UPGRADE_COMMITMENT, pad(8)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
/// When [`Enabled`](Self::Enabled), the kernel dispatches the faucet's callbacks whenever one of
/// its assets is added to a vault or note. When [`Disabled`](Self::Disabled), callbacks are skipped
/// entirely and no foreign-account read is performed.
///
/// The flag only enables the dispatch. On dispatch, the kernel reads the callback's procedure root
/// from the faucet's storage and skips the invocation if the callback slot is absent or holds the
/// empty word, so [`Enabled`](Self::Enabled) means callbacks may be invoked for the faucet's
/// assets, not that the faucet has any.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[repr(u8)]
pub enum AssetCallbackFlag {
Expand Down
Loading
Loading