Skip to content

feat(standards): add Blocklistable component for per-account freezing#2820

Open
onurinanc wants to merge 37 commits into
nextfrom
blocklist
Open

feat(standards): add Blocklistable component for per-account freezing#2820
onurinanc wants to merge 37 commits into
nextfrom
blocklist

Conversation

@onurinanc
Copy link
Copy Markdown
Collaborator

Copy link
Copy Markdown
Contributor

@bobbinth bobbinth left a comment

Choose a reason for hiding this comment

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

Looks good! Thank you! Not a full review yet, but I left some comments inline which could change the approach in this PR.

Comment thread crates/miden-standards/src/account/blocklistable.rs Outdated
Comment thread crates/miden-standards/asm/standards/utils/blocklistable.masm Outdated
Comment thread crates/miden-standards/asm/standards/utils/blocklistable.masm Outdated
Comment thread crates/miden-standards/asm/standards/faucets/restricted.masm Outdated
@onurinanc onurinanc mentioned this pull request Apr 28, 2026
@mmagician mmagician added pr-from-maintainers PRs that come from internal contributors or integration partners. They should be given priority standards Related to standard note scripts or account components labels Apr 29, 2026
@mmagician mmagician requested a review from Fumuran April 29, 2026 08:40
@onurinanc onurinanc requested a review from bobbinth May 8, 2026 12:41
Copy link
Copy Markdown
Contributor

@bobbinth bobbinth left a comment

Choose a reason for hiding this comment

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

Thank you! Looks good! Not a full review yet, but I left a few comments inline about some of the structural aspects.

Comment thread crates/miden-standards/src/account/policies/manager.rs Outdated
Comment on lines +26 to +38
pub enum TransferPolicy {
/// Active policy = [`TransferAllowAll::root`] (the callback predicate accepts unconditionally).
#[default]
AllowAll,
/// Active policy = [`TransferIfNotBlocklisted::root`]. The policy component installs the
/// `blocked_users` storage map alongside its predicate procedure (see
/// [`crate::account::faucets::Blocklist`] for the storage namespace).
IfNotBlocklisted,
/// Active policy = the provided root. The corresponding component(s) must be installed by
/// the caller separately; resolving this variant into built-in components yields an empty
/// list.
Custom(Word),
}
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.

Maybe not for this PR, but I wonder if we should change this into a struct - e.g., something like:

pub struct TransferPolicy(Vec<AccountComponent>);

impl TransferPolicy {
    pub fn from_component(component: AccountComponent) -> Self {
        ...
    }

    pub fn allow_all() -> Self {
        ...
    }

    pub fn basic_blocklist() -> Self {
        ...
    }
}

This would also apply to other similar enums.

Comment thread crates/miden-standards/src/account/faucets/restrictions/blocklist.rs Outdated
Copy link
Copy Markdown
Contributor

@PhilippGackstatter PhilippGackstatter left a comment

Choose a reason for hiding this comment

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

Looks good to me!

I think the primary things for this PR are:

  • Potentially removing a level of indirection for the asset callbacks, as described by @bobbinth.
  • Potentially collapsing TransferIfNotBlocklisted and Blocklist into one.
  • Avoiding panics in TokenPolicyManager.

The outcome of #2862 should probably not hold this PR up.


# The slot where the per-faucet map of blocked users is stored.
# Map entries: [0, 0, account_id_suffix, account_id_prefix] => [is_blocked, 0, 0, 0]
const BLOCKED_USERS_SLOT = word("miden::standards::faucets::restrictions::blocklist::blocked_users")
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.

Why does this fall under restrictions rather than policies::transfer?

/// exposed on a production faucet. Tests may invoke them via `exec.` directly when the test
/// transaction runs with the faucet as the native account.
#[derive(Debug, Clone, Copy, Default)]
pub struct TransferIfNotBlocklisted;
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.

Not necessarily for this PR, but I think we should probably support the "reconstruction" of account components from an Account. The unit structs we have now do not support this, because here, for example, we assume the block list is initially empty (which is fine). But to support account inspection, and component upgrades, it'd be easier if we could support such reconstruction. This I think applies to:

  • TransferIfNotBlocklisted
  • Blocklist
  • RoleBasedAccessControl

For now I think we can just open an issue and track this.

Comment on lines +71 to +75
impl OwnerOnlyBlocklistAdmin {
/// The name of the component.
pub const NAME: &'static str =
"miden::standards::components::faucets::restrictions::blocklist_owner";

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 would make the component struct name and the NAME consistent, e.g.:

Suggested change
impl OwnerOnlyBlocklistAdmin {
/// The name of the component.
pub const NAME: &'static str =
"miden::standards::components::faucets::restrictions::blocklist_owner";
impl OwnerOnlyBlocklistAdmin {
/// The name of the component.
pub const NAME: &'static str =
"miden::standards::components::faucets::restrictions::owner_only_blocklist_admin";

/// word `[1, 0, 0, 0]`; the zero word (including the default for unset entries) means not
/// blocked.
#[derive(Debug, Clone, Copy)]
pub struct Blocklist;
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.

Maybe I'm missing it, but what is the advantage of having Blocklist and TransferIfNotBlocklisted? Seems like these could be merged.


mod basic_fungible;
mod network_fungible;
pub mod restrictions;
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.

Suggested change
pub mod restrictions;
mod restrictions;

We usually don't make these public.

Comment on lines 579 to 581
/// Every kind must have exactly one [`PolicyRegistration::Active`] entry; otherwise the
/// internal storage-slot construction panics.
fn into_iter(self) -> Self::IntoIter {
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.

We should avoid these panics by construction.

We could use a bon::builder to make sure the active policies are set:

#[bon::bon]
impl TokenPolicyManager {
    #[builder]
    pub fn new(
        #[builder(field)] allowed_mint_policies: BTreeMap<Word, PolicyConfig>,
        #[builder(field)] allowed_burn_policies: BTreeMap<Word, PolicyConfig>,
        #[builder(field)] allowed_send_policies: BTreeMap<Word, PolicyConfig>,
        #[builder(field)] allowed_receive_policies: BTreeMap<Word, PolicyConfig>,
        authority: PolicyAuthority,
        active_mint_policy: MintPolicyConfig,
        active_burn_policy: BurnPolicyConfig,
        active_send_policy: TransferPolicy,
        active_receive_policy: TransferPolicy,
    ) -> TokenPolicyManager {
        // convert and insert active policies into allowed

        Self {
            authority,
            mint_policies: allowed_mint_policies.into_values().collect(),
            burn_policies: allowed_burn_policies.into_values().collect(),
            send_policies: allowed_send_policies.into_values().collect(),
            receive_policies: allowed_receive_policies.into_values().collect(),
        }
    }
}

impl<S: token_policy_manager_builder::State> TokenPolicyManagerBuilder<S> {
    /// TODO
    pub fn allowed_mint_policy(mut self, config: MintPolicyConfig) -> Self {
        // TODO: self.allowed_mint_policies.insert(config.root, config);
        self
    }
}

// Usage

fn test() {
  TokenPolicyManager::builder()
        .authority(PolicyAuthority::OwnerControlled)
        .active_mint_policy(MintPolicyConfig::OwnerOnly)
        .allowed_mint_policy(MintPolicyConfig::AllowAll)
        // ...
        .build();
}
  • The builder ensures an active policy for each category is set.
  • The BTreeMap ensures deduplication by root (maybe it makes sense to do it differently, or use the map also within the token policy manager - this is just a high-level idea).

Comment on lines +593 to +594
for kind in [mint_policies, burn_policies, send_policies, receive_policies] {
for entry in kind {
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.

Nit: kind and entry make this a bit hard to read. Consider:

let policy_iter = mint_policies
            .into_iter()
            .chain(burn_policies)
            .chain(send_policies)
            .chain(receive_policies);

for policy_config in policy_iter { ... }

If the fields internally were all BTreeMap<Word, PolicyConfig>, they could be merged here for natural deduplication. Then, iteration wouldn't have to check for duplication.

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 overall this would be nicer, but we should still be able to determine that a given policy category (mint, burn, send, receive) has only one active policy set. Maybe the way to define the struct could be:

pub struct TokenPolicyManager {
    authority: PolicyAuthority,
    active_mint_policy_root: Word,
    active_burn_policy_root: Word,
    active_send_policy_root: Word,
    active_receive_policy_root: Word,
    policies: BTreeMap<Word, PolicyConfig>,
}

Comment on lines +635 to +645
/// Builds the `allowed_*_policies` storage map for a given kind. Includes both Active and
/// Reserved entries so runtime `set_*_policy` validation accepts swaps to either.
fn build_allowed_map(entries: &[PolicyConfig], kind: &str) -> StorageMap {
let allowed_flag = Word::from([1u32, 0, 0, 0]);
let map_entries: Vec<_> = entries
.iter()
.map(|e| (StorageMapKey::from_raw(e.root), allowed_flag))
.collect();
StorageMap::with_entries(map_entries)
.unwrap_or_else(|_| panic!("allowed {kind} policy roots should have unique keys"))
}
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.

Same here, if the fields were BTreeMap this would more naturally give us unique entries.

Nit: Change this to an expect.

Comment on lines +28 to +36
pub mod burn;
mod manager;
pub mod mint;
pub mod transfer;

pub use burn::{BurnAllowAll, BurnOwnerOnly, BurnPolicyConfig};
pub use manager::TokenPolicyManager;
pub use manager::{PolicyConfig, TokenPolicyManager};
pub use mint::{MintAllowAll, MintOwnerOnly, MintPolicyConfig};
pub use transfer::{TransferAllowAll, TransferIfNotBlocklisted, TransferPolicy};
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.

Since we pub use all the items of these modules, I think we can make the modules themselves private, right?

Copy link
Copy Markdown
Contributor

@bobbinth bobbinth left a comment

Choose a reason for hiding this comment

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

Looks good! Thank you! I left some more comments inline - but most should be pretty easy to address.

Comment thread crates/miden-standards/src/account/policies/manager.rs Outdated
Comment thread crates/miden-standards/src/account/policies/manager.rs
Comment thread crates/miden-standards/src/account/policies/manager.rs
Comment on lines +455 to +465
// Only register the asset-callback slots when at least one of the send / receive
// policies actually performs enforcement. Beyond saving slots and dispatch overhead
// for no-op `AllowAll`, this also keeps the minted asset value word free of
// `AssetCallbackFlag::Enabled` — `protocol::faucet::create_fungible_asset` reads the
// callback slots and stamps the flag on every minted asset when they are populated.
//
// With the flattened policy dispatch, the protocol callback slots hold the active
// policy proc root directly (no manager-side wrapper), so we initialize them to the
// active send / receive policy roots.
let needs_callbacks = self.send_policies.iter().any(|p| p.requires_callbacks)
|| self.receive_policies.iter().any(|p| p.requires_callbacks);
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.

"Allow all" would be the only policy that doesn't require a callback, right? If so, I wonder if we could get rid of the requires_callback field and just do a match against the "allow all" procedure root.

More generally, I wonder if "allow all" should be treated somehow specially. Basically, if "allow all" is the active policy - do we even need to populate call back policy slot?

Comment on lines +593 to +594
for kind in [mint_policies, burn_policies, send_policies, receive_policies] {
for entry in kind {
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 overall this would be nicer, but we should still be able to determine that a given policy category (mint, burn, send, receive) has only one active policy set. Maybe the way to define the struct could be:

pub struct TokenPolicyManager {
    authority: PolicyAuthority,
    active_mint_policy_root: Word,
    active_burn_policy_root: Word,
    active_send_policy_root: Word,
    active_receive_policy_root: Word,
    policies: BTreeMap<Word, PolicyConfig>,
}

Comment thread crates/miden-standards/asm/standards/faucets/policies/policy_manager.masm Outdated
Comment thread crates/miden-standards/asm/standards/faucets/policies/policy_manager.masm Outdated
Comment thread crates/miden-standards/asm/standards/faucets/policies/transfer/blocklist/mod.masm Outdated
Comment thread crates/miden-standards/asm/standards/faucets/policies/transfer/blocklist/mod.masm Outdated
Copy link
Copy Markdown
Contributor

@bobbinth bobbinth left a comment

Choose a reason for hiding this comment

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

Looks good! Thank you! I left some more comments inline - but once these are addressed, we should be good to merge.

I think we also have a couple of follow-ups coming out of this PR. Could you create an issue capturing these?

Comment on lines +53 to +60
pub fn with_blocked_accounts<I>(blocked_accounts: I) -> Self
where
I: IntoIterator<Item = AccountId>,
{
Self {
blocked_accounts: blocked_accounts.into_iter().collect(),
}
}
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.

How should we deal with duplicates here? We could either silently remove them, or return an error.

Or, we could also could also change the internal type into BTreeSet (and this would silently de-duplicate).

Comment on lines +100 to +104
pub(crate) fn account_id_to_map_key(id: AccountId) -> StorageMapKey {
use miden_protocol::Felt;
let key = Word::from([Felt::ZERO, Felt::ZERO, id.suffix(), id.prefix().as_felt()]);
StorageMapKey::from_raw(key)
}
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.

Do we not already have a helper function to do this? cc @PhilippGackstatter in case he knows.

If not, I'd probably as impl From<AccountId> for StorageMapKey and put this code in the account ID module.

Comment on lines +88 to +93
let entries: Vec<_> = self
.blocked_accounts
.iter()
.map(|id| (account_id_to_map_key(*id), blocked_word))
.collect();
StorageMap::with_entries(entries).expect("initial blocked accounts should have unique IDs")
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.

nit: we should be able to avoid allocating entries here and just pass the iterator the StorageMap::with_entries().

Comment on lines +83 to +86
let blocked_accounts_slot = StorageSlot::with_map(
BlocklistStorage::blocked_accounts_slot().clone(),
storage.build_storage_map(),
);
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.

Ideally, we'd be able to do something like:

let slots = storage.into_slots();

And then, line 103 below would be something like:

AccountComponent::new(
    BasicBlocklist::code().clone(),
    storage.into_slots(),
    metadata,
)

Comment on lines +220 to +225
if registration == PolicyRegistration::Active {
assert!(
self.active_mint_policy_root == Word::default(),
"token policy manager: more than one active mint policy registered",
);
self.active_mint_policy_root = root;
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 it would be better if this was an error rather than panic - but let's leave this for a follow-up PR.

This also applies to other similar methods.

Comment on lines +341 to +349
/// Returns the active send policy procedure root. Panics if no active send policy has
/// been registered.
pub fn active_send_policy(&self) -> Word {
assert!(
self.active_send_policy_root != Word::default(),
"token policy manager: no active send policy registered",
);
self.active_send_policy_root
}
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.

Here, and in other similar methods, instead of panicking we should return Option<Word>.

@onurinanc
Copy link
Copy Markdown
Collaborator Author

Addressed the review suggestions, and opened an issue to track the follow-up items:

@onurinanc onurinanc requested a review from bobbinth May 14, 2026 15:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-from-maintainers PRs that come from internal contributors or integration partners. They should be given priority standards Related to standard note scripts or account components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants