diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fdf3d6dbc..aaf9ed729d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - [BREAKING] Added cycle counts to notes returned by `NoteConsumptionInfo` and removed public fields from related types ([#2772](https://github.com/0xMiden/miden-base/issues/2772)). - [BREAKING] Removed unused `payback_attachment` from `SwapNoteStorage` and `attachment` from `MintNoteStorage` ([#2789](https://github.com/0xMiden/protocol/pull/2789)). - Automatically enable `concurrent` feature in `miden-tx` for `std` context ([#2791](https://github.com/0xMiden/protocol/pull/2791)). +- Added `Pausable` standard component with `pause`, `unpause`, `is_paused` procedures and `on_before_asset_added_to_account`, `on_before_asset_added_to_note` callbacks ([#2793](https://github.com/0xMiden/protocol/pull/2793)). - Added `TransactionScript::from_package()` method to create `TransactionScript` from `miden-mast-package::Package` ([#2779](https://github.com/0xMiden/protocol/pull/2779)). ### Fixes diff --git a/crates/miden-standards/asm/account_components/utils/pausable.masm b/crates/miden-standards/asm/account_components/utils/pausable.masm new file mode 100644 index 0000000000..97f52ba1a9 --- /dev/null +++ b/crates/miden-standards/asm/account_components/utils/pausable.masm @@ -0,0 +1,10 @@ +# The MASM code of the Pausable account component. +# +# NOTE: This is a temporary no-auth variant of the component for testing purposes. +# It is intended to be replaced by dedicated owner and role-based access control wrappers. + +pub use ::miden::standards::utils::pausable::is_paused +pub use ::miden::standards::utils::pausable::pause +pub use ::miden::standards::utils::pausable::unpause +pub use ::miden::standards::utils::pausable::on_before_asset_added_to_account +pub use ::miden::standards::utils::pausable::on_before_asset_added_to_note diff --git a/crates/miden-standards/asm/standards/utils/pausable.masm b/crates/miden-standards/asm/standards/utils/pausable.masm new file mode 100644 index 0000000000..054619d85a --- /dev/null +++ b/crates/miden-standards/asm/standards/utils/pausable.masm @@ -0,0 +1,192 @@ +# miden::standards::utils::pausable +# +# Minimal pause flag storage and helpers. Pause/unpause procedures do not perform authorization. +# Compose with ownable2step or role-based access control in a higher layer. +# +# Asset callbacks enforce "not paused" when the issuing faucet has callbacks enabled on the asset. + +use miden::core::word +use miden::protocol::active_account +use miden::protocol::native_account + +# CONSTANTS +# ================================================================================================ + +# The slot where the paused flag is stored as a single word. +# Unpaused: [0, 0, 0, 0]. Paused: [1, 0, 0, 0]. +const IS_PAUSED_SLOT = word("miden::standards::utils::pausable::is_paused") + +const PAUSED_WORD = [1, 0, 0, 0] + +const UNPAUSED_WORD = [0, 0, 0, 0] + +# ERRORS +# ================================================================================================ + +const ERR_PAUSABLE_ENFORCED_PAUSE = "the contract is paused" + +const ERR_PAUSABLE_EXPECTED_PAUSE = "the contract is not paused" + +# PUBLIC INTERFACE +# ================================================================================================ + +#! Returns whether the account is currently paused. +#! +#! Reads [`IS_PAUSED_SLOT`] on the active account and returns `1` if the stored word is non-zero +#! (paused) or `0` if it is the zero word (unpaused). +#! +#! Inputs: [pad(16)] +#! Outputs: [is_paused, pad(15)] +#! +#! Invocation: call +pub proc is_paused + push.IS_PAUSED_SLOT[0..2] + exec.active_account::get_item + # => [is_paused, 0, 0, 0, pad(16)] + + exec.word::eqz not + # => [is_paused, pad(16)] + + swap drop + # => [is_paused, pad(15)] +end + +#! Sets the paused flag. Fails if already paused. +#! +#! This procedure does not verify the caller. Wrap with access control in the account component +#! composition if only privileged accounts should pause. +#! +#! Inputs: [pad(16)] +#! Outputs: [pad(16)] +#! +#! Invocation: call +pub proc pause + exec.assert_not_paused + # => [pad(16)] + + push.PAUSED_WORD + # => [1, 0, 0, 0, pad(16)] + + push.IS_PAUSED_SLOT[0..2] + # => [slot_suffix, slot_prefix, 1, 0, 0, 0, pad(16)] + + exec.native_account::set_item + # => [OLD_WORD, pad(16)] + + dropw + # => [pad(16)] +end + +#! Clears the paused flag. Fails if not paused. +#! +#! This procedure does not verify the caller. +#! +#! Inputs: [pad(16)] +#! Outputs: [pad(16)] +#! +#! Invocation: call +pub proc unpause + exec.assert_paused + # => [pad(16)] + + push.UNPAUSED_WORD + # => [0, 0, 0, 0, pad(16)] + + push.IS_PAUSED_SLOT[0..2] + # => [slot_suffix, slot_prefix, 0, 0, 0, 0, pad(16)] + + exec.native_account::set_item + # => [OLD_WORD, pad(16)] + + dropw + # => [pad(16)] +end + +#! Requires the contract to be unpaused (storage word is the zero word). +#! +#! Reads [`IS_PAUSED_SLOT`] on the active account and applies [`word::eqz`]. If the stored word +#! is non-zero (paused), panics with [`ERR_PAUSABLE_ENFORCED_PAUSE`]. +#! +#! Use from other modules or transaction scripts to guard logic that must not run while paused. +#! In asset callback foreign context, the active account is the issuing faucet. +#! +#! Inputs: [] +#! Outputs: [] +#! +#! Panics if: +#! - the paused-state word is not the zero word. +#! +#! Invocation: exec +pub proc assert_not_paused + push.IS_PAUSED_SLOT[0..2] + exec.active_account::get_item + # => [is_paused, 0, 0, 0] + + exec.word::eqz + # => [is_unpaused] + + assert.err=ERR_PAUSABLE_ENFORCED_PAUSE + # => [] +end + +#! Requires the contract to be paused (storage word is not the zero word). +#! +#! Reads [`IS_PAUSED_SLOT`] on the active account, then [`word::eqz`] and inverts. If the +#! stored word is zero (unpaused), panics with [`ERR_PAUSABLE_EXPECTED_PAUSE`]. +#! +#! Typical use: guard `unpause` so clearing the flag only happens from a paused state. +#! +#! Inputs: [] +#! Outputs: [] +#! +#! Panics if: +#! - the paused-state word is the zero word. +#! +#! Invocation: exec +pub proc assert_paused + push.IS_PAUSED_SLOT[0..2] + exec.active_account::get_item + # => [is_paused, 0, 0, 0] + + exec.word::eqz not + # => [is_paused] + + assert.err=ERR_PAUSABLE_EXPECTED_PAUSE + # => [] +end + +# CALLBACKS +# ================================================================================================ + +#! Callback when a callbacks-enabled asset is added to an account vault. +#! +#! Panics if this faucet account is paused (reads `IS_PAUSED_SLOT` on the active account, +#! which is the issuing faucet in the callback foreign context). +#! +#! Inputs: [ASSET_KEY, ASSET_VALUE, pad(8)] +#! Outputs: [ASSET_VALUE, pad(12)] +#! +#! Invocation: call +pub proc on_before_asset_added_to_account + exec.assert_not_paused + # => [ASSET_KEY, ASSET_VALUE, pad(8)] + + dropw + # => [ASSET_VALUE, pad(12)] +end + +#! Callback when a callbacks-enabled asset is added to an output note. +#! +#! Panics if this faucet account is paused. +#! +#! 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_not_paused + # => [ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] + + dropw + # => [ASSET_VALUE, note_idx, pad(7)] +end diff --git a/crates/miden-standards/src/account/components/mod.rs b/crates/miden-standards/src/account/components/mod.rs index e8a8bea866..b6a8179a98 100644 --- a/crates/miden-standards/src/account/components/mod.rs +++ b/crates/miden-standards/src/account/components/mod.rs @@ -97,6 +97,13 @@ static NETWORK_FUNGIBLE_FAUCET_LIBRARY: LazyLock = LazyLock::new(|| { Library::read_from_bytes(bytes).expect("Shipped Network Fungible Faucet library is well-formed") }); +// Initialize the Pausable library only once. +static PAUSABLE_LIBRARY: LazyLock = LazyLock::new(|| { + let bytes = + include_bytes!(concat!(env!("OUT_DIR"), "/assets/account_components/utils/pausable.masl")); + Library::read_from_bytes(bytes).expect("Shipped Pausable library is well-formed") +}); + // Initialize the Fungible Token Metadata library only once. static FUNGIBLE_TOKEN_METADATA_LIBRARY: LazyLock = LazyLock::new(|| { let bytes = include_bytes!(concat!( @@ -169,6 +176,11 @@ pub fn network_fungible_faucet_library() -> Library { NETWORK_FUNGIBLE_FAUCET_LIBRARY.clone() } +/// Returns the Pausable component library. +pub fn pausable_library() -> Library { + PAUSABLE_LIBRARY.clone() +} + /// Returns the Fungible Token Metadata Library. pub fn fungible_token_metadata_library() -> Library { FUNGIBLE_TOKEN_METADATA_LIBRARY.clone() diff --git a/crates/miden-standards/src/account/mod.rs b/crates/miden-standards/src/account/mod.rs index e03614bab8..d02f754f66 100644 --- a/crates/miden-standards/src/account/mod.rs +++ b/crates/miden-standards/src/account/mod.rs @@ -8,6 +8,7 @@ pub mod faucets; pub mod interface; pub mod metadata; pub mod mint_policies; +pub mod pausable; pub mod policy_manager; pub mod wallets; diff --git a/crates/miden-standards/src/account/pausable.rs b/crates/miden-standards/src/account/pausable.rs new file mode 100644 index 0000000000..2ceac7ab70 --- /dev/null +++ b/crates/miden-standards/src/account/pausable.rs @@ -0,0 +1,183 @@ +use alloc::vec::Vec; + +use miden_protocol::account::component::{ + AccountComponentMetadata, + FeltSchema, + StorageSchema, + StorageSlotSchema, +}; +use miden_protocol::account::{AccountComponent, AccountType, StorageSlot, StorageSlotName}; +use miden_protocol::asset::AssetCallbacks; +use miden_protocol::utils::sync::LazyLock; +use miden_protocol::{Felt, Word}; + +use crate::account::components::pausable_library; +use crate::procedure_digest; + +// PAUSABLE ACCOUNT COMPONENT +// ================================================================================================ + +static IS_PAUSED_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::utils::pausable::is_paused") + .expect("storage slot name should be valid") +}); + +procedure_digest!( + PAUSABLE_IS_PAUSED, + Pausable::NAME, + Pausable::IS_PAUSED_PROC_NAME, + pausable_library +); + +procedure_digest!(PAUSABLE_PAUSE, Pausable::NAME, Pausable::PAUSE_PROC_NAME, pausable_library); + +procedure_digest!(PAUSABLE_UNPAUSE, Pausable::NAME, Pausable::UNPAUSE_PROC_NAME, pausable_library); + +procedure_digest!( + PAUSABLE_ON_BEFORE_ASSET_ADDED_TO_ACCOUNT, + Pausable::NAME, + Pausable::ON_BEFORE_ASSET_ADDED_TO_ACCOUNT_PROC_NAME, + pausable_library +); + +procedure_digest!( + PAUSABLE_ON_BEFORE_ASSET_ADDED_TO_NOTE, + Pausable::NAME, + Pausable::ON_BEFORE_ASSET_ADDED_TO_NOTE_PROC_NAME, + pausable_library +); + +/// Account component that stores a pause flag and registers asset callbacks that reject transfers +/// while paused. +/// +/// `pause` and `unpause` do not authenticate the caller — this is an intentional choice: +/// the core mechanism is kept without access control so that owner and role-based access control +/// can be implemented on top without duplicating the pause/unpause. +/// +/// ## Storage +/// +/// - [`Self::is_paused_slot()`]: single word; all zeros means unpaused, `[1,0,0,0]` means paused +/// (see MASM `miden::standards::utils::pausable`). +/// - Protocol callback slots from [`AssetCallbacks`] registered by every constructor. +#[derive(Debug, Clone, Copy, Default)] +pub struct Pausable { + initial_state: bool, +} + +impl Pausable { + /// Component library path (merged account module name). + pub const NAME: &'static str = "miden::standards::components::utils::pausable"; + + const IS_PAUSED_PROC_NAME: &'static str = "is_paused"; + const PAUSE_PROC_NAME: &'static str = "pause"; + const UNPAUSE_PROC_NAME: &'static str = "unpause"; + const ON_BEFORE_ASSET_ADDED_TO_ACCOUNT_PROC_NAME: &'static str = + "on_before_asset_added_to_account"; + const ON_BEFORE_ASSET_ADDED_TO_NOTE_PROC_NAME: &'static str = "on_before_asset_added_to_note"; + + /// Creates a new [`Pausable`] with the given initial paused state. + /// + /// Use this constructor when the flag comes from configuration, CLI input, a registry, etc. + /// For literal values prefer [`Self::paused`] / [`Self::unpaused`] (or [`Self::default`] for + /// the unpaused default). + pub const fn new(initial_state: bool) -> Self { + Self { initial_state } + } + + /// Creates a new [`Pausable`] that starts in the paused state. + pub const fn paused() -> Self { + Self::new(true) + } + + /// Creates a new [`Pausable`] that starts in the unpaused state. + /// + /// Equivalent to [`Self::default`]; provided as an explicit literal form for call sites that + /// prefer spelling out the initial state. + pub const fn unpaused() -> Self { + Self::new(false) + } + + /// Storage slot name for the pause flag word. + pub fn is_paused_slot() -> &'static StorageSlotName { + &IS_PAUSED_SLOT_NAME + } + + /// Schema entry for the pause flag slot (documentation / tooling). + pub fn is_paused_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::is_paused_slot().clone(), + StorageSlotSchema::value( + "Pause flag word; zero is unpaused, canonical paused encoding is [1,0,0,0]", + [ + FeltSchema::felt("w0").with_default(Felt::ZERO), + FeltSchema::felt("w1").with_default(Felt::ZERO), + FeltSchema::felt("w2").with_default(Felt::ZERO), + FeltSchema::felt("w3").with_default(Felt::ZERO), + ], + ), + ) + } + + /// Metadata for accounts that include this component (faucet types that may issue + /// callback-enabled assets). + pub fn component_metadata() -> AccountComponentMetadata { + let storage_schema = StorageSchema::new([Self::is_paused_slot_schema()]) + .expect("storage schema should be valid"); + + AccountComponentMetadata::new( + Self::NAME, + [AccountType::FungibleFaucet, AccountType::NonFungibleFaucet], + ) + .with_description( + "Pausable component: pause/unpause and on_before_asset_added callbacks without auth", + ) + .with_storage_schema(storage_schema) + } + + pub fn is_paused_digest() -> Word { + *PAUSABLE_IS_PAUSED + } + + pub fn pause_digest() -> Word { + *PAUSABLE_PAUSE + } + + pub fn unpause_digest() -> Word { + *PAUSABLE_UNPAUSE + } + + pub fn on_before_asset_added_to_account_digest() -> Word { + *PAUSABLE_ON_BEFORE_ASSET_ADDED_TO_ACCOUNT + } + + pub fn on_before_asset_added_to_note_digest() -> Word { + *PAUSABLE_ON_BEFORE_ASSET_ADDED_TO_NOTE + } +} + +impl From for AccountComponent { + fn from(pausable: Pausable) -> Self { + let initial_word = if pausable.initial_state { + Word::from([1u32, 0, 0, 0]) + } else { + Word::default() + }; + + let is_paused_slot = + StorageSlot::with_value(Pausable::is_paused_slot().clone(), initial_word); + let callback_slots = AssetCallbacks::new() + .on_before_asset_added_to_account(Pausable::on_before_asset_added_to_account_digest()) + .on_before_asset_added_to_note(Pausable::on_before_asset_added_to_note_digest()) + .into_storage_slots(); + + let mut storage_slots = Vec::with_capacity(1 + callback_slots.len()); + storage_slots.push(is_paused_slot); + storage_slots.extend(callback_slots); + + let metadata = Pausable::component_metadata(); + + AccountComponent::new(pausable_library(), storage_slots, metadata).expect( + "pausable component should satisfy the requirements of a valid account component", + ) + } +} diff --git a/crates/miden-testing/tests/scripts/mod.rs b/crates/miden-testing/tests/scripts/mod.rs index 9b8c3e12e5..5d6368a631 100644 --- a/crates/miden-testing/tests/scripts/mod.rs +++ b/crates/miden-testing/tests/scripts/mod.rs @@ -3,6 +3,7 @@ mod fee; mod ownable2step; mod p2id; mod p2ide; +mod pausable; mod pswap; mod send_note; mod swap; diff --git a/crates/miden-testing/tests/scripts/pausable.rs b/crates/miden-testing/tests/scripts/pausable.rs new file mode 100644 index 0000000000..eb9c94473f --- /dev/null +++ b/crates/miden-testing/tests/scripts/pausable.rs @@ -0,0 +1,383 @@ +//! Tests for [`miden_standards::account::pausable::Pausable`] asset callbacks and pause/unpause +//! scripts. + +extern crate alloc; + +use miden_protocol::Word; +use miden_protocol::account::auth::AuthScheme; +use miden_protocol::account::{ + Account, + AccountBuilder, + AccountComponent, + AccountId, + AccountStorageMode, + AccountType, +}; +use miden_protocol::asset::{ + Asset, + AssetCallbackFlag, + FungibleAsset, + NonFungibleAsset, + NonFungibleAssetDetails, +}; +use miden_protocol::errors::MasmError; +use miden_protocol::note::{NoteTag, NoteType}; +use miden_standards::account::faucets::BasicFungibleFaucet; +use miden_standards::account::metadata::{FungibleTokenMetadataBuilder, TokenName}; +use miden_standards::account::pausable::Pausable; +use miden_standards::code_builder::CodeBuilder; +use miden_standards::testing::account_component::MockFaucetComponent; +use miden_testing::{ + AccountState, + Auth, + MockChain, + MockChainBuilder, + assert_transaction_executor_error, +}; + +const ERR_PAUSABLE_ENFORCED_PAUSE: MasmError = MasmError::from_static_str("the contract is paused"); + +const ERR_PAUSABLE_EXPECTED_PAUSE: MasmError = + MasmError::from_static_str("the contract is not paused"); + +fn add_faucet_with_pausable(builder: &mut MockChainBuilder) -> anyhow::Result { + let faucet_metadata = FungibleTokenMetadataBuilder::new( + TokenName::new("SYM")?, + "SYM".try_into()?, + 8, + 1_000_000u64, + ) + .build()?; + + let account_builder = AccountBuilder::new([43u8; 32]) + .storage_mode(AccountStorageMode::Public) + .account_type(AccountType::FungibleFaucet) + .with_component(faucet_metadata) + .with_component(BasicFungibleFaucet) + .with_component(Pausable::default()); + + builder.add_account_from_builder( + Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + }, + account_builder, + AccountState::Exists, + ) +} + +fn add_faucet_with_pausable_for_account_type( + builder: &mut MockChainBuilder, + account_type: AccountType, +) -> anyhow::Result { + if !account_type.is_faucet() { + anyhow::bail!("account type must be a faucet"); + } + + let faucet_components: Vec = match account_type { + AccountType::FungibleFaucet => { + let faucet_metadata = FungibleTokenMetadataBuilder::new( + TokenName::new("SYM")?, + "SYM".try_into()?, + 8, + 1_000_000u64, + ) + .build()?; + vec![faucet_metadata.into(), BasicFungibleFaucet.into()] + }, + AccountType::NonFungibleFaucet => vec![MockFaucetComponent.into()], + _ => anyhow::bail!("pausable tests only use fungible or non-fungible faucet account types"), + }; + + let mut account_builder = AccountBuilder::new([43u8; 32]) + .storage_mode(AccountStorageMode::Public) + .account_type(account_type); + for component in faucet_components { + account_builder = account_builder.with_component(component); + } + account_builder = account_builder.with_component(Pausable::default()); + + builder.add_account_from_builder( + Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + }, + account_builder, + AccountState::Exists, + ) +} + +async fn execute_faucet_pause( + mock_chain: &mut MockChain, + faucet_id: AccountId, +) -> anyhow::Result<()> { + let pause_script = r#" + begin + padw padw push.0 + call.::miden::standards::utils::pausable::pause + dropw dropw dropw dropw + end + "#; + let tx_script = CodeBuilder::default().compile_tx_script(pause_script)?; + let executed = mock_chain + .build_tx_context(faucet_id, &[], &[])? + .tx_script(tx_script) + .build()? + .execute() + .await?; + mock_chain.add_pending_executed_transaction(&executed)?; + mock_chain.prove_next_block()?; + Ok(()) +} + +async fn execute_faucet_unpause( + mock_chain: &mut MockChain, + faucet_id: AccountId, +) -> anyhow::Result<()> { + let unpause_script = r#" + begin + padw padw push.0 + call.::miden::standards::utils::pausable::unpause + dropw dropw dropw dropw + end + "#; + let tx_script = CodeBuilder::default().compile_tx_script(unpause_script)?; + let executed = mock_chain + .build_tx_context(faucet_id, &[], &[])? + .tx_script(tx_script) + .build()? + .execute() + .await?; + mock_chain.add_pending_executed_transaction(&executed)?; + mock_chain.prove_next_block()?; + Ok(()) +} + +#[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 pausable_receive_asset_succeeds_when_unpaused( + #[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_pausable_for_account_type(&mut builder, account_type)?; + + let note = builder.add_p2id_note( + faucet.id(), + target_account.id(), + &[create_asset(faucet.id())?], + NoteType::Public, + )?; + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + + mock_chain + .build_tx_context(target_account.id(), &[note.id()], &[])? + .foreign_accounts(vec![faucet_inputs]) + .build()? + .execute() + .await?; + + Ok(()) +} + +#[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 pausable_receive_asset_fails_when_paused( + #[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_pausable_for_account_type(&mut builder, account_type)?; + + let note = builder.add_p2id_note( + faucet.id(), + target_account.id(), + &[create_asset(faucet.id())?], + NoteType::Public, + )?; + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + execute_faucet_pause(&mut mock_chain, faucet.id()).await?; + + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + + let result = mock_chain + .build_tx_context(target_account.id(), &[note.id()], &[])? + .foreign_accounts(vec![faucet_inputs]) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_PAUSABLE_ENFORCED_PAUSE); + + Ok(()) +} + +#[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 pausable_add_asset_to_note_fails_when_paused( + #[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_pausable_for_account_type(&mut builder, account_type)?; + + let asset = create_asset(faucet.id())?; + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + execute_faucet_pause(&mut mock_chain, faucet.id()).await?; + + 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 tx_script = CodeBuilder::with_mock_libraries().compile_tx_script(&script_code)?; + + 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_PAUSABLE_ENFORCED_PAUSE); + + Ok(()) +} + +#[tokio::test] +async fn pausable_pause_then_unpause_then_receive_succeeds() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; + let faucet = add_faucet_with_pausable(&mut builder)?; + + let amount: u64 = 50; + let fungible_asset = + FungibleAsset::new(faucet.id(), amount)?.with_callbacks(AssetCallbackFlag::Enabled); + let note = builder.add_p2id_note( + faucet.id(), + target_account.id(), + &[Asset::Fungible(fungible_asset)], + NoteType::Public, + )?; + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + execute_faucet_pause(&mut mock_chain, faucet.id()).await?; + execute_faucet_unpause(&mut mock_chain, faucet.id()).await?; + + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + + mock_chain + .build_tx_context(target_account.id(), &[note.id()], &[])? + .foreign_accounts(vec![faucet_inputs]) + .build()? + .execute() + .await?; + + Ok(()) +} + +#[tokio::test] +async fn pausable_unpause_while_unpaused_fails() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let _wallet = builder.add_existing_wallet(Auth::IncrNonce)?; + let faucet = add_faucet_with_pausable(&mut builder)?; + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let unpause_script = r#" + begin + padw padw push.0 + call.::miden::standards::utils::pausable::unpause + dropw dropw dropw dropw + end + "#; + let tx_script = CodeBuilder::default().compile_tx_script(unpause_script)?; + + let result = mock_chain + .build_tx_context(faucet.id(), &[], &[])? + .tx_script(tx_script) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_PAUSABLE_EXPECTED_PAUSE); + + Ok(()) +}