diff --git a/CHANGELOG.md b/CHANGELOG.md index 417f30d9f6..b9f33a1fc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Added `AccountComponent::has_procedure(root)` helper ([#2974](https://github.com/0xMiden/protocol/pull/2974)). - Optimized protocol MASM stack-cleaning sequences, saving 1 cycle per occurrence across 9 single-element-extraction procedures ([#3041](https://github.com/0xMiden/protocol/pull/3041)). - [BREAKING] Refactored `TokenPolicyManager` by adding `invoke_send_policy` / `invoke_receive_policy` wrappers (stored in the protocol reserved asset callback slots) that read the active policy root from the new `active_send_policy_proc_root` / `active_receive_policy_proc_root` storage slots ([#3047](https://github.com/0xMiden/protocol/pull/3047)). +- Added regression tests ensuring a `TokenPolicyManager` with only reserved send/receive policies installs the protocol reserved asset callback slots, so `has_callbacks` is correct from creation and minted assets carry the callback flag ([#3091](https://github.com/0xMiden/protocol/pull/3091)). - Added a definition of the Miden operator on the architecture overview page and linked it from the note lifecycle ([#3017](https://github.com/0xMiden/protocol/pull/3017)). - Clarified Miden's operational roles on the architecture overview page and linked them from the note lifecycle ([#3017](https://github.com/0xMiden/protocol/pull/3017)). - [BREAKING] Replaced `AccountInterface::build_send_notes_script` with a standalone `SendNotesTransactionScript` built against `AccountCodeInterface` ([#3055](https://github.com/0xMiden/protocol/pull/3055)). diff --git a/crates/miden-standards/src/account/policies/manager.rs b/crates/miden-standards/src/account/policies/manager.rs index d85fa41f89..22a0e971a4 100644 --- a/crates/miden-standards/src/account/policies/manager.rs +++ b/crates/miden-standards/src/account/policies/manager.rs @@ -197,12 +197,21 @@ struct PolicyConfig { /// /// Construct via [`Self::builder`]. The builder requires the active mint and burn policy /// ([`TokenPolicyManagerBuilder::active_mint_policy`] / -/// [`TokenPolicyManagerBuilder::active_burn_policy`]). Active send / receive policies -/// ([`TokenPolicyManagerBuilder::active_send_policy`] / -/// [`TokenPolicyManagerBuilder::active_receive_policy`]) are optional — when omitted, the -/// protocol-reserved asset-callback slots are not installed, so every minted asset carries -/// [`AssetCallbackFlag::Disabled`][miden_protocol::asset::AssetCallbackFlag::Disabled] and is -/// permanently exempt from any transfer policy installed later. +/// [`TokenPolicyManagerBuilder::active_burn_policy`]). Send / receive policies are optional and may +/// be registered as active ([`TokenPolicyManagerBuilder::active_send_policy`] / +/// [`TokenPolicyManagerBuilder::active_receive_policy`]) and/or as reserved alternatives +/// ([`TokenPolicyManagerBuilder::allowed_send_policy`] / +/// [`TokenPolicyManagerBuilder::allowed_receive_policy`]) for runtime switching. The +/// protocol-reserved asset-callback slots (see the storage layout below) are installed whenever at +/// least one send or receive policy of either kind is registered - active or reserved - so every +/// minted asset carries +/// [`AssetCallbackFlag::Enabled`][miden_protocol::asset::AssetCallbackFlag::Enabled] from creation, +/// even when only reserved policies exist and no active root is set yet. This keeps `has_callbacks` +/// true for the faucet's entire lifetime, so promoting a reserved policy later via +/// `set_send_policy` / `set_receive_policy` enforces it against the whole circulating supply rather +/// than only assets minted after the switch. The slots are omitted only when no send or receive +/// policy of any kind is registered, in which case minted assets carry +/// [`AssetCallbackFlag::Disabled`][miden_protocol::asset::AssetCallbackFlag::Disabled]. /// /// ## Storage layout /// @@ -760,6 +769,57 @@ mod tests { assert_eq!(active_receive_slot.value(), allow_all_root); } + /// Checks that a manager whose send / receive policies are registered only as reserved + /// alternatives (no active transfer policy yet) still installs the protocol-reserved callback + /// slots with the fixed `invoke_*_policy` wrapper roots, so `has_callbacks` is true from + /// creation. + #[test] + fn reserved_only_transfer_policy_registers_protocol_callback_slots() { + let manager = TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::allow_all()) + .active_burn_policy(BurnPolicy::allow_all()) + .allowed_send_policy(TransferPolicy::allow_all()) + .allowed_receive_policy(TransferPolicy::allow_all()) + .build(); + + let manager_component = manager.to_manager_component(); + + // Both callback slots are installed even though no transfer policy is active yet. + let on_account_slot = + find_slot(&manager_component, AssetCallbacks::on_before_asset_added_to_account_slot()) + .expect( + "reserved receive policy must register the on_before_asset_added_to_account \ + protocol callback slot", + ); + let on_note_slot = + find_slot(&manager_component, AssetCallbacks::on_before_asset_added_to_note_slot()) + .expect( + "reserved send policy must register the on_before_asset_added_to_note protocol \ + callback slot", + ); + + // They hold the fixed wrapper roots, identical to the active-policy case. + assert_eq!( + on_account_slot.value(), + TokenPolicyManager::invoke_receive_policy_root().as_word() + ); + assert_eq!(on_note_slot.value(), TokenPolicyManager::invoke_send_policy_root().as_word()); + + // No policy has been activated yet, so the active-policy slots still hold the empty word. + let active_send_slot = + find_slot(&manager_component, TokenPolicyManager::active_send_policy_slot()) + .expect("active send policy slot must be registered"); + let active_receive_slot = + find_slot(&manager_component, TokenPolicyManager::active_receive_policy_slot()) + .expect("active receive policy slot must be registered"); + assert_eq!(active_send_slot.value(), Word::empty()); + assert_eq!(active_receive_slot.value(), Word::empty()); + + // The reserved roots are recorded in the allowed-roots maps so they can be promoted later. + assert!(manager.allowed_send_policies().contains(&TransferPolicy::allow_all().root())); + assert!(manager.allowed_receive_policies().contains(&TransferPolicy::allow_all().root())); + } + /// A manager configured without send / receive policies must NOT register the /// protocol callback slots — otherwise it would always needlessly mint assets with /// callbacks enabled. diff --git a/crates/miden-testing/tests/scripts/faucet.rs b/crates/miden-testing/tests/scripts/faucet.rs index 06d0313ab1..4e8bdc4b98 100644 --- a/crates/miden-testing/tests/scripts/faucet.rs +++ b/crates/miden-testing/tests/scripts/faucet.rs @@ -243,6 +243,42 @@ fn build_network_faucet_with_burn_switching( builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) } +/// Builds an existing public fungible faucet whose send and receive policies are registered only +/// as reserved alternatives, with no active transfer policy. Used to exercise minting on a faucet +/// that has reserved-but-inactive transfer policies. +fn build_existing_faucet_with_reserved_only_transfer_policy( + builder: &mut MockChainBuilder, + token_symbol: &str, + max_supply: u64, + owner: AccountId, +) -> anyhow::Result { + let name = TokenName::new(token_symbol)?; + let symbol = TokenSymbol::new(token_symbol)?; + let max_supply = AssetAmount::new(max_supply)?; + let faucet = FungibleFaucet::builder() + .name(name) + .symbol(symbol) + .decimals(10) + .max_supply(max_supply) + .build()?; + + let token_policy_manager = TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::allow_all()) + .active_burn_policy(BurnPolicy::allow_all()) + .allowed_send_policy(TransferPolicy::allow_all()) + .allowed_receive_policy(TransferPolicy::allow_all()) + .build(); + + let account_builder = AccountBuilder::new(builder.rng_mut().random()) + .account_type(AccountType::Public) + .with_component(faucet) + .with_component(Ownable2Step::new(owner)) + .with_component(Authority::OwnerControlled) + .with_components(token_policy_manager); + + builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) +} + /// Builds a network fungible faucet whose active burn policy is `min_burn_amount`, configured /// with the given threshold. The faucet installs an owner-controlled [`Authority`] so the /// owner-gated `set_min_burn_amount` setter can be exercised, plus the standard transfer @@ -335,6 +371,39 @@ async fn minting_fungible_asset_on_existing_faucet_succeeds() -> anyhow::Result< Ok(()) } +/// Checks that minting on a faucet whose transfer policies are registered only as reserved +/// alternatives still produces assets carrying `AssetCallbackFlag::Enabled`. The mint succeeds and +/// the output asset is enabled only if `has_callbacks` is true from creation. +#[tokio::test] +async fn minting_on_reserved_only_transfer_policy_faucet_enables_callbacks() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let owner_account_id = + AccountId::dummy([1; 15], AccountIdVersion::Version1, AccountType::Private); + + let faucet = build_existing_faucet_with_reserved_only_transfer_policy( + &mut builder, + "RSV", + 1000, + owner_account_id, + )?; + let mut mock_chain = builder.build()?; + + let params = FaucetTestParams { + recipient: Word::from([0, 1, 2, 3u32]), + tag: NoteTag::default(), + note_type: NoteType::Private, + amount: Felt::new_unchecked(100), + }; + + let executed_transaction = + execute_mint_transaction(&mut mock_chain, faucet.clone(), ¶ms).await?; + // `verify_minted_output_note` asserts the minted asset carries `AssetCallbackFlag::Enabled`. + verify_minted_output_note(&executed_transaction, &faucet, ¶ms)?; + + Ok(()) +} + /// Tests that mint fails when the minted amount would exceed the max supply. #[tokio::test] async fn faucet_contract_mint_fungible_asset_fails_exceeds_max_supply() -> anyhow::Result<()> {