From 8ce07e2c58f284eacb3d71560b306388f5a518df Mon Sep 17 00:00:00 2001 From: onurinanc Date: Thu, 23 Apr 2026 14:21:49 +0200 Subject: [PATCH 1/2] rename blocklist -> blocklistable --- .../utils/blocklistable.masm | 9 + .../asm/standards/utils/blocklistable.masm | 234 +++++++++ .../src/account/blocklistable.rs | 165 ++++++ .../src/account/components/mod.rs | 17 + crates/miden-standards/src/account/mod.rs | 1 + .../tests/scripts/blocklistable.rs | 479 ++++++++++++++++++ crates/miden-testing/tests/scripts/mod.rs | 1 + 7 files changed, 906 insertions(+) create mode 100644 crates/miden-standards/asm/account_components/utils/blocklistable.masm create mode 100644 crates/miden-standards/asm/standards/utils/blocklistable.masm create mode 100644 crates/miden-standards/src/account/blocklistable.rs create mode 100644 crates/miden-testing/tests/scripts/blocklistable.rs diff --git a/crates/miden-standards/asm/account_components/utils/blocklistable.masm b/crates/miden-standards/asm/account_components/utils/blocklistable.masm new file mode 100644 index 0000000000..9c2c5467b5 --- /dev/null +++ b/crates/miden-standards/asm/account_components/utils/blocklistable.masm @@ -0,0 +1,9 @@ +# The MASM code of the Blocklistable 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::blocklistable::blocklist +pub use ::miden::standards::utils::blocklistable::unblocklist +pub use ::miden::standards::utils::blocklistable::on_before_asset_added_to_account +pub use ::miden::standards::utils::blocklistable::on_before_asset_added_to_note diff --git a/crates/miden-standards/asm/standards/utils/blocklistable.masm b/crates/miden-standards/asm/standards/utils/blocklistable.masm new file mode 100644 index 0000000000..0430eb54f0 --- /dev/null +++ b/crates/miden-standards/asm/standards/utils/blocklistable.masm @@ -0,0 +1,234 @@ +# miden::standards::utils::blocklistable +# +# Per-account blocklist storage and helpers. Blocklist/unblocklist procedures do not perform +# authorization. Compose with ownable2step or role-based access control in a higher layer. +# +# Asset callbacks enforce "native account is not blocklisted" when the issuing faucet has callbacks +# enabled on the asset. The callbacks run in the faucet's foreign context but read the native +# account ID (the account that started the transaction) via `native_account::get_id`: +# +# - `on_before_asset_added_to_account` is invoked when an asset is added to an account vault, +# so the native account is the asset recipient. +# - `on_before_asset_added_to_note` is invoked when an asset is added to an output note, +# so the native account is the note creator (sender). + +use miden::core::word +use miden::protocol::active_account +use miden::protocol::native_account + +# CONSTANTS +# ================================================================================================ + +# The slot where the per-account blocklist map is stored. +# Map entries: [0, 0, account_id_suffix, account_id_prefix] => [is_blocklisted, 0, 0, 0] +const BLOCKLIST_SLOT = word("miden::standards::utils::blocklistable::blocklist") + +const BLOCKLISTED_WORD = [1, 0, 0, 0] + +const UNBLOCKLISTED_WORD = [0, 0, 0, 0] + +# ERRORS +# ================================================================================================ + +const ERR_BLOCKLIST_ACCOUNT_IS_BLOCKLISTED = "account is blocklisted" + +const ERR_BLOCKLIST_ALREADY_BLOCKLISTED = "account is already blocklisted" + +const ERR_BLOCKLIST_NOT_BLOCKLISTED = "account is not blocklisted" + +# PUBLIC INTERFACE +# ================================================================================================ + +#! Returns whether the given account is currently blocklisted on this faucet. +#! +#! Reads [`BLOCKLIST_SLOT`] on the active account at the key derived from the account ID. +#! Returns `1` if the stored word is non-zero (blocklisted) or `0` if it is the zero word +#! (not blocklisted, including the default for never-set entries). +#! +#! Inputs: [account_id_suffix, account_id_prefix] +#! Outputs: [is_blocklisted] +#! +#! Invocation: exec +pub proc is_blocklisted + exec.build_blocklist_map_key + # => [KEY] + + push.BLOCKLIST_SLOT[0..2] + # => [slot_suffix, slot_prefix, KEY] + + exec.active_account::get_map_item + # => [VALUE] + + exec.word::eqz not + # => [is_blocklisted] +end + +#! Adds the given account to the blocklist. Fails if it is already blocklisted. +#! +#! This procedure does not verify the caller. Wrap with access control in the account component +#! composition if only privileged accounts should blocklist. +#! +#! Inputs: [account_id_suffix, account_id_prefix, pad(14)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the account is already blocklisted. +#! +#! Invocation: call +pub proc blocklist + # copy the ID to check it is not already blocklisted. + dup.1 dup.1 + # => [account_id_suffix, account_id_prefix, account_id_suffix, account_id_prefix, pad(14)] + + exec.is_blocklisted + # => [is_blocklisted, account_id_suffix, account_id_prefix, pad(14)] + + not assert.err=ERR_BLOCKLIST_ALREADY_BLOCKLISTED + # => [account_id_suffix, account_id_prefix, pad(14)] + + exec.build_blocklist_map_key + # => [KEY, pad(14)] + + push.BLOCKLIST_SLOT[0..2] + # => [slot_suffix, slot_prefix, KEY, pad(14)] + + push.BLOCKLISTED_WORD + # => [BLOCKLISTED_WORD, slot_suffix, slot_prefix, KEY, pad(14)] + + # set_map_item expects: [slot_suffix, slot_prefix, KEY, VALUE] + movdnw.2 + # => [slot_suffix, slot_prefix, KEY, BLOCKLISTED_WORD, pad(14)] + + exec.native_account::set_map_item + # => [OLD_VALUE, pad(14)] + + dropw + # => [pad(16)] +end + +#! Removes the given account from the blocklist. Fails if it is not currently blocklisted. +#! +#! This procedure does not verify the caller. +#! +#! Inputs: [account_id_suffix, account_id_prefix, pad(14)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the account is not currently blocklisted. +#! +#! Invocation: call +pub proc unblocklist + # copy the ID to check it is currently blocklisted. + dup.1 dup.1 + # => [account_id_suffix, account_id_prefix, account_id_suffix, account_id_prefix, pad(14)] + + exec.is_blocklisted + # => [is_blocklisted, account_id_suffix, account_id_prefix, pad(14)] + + assert.err=ERR_BLOCKLIST_NOT_BLOCKLISTED + # => [account_id_suffix, account_id_prefix, pad(14)] + + exec.build_blocklist_map_key + # => [KEY, pad(14)] + + push.BLOCKLIST_SLOT[0..2] + # => [slot_suffix, slot_prefix, KEY, pad(14)] + + push.UNBLOCKLISTED_WORD + # => [UNBLOCKLISTED_WORD, slot_suffix, slot_prefix, KEY, pad(14)] + + movdnw.2 + # => [slot_suffix, slot_prefix, KEY, UNBLOCKLISTED_WORD, pad(14)] + + exec.native_account::set_map_item + # => [OLD_VALUE, pad(14)] + + dropw + # => [pad(16)] +end + +#! Requires the given account to not be blocklisted. +#! +#! Delegates to [`is_blocklisted`] and inverts the result. If the account is blocklisted, panics +#! with [`ERR_BLOCKLIST_ACCOUNT_IS_BLOCKLISTED`]. +#! +#! Use from other modules or transaction scripts to guard logic that must not run for blocklisted +#! accounts. In asset callback foreign context, the active account is the issuing faucet. +#! +#! Inputs: [account_id_suffix, account_id_prefix] +#! Outputs: [] +#! +#! Panics if: +#! - the stored blocklist word for this account is not the zero word. +#! +#! Invocation: exec +pub proc assert_not_blocklisted + exec.is_blocklisted + # => [is_blocklisted] + + not + # => [is_not_blocklisted] + + assert.err=ERR_BLOCKLIST_ACCOUNT_IS_BLOCKLISTED + # => [] +end + +# CALLBACKS +# ================================================================================================ + +#! Callback when a callbacks-enabled asset is added to an account vault. +#! +#! Panics if the native account (the asset recipient) is blocklisted on this faucet. +#! +#! Inputs: [ASSET_KEY, ASSET_VALUE, pad(8)] +#! Outputs: [ASSET_VALUE, pad(12)] +#! +#! Invocation: call +pub proc on_before_asset_added_to_account + exec.native_account::get_id + # => [account_id_suffix, account_id_prefix, ASSET_KEY, ASSET_VALUE, pad(8)] + + exec.assert_not_blocklisted + # => [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 the native account (the note creator / sender) is blocklisted on this faucet. +#! +#! 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.native_account::get_id + # => [account_id_suffix, account_id_prefix, ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] + + exec.assert_not_blocklisted + # => [ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] + + dropw + # => [ASSET_VALUE, note_idx, pad(7)] +end + +# HELPER PROCEDURES +# ================================================================================================ + +#! Builds the blocklist map key for the given account ID. +#! +#! Inputs: [account_id_suffix, account_id_prefix] +#! Outputs: [KEY] +#! +#! Where: +#! - KEY is [0, 0, account_id_suffix, account_id_prefix]. +#! +#! Invocation: exec +proc build_blocklist_map_key + # => [account_id_suffix, account_id_prefix] + + push.0.0 + # => [0, 0, account_id_suffix, account_id_prefix] = KEY +end diff --git a/crates/miden-standards/src/account/blocklistable.rs b/crates/miden-standards/src/account/blocklistable.rs new file mode 100644 index 0000000000..5b5d38468f --- /dev/null +++ b/crates/miden-standards/src/account/blocklistable.rs @@ -0,0 +1,165 @@ +use alloc::vec::Vec; + +use miden_protocol::Word; +use miden_protocol::account::component::{ + AccountComponentMetadata, + SchemaType, + StorageSchema, + StorageSlotSchema, +}; +use miden_protocol::account::{ + AccountComponent, + AccountType, + StorageMap, + StorageSlot, + StorageSlotName, +}; +use miden_protocol::asset::AssetCallbacks; +use miden_protocol::utils::sync::LazyLock; + +use crate::account::components::blocklistable_library; +use crate::procedure_digest; + +// BLOCKLISTABLE ACCOUNT COMPONENT +// ================================================================================================ + +static BLOCKLIST_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::utils::blocklistable::blocklist") + .expect("storage slot name should be valid") +}); + +procedure_digest!( + BLOCKLISTABLE_BLOCKLIST, + Blocklistable::NAME, + Blocklistable::BLOCKLIST_PROC_NAME, + blocklistable_library +); + +procedure_digest!( + BLOCKLISTABLE_UNBLOCKLIST, + Blocklistable::NAME, + Blocklistable::UNBLOCKLIST_PROC_NAME, + blocklistable_library +); + +procedure_digest!( + BLOCKLISTABLE_ON_BEFORE_ASSET_ADDED_TO_ACCOUNT, + Blocklistable::NAME, + Blocklistable::ON_BEFORE_ASSET_ADDED_TO_ACCOUNT_PROC_NAME, + blocklistable_library +); + +procedure_digest!( + BLOCKLISTABLE_ON_BEFORE_ASSET_ADDED_TO_NOTE, + Blocklistable::NAME, + Blocklistable::ON_BEFORE_ASSET_ADDED_TO_NOTE_PROC_NAME, + blocklistable_library +); + +/// Account component that stores a per-account blocklist map and registers asset callbacks that +/// reject transfers whose native account (asset recipient for +/// `on_before_asset_added_to_account`, or note creator for `on_before_asset_added_to_note`) is +/// currently blocklisted on the issuing faucet. +/// +/// `blocklist` and `unblocklist` 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 blocklist/unblocklist logic. +/// +/// ## Storage +/// +/// - [`Self::blocklist_slot()`]: storage map keyed by account ID (word layout `[0, 0, +/// account_id_suffix, account_id_prefix]`). An account is considered blocklisted when its entry +/// is the word `[1, 0, 0, 0]`; the zero word (including the default for unset entries) means not +/// blocklisted. +/// - Protocol callback slots from [`AssetCallbacks`] registered by every constructor. +#[derive(Debug, Clone, Copy, Default)] +pub struct Blocklistable; + +impl Blocklistable { + /// Component library path (merged account module name). + pub const NAME: &'static str = "miden::standards::components::utils::blocklistable"; + + const BLOCKLIST_PROC_NAME: &'static str = "blocklist"; + const UNBLOCKLIST_PROC_NAME: &'static str = "unblocklist"; + 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 [`Blocklistable`] with an empty blocklist. + pub const fn new() -> Self { + Self + } + + /// Storage slot name for the blocklist map. + pub fn blocklist_slot() -> &'static StorageSlotName { + &BLOCKLIST_SLOT_NAME + } + + /// Schema entry for the blocklist map slot (documentation / tooling). + pub fn blocklist_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::blocklist_slot().clone(), + StorageSlotSchema::map( + "Per-account blocklist flag; zero word is not blocklisted, [1,0,0,0] is blocklisted", + SchemaType::native_word(), + SchemaType::bool(), + ), + ) + } + + /// 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::blocklist_slot_schema()]) + .expect("storage schema should be valid"); + + AccountComponentMetadata::new( + Self::NAME, + [AccountType::FungibleFaucet, AccountType::NonFungibleFaucet], + ) + .with_description( + "Blocklistable component: blocklist/unblocklist and on_before_asset_added callbacks \ + without auth", + ) + .with_storage_schema(storage_schema) + } + + pub fn blocklist_digest() -> Word { + *BLOCKLISTABLE_BLOCKLIST + } + + pub fn unblocklist_digest() -> Word { + *BLOCKLISTABLE_UNBLOCKLIST + } + + pub fn on_before_asset_added_to_account_digest() -> Word { + *BLOCKLISTABLE_ON_BEFORE_ASSET_ADDED_TO_ACCOUNT + } + + pub fn on_before_asset_added_to_note_digest() -> Word { + *BLOCKLISTABLE_ON_BEFORE_ASSET_ADDED_TO_NOTE + } +} + +impl From for AccountComponent { + fn from(_blocklistable: Blocklistable) -> Self { + let blocklist_slot = + StorageSlot::with_map(Blocklistable::blocklist_slot().clone(), StorageMap::default()); + let callback_slots = AssetCallbacks::new() + .on_before_asset_added_to_account( + Blocklistable::on_before_asset_added_to_account_digest(), + ) + .on_before_asset_added_to_note(Blocklistable::on_before_asset_added_to_note_digest()) + .into_storage_slots(); + + let mut storage_slots = Vec::with_capacity(1 + callback_slots.len()); + storage_slots.push(blocklist_slot); + storage_slots.extend(callback_slots); + + let metadata = Blocklistable::component_metadata(); + + AccountComponent::new(blocklistable_library(), storage_slots, metadata).expect( + "blocklistable component should satisfy the requirements of a valid account component", + ) + } +} diff --git a/crates/miden-standards/src/account/components/mod.rs b/crates/miden-standards/src/account/components/mod.rs index e8a8bea866..fd30f83aa2 100644 --- a/crates/miden-standards/src/account/components/mod.rs +++ b/crates/miden-standards/src/account/components/mod.rs @@ -146,6 +146,18 @@ static BURN_POLICY_AUTH_CONTROLLED_LIBRARY: LazyLock = LazyLock::new(|| .expect("Shipped Burn Policy Auth Controlled library is well-formed") }); +// UTILS LIBRARIES +// ================================================================================================ + +// Initialize the Blocklistable library only once. +static BLOCKLISTABLE_LIBRARY: LazyLock = LazyLock::new(|| { + let bytes = include_bytes!(concat!( + env!("OUT_DIR"), + "/assets/account_components/utils/blocklistable.masl" + )); + Library::read_from_bytes(bytes).expect("Shipped Blocklistable library is well-formed") +}); + // METADATA LIBRARIES // ================================================================================================ @@ -194,6 +206,11 @@ pub fn burn_auth_controlled_library() -> Library { BURN_POLICY_AUTH_CONTROLLED_LIBRARY.clone() } +/// Returns the Blocklistable component library. +pub fn blocklistable_library() -> Library { + BLOCKLISTABLE_LIBRARY.clone() +} + /// Returns the Singlesig Library. pub fn singlesig_library() -> Library { SINGLESIG_LIBRARY.clone() diff --git a/crates/miden-standards/src/account/mod.rs b/crates/miden-standards/src/account/mod.rs index e03614bab8..62da2952e2 100644 --- a/crates/miden-standards/src/account/mod.rs +++ b/crates/miden-standards/src/account/mod.rs @@ -2,6 +2,7 @@ use super::auth_method::AuthMethod; pub mod access; pub mod auth; +pub mod blocklistable; pub mod burn_policies; pub mod components; pub mod faucets; diff --git a/crates/miden-testing/tests/scripts/blocklistable.rs b/crates/miden-testing/tests/scripts/blocklistable.rs new file mode 100644 index 0000000000..38b296418a --- /dev/null +++ b/crates/miden-testing/tests/scripts/blocklistable.rs @@ -0,0 +1,479 @@ +//! Tests for [`miden_standards::account::blocklistable::Blocklistable`] asset callbacks and +//! blocklist/unblocklist scripts. + +extern crate alloc; + +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_protocol::{Felt, Word}; +use miden_standards::account::blocklistable::Blocklistable; +use miden_standards::account::faucets::BasicFungibleFaucet; +use miden_standards::account::metadata::{FungibleTokenMetadataBuilder, TokenName}; +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_BLOCKLIST_ACCOUNT_IS_BLOCKLISTED: MasmError = + MasmError::from_static_str("account is blocklisted"); + +const ERR_BLOCKLIST_ALREADY_BLOCKLISTED: MasmError = + MasmError::from_static_str("account is already blocklisted"); + +const ERR_BLOCKLIST_NOT_BLOCKLISTED: MasmError = + MasmError::from_static_str("account is not blocklisted"); + +fn add_faucet_with_blocklistable(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(Blocklistable::new()); + + builder.add_account_from_builder( + Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + }, + account_builder, + AccountState::Exists, + ) +} + +fn add_faucet_with_blocklistable_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!( + "blocklistable 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(Blocklistable::new()); + + builder.add_account_from_builder( + Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + }, + account_builder, + AccountState::Exists, + ) +} + +fn account_id_felts(account_id: AccountId) -> (Felt, Felt) { + let [prefix, suffix]: [Felt; 2] = account_id.into(); + (prefix, suffix) +} + +async fn execute_faucet_blocklist( + mock_chain: &mut MockChain, + faucet_id: AccountId, + target_id: AccountId, +) -> anyhow::Result<()> { + let (prefix, suffix) = account_id_felts(target_id); + let script = format!( + r#" + begin + push.{prefix} + push.{suffix} + call.::miden::standards::utils::blocklistable::blocklist + dropw dropw dropw dropw + end + "# + ); + let tx_script = CodeBuilder::default().compile_tx_script(&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_unblocklist( + mock_chain: &mut MockChain, + faucet_id: AccountId, + target_id: AccountId, +) -> anyhow::Result<()> { + let (prefix, suffix) = account_id_felts(target_id); + let script = format!( + r#" + begin + push.{prefix} + push.{suffix} + call.::miden::standards::utils::blocklistable::unblocklist + dropw dropw dropw dropw + end + "# + ); + let tx_script = CodeBuilder::default().compile_tx_script(&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 blocklist_receive_asset_succeeds_when_not_blocklisted( + #[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_blocklistable_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 blocklist_receive_asset_fails_when_recipient_blocklisted( + #[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_blocklistable_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_blocklist(&mut mock_chain, faucet.id(), target_account.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_BLOCKLIST_ACCOUNT_IS_BLOCKLISTED); + + 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 blocklist_add_asset_to_note_fails_when_sender_blocklisted( + #[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_blocklistable_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_blocklist(&mut mock_chain, faucet.id(), target_account.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_BLOCKLIST_ACCOUNT_IS_BLOCKLISTED); + + Ok(()) +} + +#[tokio::test] +async fn blocklist_then_unblocklist_then_receive_succeeds() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; + let faucet = add_faucet_with_blocklistable(&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_blocklist(&mut mock_chain, faucet.id(), target_account.id()).await?; + execute_faucet_unblocklist(&mut mock_chain, faucet.id(), target_account.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 blocklist_already_blocklisted_fails() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; + let faucet = add_faucet_with_blocklistable(&mut builder)?; + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + execute_faucet_blocklist(&mut mock_chain, faucet.id(), target_account.id()).await?; + + let (prefix, suffix) = account_id_felts(target_account.id()); + let script = format!( + r#" + begin + push.{prefix} + push.{suffix} + call.::miden::standards::utils::blocklistable::blocklist + dropw dropw dropw dropw + end + "# + ); + let tx_script = CodeBuilder::default().compile_tx_script(&script)?; + let result = mock_chain + .build_tx_context(faucet.id(), &[], &[])? + .tx_script(tx_script) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_BLOCKLIST_ALREADY_BLOCKLISTED); + + Ok(()) +} + +#[tokio::test] +async fn unblocklist_when_not_blocklisted_fails() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; + let faucet = add_faucet_with_blocklistable(&mut builder)?; + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let (prefix, suffix) = account_id_felts(target_account.id()); + let script = format!( + r#" + begin + push.{prefix} + push.{suffix} + call.::miden::standards::utils::blocklistable::unblocklist + dropw dropw dropw dropw + end + "# + ); + let tx_script = CodeBuilder::default().compile_tx_script(&script)?; + let result = mock_chain + .build_tx_context(faucet.id(), &[], &[])? + .tx_script(tx_script) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_BLOCKLIST_NOT_BLOCKLISTED); + + Ok(()) +} + +#[tokio::test] +async fn blocklist_does_not_affect_other_accounts() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let blocklisted_account = builder.add_existing_wallet(Auth::IncrNonce)?; + let other_account = builder.add_existing_wallet(Auth::IncrNonce)?; + let faucet = add_faucet_with_blocklistable(&mut builder)?; + + let amount: u64 = 25; + let fungible_asset = + FungibleAsset::new(faucet.id(), amount)?.with_callbacks(AssetCallbackFlag::Enabled); + let note = builder.add_p2id_note( + faucet.id(), + other_account.id(), + &[Asset::Fungible(fungible_asset)], + NoteType::Public, + )?; + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + // Blocklist a different account — the non-blocklisted one should still receive. + execute_faucet_blocklist(&mut mock_chain, faucet.id(), blocklisted_account.id()).await?; + + let faucet_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; + + mock_chain + .build_tx_context(other_account.id(), &[note.id()], &[])? + .foreign_accounts(vec![faucet_inputs]) + .build()? + .execute() + .await?; + + Ok(()) +} diff --git a/crates/miden-testing/tests/scripts/mod.rs b/crates/miden-testing/tests/scripts/mod.rs index 9b8c3e12e5..bb296d2fa8 100644 --- a/crates/miden-testing/tests/scripts/mod.rs +++ b/crates/miden-testing/tests/scripts/mod.rs @@ -1,3 +1,4 @@ +mod blocklistable; mod faucet; mod fee; mod ownable2step; From 4f144406f606ab3e776b77dc66961cbe905bc9f9 Mon Sep 17 00:00:00 2001 From: onurinanc Date: Thu, 23 Apr 2026 15:15:57 +0200 Subject: [PATCH 2/2] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fdf3d6dbc..0cb95eab13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - [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 `TransactionScript::from_package()` method to create `TransactionScript` from `miden-mast-package::Package` ([#2779](https://github.com/0xMiden/protocol/pull/2779)). +- Added Blocklistable component for per-account freezing ([#2820])(https://github.com/0xMiden/protocol/pull/2820). ### Fixes