diff --git a/CHANGELOG.md b/CHANGELOG.md index a02be80061..e8a45db00d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ ### Fixes +- Account components can declare a `ComponentDependency` in their metadata, and `AccountBuilder` now rejects an account that leaves one unsatisfied; the owner-gated mint and burn policies and `Authority::OwnerControlled` declare the `Ownable2Step` owner slot, so a faucet that would abort on every mint no longer builds ([#3527](https://github.com/0xMiden/protocol/pull/3527)). + ## v0.16.0 (2026-08-06) ### Features diff --git a/crates/miden-protocol/src/account/builder/mod.rs b/crates/miden-protocol/src/account/builder/mod.rs index 07b2f433e3..bcec62716e 100644 --- a/crates/miden-protocol/src/account/builder/mod.rs +++ b/crates/miden-protocol/src/account/builder/mod.rs @@ -208,6 +208,8 @@ impl AccountBuilder { /// - The number of [`StorageSlot`](crate::account::StorageSlot)s of all components exceeds 255. /// - [`MastForest::merge`](miden_processor::mast::MastForest::merge) fails on the given /// components. + /// - A component declares a [`ComponentDependency`](crate::account::ComponentDependency) that + /// no component on the account satisfies. /// - If duplicate assets were added to the builder (only under the `testing` feature). /// - If the vault is not empty on new accounts (only under the `testing` feature). pub fn build(mut self) -> Result { @@ -306,7 +308,7 @@ mod tests { use miden_mast_package::Package; use super::*; - use crate::account::component::AccountComponentMetadata; + use crate::account::component::{AccountComponentMetadata, ComponentDependency}; use crate::account::{AccountProcedureRoot, StorageSlot, StorageSlotName}; use crate::testing::assembler::assemble_test_package; use crate::testing::noop_auth_component::NoopAuthComponent; @@ -386,6 +388,53 @@ mod tests { } } + /// A component that accesses a storage slot installed by [`CustomComponent2`] without + /// installing it itself, declared as a [`ComponentDependency`]. + struct DependentComponent; + impl From for AccountComponent { + fn from(_: DependentComponent) -> Self { + let metadata = AccountComponentMetadata::new("test::dependent_component") + .with_dependency(ComponentDependency::StorageSlot( + CUSTOM_COMPONENT2_SLOT_NAME0.clone(), + )); + + AccountComponent::new(CUSTOM_PACKAGE1.clone(), vec![], metadata) + .expect("component should be valid") + } + } + + /// A component whose declared dependency is not installed would abort at runtime on every + /// procedure that accesses the missing slot, so the account must not build at all. + #[test] + fn account_builder_rejects_unsatisfied_dependency() { + let err = Account::builder([5; 32]) + .with_component(NoopAuthComponent) + .with_component(DependentComponent) + .build() + .expect_err("component dependency is not satisfied"); + + assert_matches!(err, AccountError::BuildError(_, Some(source)) => { + assert_matches!(*source, AccountError::UnsatisfiedComponentDependency { component_name, slot_name } => { + assert_eq!(component_name, "test::dependent_component"); + assert_eq!(slot_name, *CUSTOM_COMPONENT2_SLOT_NAME0); + }); + }); + } + + /// Any component may satisfy a dependency: the account builds once some other component + /// installs the required slot. + #[test] + fn account_builder_accepts_satisfied_dependency() { + let account = Account::builder([5; 32]) + .with_component(NoopAuthComponent) + .with_component(DependentComponent) + .with_component(CustomComponent2 { slot0: 1, slot1: 2 }) + .build() + .expect("component dependency is satisfied by CustomComponent2"); + + assert!(account.storage().get(&CUSTOM_COMPONENT2_SLOT_NAME0).is_some()); + } + #[test] fn account_builder() { let storage_slot0 = 25; diff --git a/crates/miden-protocol/src/account/component/metadata/mod.rs b/crates/miden-protocol/src/account/component/metadata/mod.rs index e06f1b2767..cbb4512450 100644 --- a/crates/miden-protocol/src/account/component/metadata/mod.rs +++ b/crates/miden-protocol/src/account/component/metadata/mod.rs @@ -1,11 +1,14 @@ use alloc::collections::BTreeMap; +use alloc::format; use alloc::string::{String, ToString}; +use alloc::vec::Vec; use core::str::FromStr; use miden_mast_package::{Package, SectionId}; use semver::Version; use super::{SchemaRequirement, StorageSchema, StorageValueName}; +use crate::account::StorageSlotName; use crate::errors::AccountError; use crate::utils::serde::{ ByteReader, @@ -15,6 +18,29 @@ use crate::utils::serde::{ Serializable, }; +// COMPONENT DEPENDENCY +// ================================================================================================ + +/// A requirement that an [`AccountComponent`](super::AccountComponent) places on the account it is +/// installed on, beyond what the component itself provides. +/// +/// A component may read state that another component owns: the standard owner-gated policies, for +/// example, read the owner from a storage slot installed by the ownership component. Nothing in +/// the component's own code or storage schema records that expectation, so an account can be built +/// without the providing component and every procedure that reads the missing state then aborts at +/// runtime. +/// +/// Declaring the requirement here through +/// [`AccountComponentMetadata::with_dependency`] moves that failure to account construction: the +/// account is only built if every declared dependency is satisfied. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum ComponentDependency { + /// A storage slot the component accesses but does not install itself. Any component installed + /// on the same account may provide it. + StorageSlot(StorageSlotName), +} + // ACCOUNT COMPONENT METADATA // ================================================================================================ @@ -99,6 +125,11 @@ pub struct AccountComponentMetadata { /// Storage schema defining the component's storage layout, defaults, and init-supplied values. #[cfg_attr(feature = "std", serde(rename = "storage"))] storage_schema: StorageSchema, + + /// Requirements the component places on the account it is installed on, checked when the + /// account is built from its components. + #[cfg_attr(feature = "std", serde(default, skip_serializing_if = "Vec::is_empty"))] + dependencies: Vec, } impl AccountComponentMetadata { @@ -108,6 +139,7 @@ impl AccountComponentMetadata { /// - `description`: empty string /// - `version`: 1.0.0 /// - `storage_schema`: default (empty) + /// - `dependencies`: empty /// /// Use the `with_*` mutator methods to customize these fields. pub fn new(name: impl Into) -> Self { @@ -116,6 +148,7 @@ impl AccountComponentMetadata { description: String::new(), version: Version::new(1, 0, 0), storage_schema: StorageSchema::default(), + dependencies: Vec::new(), } } @@ -137,6 +170,15 @@ impl AccountComponentMetadata { self } + /// Adds a [`ComponentDependency`] the account must satisfy for this component to work. + /// + /// Account construction rejects an account that installs this component without satisfying + /// the dependency. + pub fn with_dependency(mut self, dependency: ComponentDependency) -> Self { + self.dependencies.push(dependency); + self + } + /// Returns the init-time values requirements for this schema. /// /// These values are used for initializing storage slot values or storage map entries. For a @@ -166,6 +208,11 @@ impl AccountComponentMetadata { pub fn storage_schema(&self) -> &StorageSchema { &self.storage_schema } + + /// Returns the requirements the component places on the account it is installed on. + pub fn dependencies(&self) -> &[ComponentDependency] { + &self.dependencies + } } impl TryFrom<&Package> for AccountComponentMetadata { @@ -197,12 +244,40 @@ impl TryFrom<&Package> for AccountComponentMetadata { // SERIALIZATION // ================================================================================================ +/// Tag written before a [`ComponentDependency`] to identify its variant. +const STORAGE_SLOT_DEPENDENCY_TAG: u8 = 0; + +impl Serializable for ComponentDependency { + fn write_into(&self, target: &mut W) { + match self { + ComponentDependency::StorageSlot(slot_name) => { + STORAGE_SLOT_DEPENDENCY_TAG.write_into(target); + slot_name.write_into(target); + }, + } + } +} + +impl Deserializable for ComponentDependency { + fn read_from(source: &mut R) -> Result { + match u8::read_from(source)? { + STORAGE_SLOT_DEPENDENCY_TAG => { + Ok(ComponentDependency::StorageSlot(StorageSlotName::read_from(source)?)) + }, + tag => Err(DeserializationError::InvalidValue(format!( + "unknown component dependency tag {tag}" + ))), + } + } +} + impl Serializable for AccountComponentMetadata { fn write_into(&self, target: &mut W) { self.name.write_into(target); self.description.write_into(target); self.version.to_string().write_into(target); self.storage_schema.write_into(target); + self.dependencies.write_into(target); } } @@ -218,12 +293,14 @@ impl Deserializable for AccountComponentMetadata { let version = semver::Version::from_str(&String::read_from(source)?) .map_err(|err: semver::Error| DeserializationError::InvalidValue(err.to_string()))?; let storage_schema = StorageSchema::read_from(source)?; + let dependencies = Vec::::read_from(source)?; Ok(Self { name, description, version, storage_schema, + dependencies, }) } } diff --git a/crates/miden-protocol/src/account/component/mod.rs b/crates/miden-protocol/src/account/component/mod.rs index 018d7ee666..7da3c949ea 100644 --- a/crates/miden-protocol/src/account/component/mod.rs +++ b/crates/miden-protocol/src/account/component/mod.rs @@ -209,6 +209,21 @@ mod tests { use crate::testing::assembler::assemble_test_package; use crate::utils::serde::Serializable; + /// Declared dependencies survive the binary round trip used to carry metadata in packages. + #[test] + fn metadata_dependencies_round_trip_through_bytes() { + use crate::account::StorageSlotName; + use crate::utils::serde::Deserializable; + + let slot_name = StorageSlotName::new("test::owner_config").unwrap(); + let metadata = AccountComponentMetadata::new("test_component") + .with_dependency(ComponentDependency::StorageSlot(slot_name)); + + let deserialized = AccountComponentMetadata::read_from_bytes(&metadata.to_bytes()).unwrap(); + + assert_eq!(deserialized, metadata); + } + #[test] fn test_extract_metadata_from_package() { // Create a simple package for testing diff --git a/crates/miden-protocol/src/account/component/storage/toml/mod.rs b/crates/miden-protocol/src/account/component/storage/toml/mod.rs index 700eb12463..4f1ceb9d03 100644 --- a/crates/miden-protocol/src/account/component/storage/toml/mod.rs +++ b/crates/miden-protocol/src/account/component/storage/toml/mod.rs @@ -19,7 +19,7 @@ use super::super::{ }; use crate::account::StorageSlotName; use crate::account::component::storage::type_registry::SCHEMA_TYPE_REGISTRY; -use crate::account::component::{AccountComponentMetadata, SchemaType}; +use crate::account::component::{AccountComponentMetadata, ComponentDependency, SchemaType}; use crate::errors::ComponentMetadataError; mod init_storage_data; @@ -40,6 +40,8 @@ struct RawAccountComponentMetadata { #[serde(rename = "storage")] #[serde(default)] storage: RawStorageSchema, + #[serde(default)] + dependencies: Vec, } impl AccountComponentMetadata { @@ -68,10 +70,15 @@ impl AccountComponentMetadata { } let storage_schema = StorageSchema::new(fields)?; - Ok(Self::new(raw.name) + let mut metadata = Self::new(raw.name) .with_description(raw.description) .with_version(raw.version) - .with_storage_schema(storage_schema)) + .with_storage_schema(storage_schema); + for dependency in raw.dependencies { + metadata = metadata.with_dependency(dependency); + } + + Ok(metadata) } /// Serializes the account component metadata into a TOML string. diff --git a/crates/miden-protocol/src/account/component/storage/toml/serde_impls.rs b/crates/miden-protocol/src/account/component/storage/toml/serde_impls.rs index b25eb91538..584d343e61 100644 --- a/crates/miden-protocol/src/account/component/storage/toml/serde_impls.rs +++ b/crates/miden-protocol/src/account/component/storage/toml/serde_impls.rs @@ -6,6 +6,48 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use super::super::type_registry::SCHEMA_TYPE_REGISTRY; use super::super::{FeltSchema, SchemaType, WordValue}; +use crate::account::StorageSlotName; +use crate::account::component::ComponentDependency; + +// COMPONENT DEPENDENCY SERIALIZATION +// ================================================================================================ + +/// Serialized as a single-entry table naming the dependency kind, e.g. +/// `storage-slot = "miden::standards::access::ownable2step::owner_config"`. +impl Serialize for ComponentDependency { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + ComponentDependency::StorageSlot(slot_name) => serializer.serialize_newtype_variant( + "ComponentDependency", + 0, + "storage-slot", + slot_name.as_str(), + ), + } + } +} + +impl<'de> Deserialize<'de> for ComponentDependency { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "kebab-case", deny_unknown_fields)] + enum RawComponentDependency { + StorageSlot(String), + } + + match RawComponentDependency::deserialize(deserializer)? { + RawComponentDependency::StorageSlot(slot_name) => StorageSlotName::new(slot_name) + .map(ComponentDependency::StorageSlot) + .map_err(D::Error::custom), + } + } +} // FELT SCHEMA SERIALIZATION // ================================================================================================ diff --git a/crates/miden-protocol/src/account/component/storage/toml/tests.rs b/crates/miden-protocol/src/account/component/storage/toml/tests.rs index f5a7c3180e..9939fd8817 100644 --- a/crates/miden-protocol/src/account/component/storage/toml/tests.rs +++ b/crates/miden-protocol/src/account/component/storage/toml/tests.rs @@ -6,6 +6,7 @@ use miden_core::{Felt, Word}; use crate::account::component::toml::init_storage_data::InitStorageDataError; use crate::account::component::{ AccountComponentMetadata, + ComponentDependency, InitStorageData, InitStorageDataError as CoreInitStorageDataError, SchemaType, @@ -258,6 +259,42 @@ fn metadata_from_toml_parses_named_storage_schema() { assert!(!requirements.contains_key(&"demo::my_map".parse::().unwrap())); } +#[test] +fn metadata_dependencies_round_trip_through_toml() { + let toml_str = r#" + name = "Test Component" + description = "Test description" + version = "0.1.0" + + [[dependencies]] + storage-slot = "demo::owner_config" + "#; + + let metadata = AccountComponentMetadata::from_toml(toml_str).unwrap(); + let slot_name = StorageSlotName::new("demo::owner_config").unwrap(); + assert_eq!(metadata.dependencies(), [ComponentDependency::StorageSlot(slot_name)]); + + let reparsed = AccountComponentMetadata::from_toml(&metadata.to_toml().unwrap()).unwrap(); + assert_eq!(reparsed, metadata); +} + +#[test] +fn metadata_from_toml_rejects_invalid_dependency_slot_name() { + let toml_str = r#" + name = "Test Component" + description = "Test description" + version = "0.1.0" + + [[dependencies]] + storage-slot = "_invalid" + "#; + + assert_matches::assert_matches!( + AccountComponentMetadata::from_toml(toml_str), + Err(ComponentMetadataError::TomlDeserializationError(_)) + ); +} + #[test] fn metadata_from_toml_rejects_non_ascii_component_description() { let toml_str = r#" diff --git a/crates/miden-protocol/src/account/mod.rs b/crates/miden-protocol/src/account/mod.rs index 35b3f9b5ca..0d9404fce0 100644 --- a/crates/miden-protocol/src/account/mod.rs +++ b/crates/miden-protocol/src/account/mod.rs @@ -1,4 +1,4 @@ -use alloc::string::ToString; +use alloc::string::{String, ToString}; use alloc::vec::Vec; use crate::asset::{Asset, AssetVault}; @@ -39,7 +39,12 @@ pub use code::AccountCode; pub use code::procedure::AccountProcedureRoot; pub mod component; -pub use component::{AccountComponent, AccountComponentCode, AccountComponentMetadata}; +pub use component::{ + AccountComponent, + AccountComponentCode, + AccountComponentMetadata, + ComponentDependency, +}; pub mod interface; pub use interface::{AccountCodeInterface, AccountComponentName}; @@ -189,12 +194,41 @@ impl Account { /// - Other components contain authentication procedures. /// - The number of [`StorageSlot`]s of all components exceeds 255. /// - [`MastForest::merge`](miden_processor::MastForest::merge) fails on all packages. + /// - A component declares a [`ComponentDependency`] that no component on the account satisfies. pub(super) fn initialize_from_components( components: Vec, ) -> Result<(AccountCode, AccountStorage), AccountError> { let code = AccountCode::from_components_unchecked(&components)?; + + // Collect the declared dependencies before the components are consumed, so they can be + // checked against the merged storage below. + let dependencies: Vec<(String, ComponentDependency)> = components + .iter() + .flat_map(|component| { + let component_name = component.metadata().name(); + component + .metadata() + .dependencies() + .iter() + .map(move |dependency| (component_name.to_string(), dependency.clone())) + }) + .collect(); + let storage = AccountStorage::from_components(components)?; + for (component_name, dependency) in dependencies { + match dependency { + ComponentDependency::StorageSlot(slot_name) => { + if storage.get(&slot_name).is_none() { + return Err(AccountError::UnsatisfiedComponentDependency { + component_name, + slot_name, + }); + } + }, + } + } + Ok((code, storage)) } diff --git a/crates/miden-protocol/src/errors/mod.rs b/crates/miden-protocol/src/errors/mod.rs index c298b44dec..b7741775c4 100644 --- a/crates/miden-protocol/src/errors/mod.rs +++ b/crates/miden-protocol/src/errors/mod.rs @@ -169,6 +169,13 @@ pub enum AccountError { DuplicateStorageSlotName(StorageSlotName), #[error("storage does not contain a slot with name {slot_name}")] StorageSlotNameNotFound { slot_name: StorageSlotName }, + #[error( + "component {component_name} depends on storage slot {slot_name}, which no component installed on the account provides" + )] + UnsatisfiedComponentDependency { + component_name: String, + slot_name: StorageSlotName, + }, #[error("storage does not contain a slot with ID {slot_id}")] StorageSlotIdNotFound { slot_id: StorageSlotId }, #[error("storage slots must be sorted by slot ID")] diff --git a/crates/miden-standards/src/account/access/authority.rs b/crates/miden-standards/src/account/access/authority.rs index ae55608a8e..dcf5d19eb8 100644 --- a/crates/miden-standards/src/account/access/authority.rs +++ b/crates/miden-standards/src/account/access/authority.rs @@ -13,6 +13,7 @@ use miden_protocol::account::{ AccountComponent, AccountProcedureRoot, AccountStorage, + ComponentDependency, RoleSymbol, StorageMap, StorageMapKey, @@ -25,6 +26,7 @@ use miden_protocol::utils::sync::LazyLock; use miden_protocol::{Felt, Word}; use thiserror::Error; +use crate::account::access::Ownable2Step; use crate::account::account_component_code; use crate::procedure_root; @@ -102,10 +104,9 @@ const RBAC_CONTROLLED: u8 = 2; /// unfrozen. /// /// The flag is toggled via `freeze` / `unfreeze`. Under [`Authority::OwnerControlled`] these are -/// gated on the [`Ownable2Step`][crate::account::access::Ownable2Step] owner; under -/// [`Authority::RbacControlled`] they resolve their role from the role map (e.g. `FREEZER` / -/// `UNFREEZER`), defaulting to the `ADMIN` role. Both bypass the frozen flag itself so the switch -/// can always be toggled. +/// gated on the [`Ownable2Step`] owner; under [`Authority::RbacControlled`] they resolve their +/// role from the role map (e.g. `FREEZER` / `UNFREEZER`), defaulting to the `ADMIN` role. Both +/// bypass the frozen flag itself so the switch can always be toggled. /// /// This flag has no effect under [`Authority::AuthControlled`], where `freeze` / `unfreeze` panic /// (there is no owner and no role graph). @@ -162,7 +163,7 @@ const RBAC_CONTROLLED: u8 = 2; pub enum Authority { /// Authority is the account's auth component. AuthControlled = AUTH_CONTROLLED, - /// Authority is the [`Ownable2Step`][crate::account::access::Ownable2Step] owner. + /// Authority is the [`Ownable2Step`] owner. OwnerControlled = OWNER_CONTROLLED, /// Authority is membership in an RBAC role, resolved per gated procedure. /// @@ -281,12 +282,22 @@ impl Authority { let storage_schema = StorageSchema::new(slots).expect("storage schema should be valid"); - AccountComponentMetadata::new(Self::NAME) + let metadata = AccountComponentMetadata::new(Self::NAME) .with_description( "Account-wide authority shared by procedures that gate state-mutating \ operations behind auth-only, owner-based, or RBAC role-based checks", ) - .with_storage_schema(storage_schema) + .with_storage_schema(storage_schema); + + // Under `OwnerControlled`, `assert_authorized` resolves the caller against the owner + // recorded by `Ownable2Step`, whose storage slot this component does not install itself. + if matches!(self, Authority::OwnerControlled) { + metadata.with_dependency(ComponentDependency::StorageSlot( + Ownable2Step::slot_name().clone(), + )) + } else { + metadata + } } // PRIVATE HELPERS diff --git a/crates/miden-standards/src/account/faucets/fungible/tests.rs b/crates/miden-standards/src/account/faucets/fungible/tests.rs index d4b879a4c6..ec5e667a63 100644 --- a/crates/miden-standards/src/account/faucets/fungible/tests.rs +++ b/crates/miden-standards/src/account/faucets/fungible/tests.rs @@ -2,6 +2,7 @@ use assert_matches::assert_matches; use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment}; use miden_protocol::account::{AccountBuilder, AccountId, AccountType, StorageMapKey}; use miden_protocol::asset::{AssetAmount, FungibleAsset, TokenSymbol}; +use miden_protocol::errors::AccountError; use miden_protocol::{Felt, Word}; use super::{ @@ -11,7 +12,7 @@ use super::{ create_network_fungible_faucet, create_singlesig_user_fungible_faucet, }; -use crate::account::access::{AccessControl, Authority}; +use crate::account::access::{AccessControl, Authority, Ownable2Step}; use crate::account::auth::{ Approver, AuthGuardedMultisig, @@ -289,3 +290,87 @@ fn get_faucet_procedures() { let _set_send_policy_root = TokenPolicyManager::set_send_policy_root(); let _set_receive_policy_root = TokenPolicyManager::set_receive_policy_root(); } + +/// A user faucet installs no `Ownable2Step` component, so an owner-only mint policy has no owner +/// slot to read and every mint would abort. +#[test] +fn user_fungible_faucet_rejects_owner_only_mint_policy() { + let auth_component = AuthSingleSig::new(Approver::new( + Word::new([Felt::ONE; 4]).into(), + AuthScheme::Falcon512Poseidon2, + )); + + let token_policy_manager = TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::owner_only()) + .active_burn_policy(BurnPolicy::allow_all()) + .build(); + + let err = create_singlesig_user_fungible_faucet( + [11u8; 32], + sample_faucet(), + auth_component, + token_policy_manager, + AccountType::Private, + ) + .expect_err("owner-only mint policy without Ownable2Step should be rejected"); + + assert_matches!(err, FungibleFaucetError::AccountError(AccountError::BuildError(_, Some(source))) => { + assert_matches!(*source, AccountError::UnsatisfiedComponentDependency { slot_name, .. } => { + assert_eq!(&slot_name, Ownable2Step::slot_name()); + }); + }); +} + +#[test] +fn user_fungible_faucet_rejects_reserved_owner_only_burn_policy() { + let auth_component = AuthSingleSig::new(Approver::new( + Word::new([Felt::ONE; 4]).into(), + AuthScheme::Falcon512Poseidon2, + )); + + let token_policy_manager = TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::allow_all()) + .active_burn_policy(BurnPolicy::allow_all()) + .allowed_burn_policy(BurnPolicy::owner_only()) + .build(); + + let err = create_singlesig_user_fungible_faucet( + [12u8; 32], + sample_faucet(), + auth_component, + token_policy_manager, + AccountType::Private, + ) + .expect_err("reserved owner-only burn policy without Ownable2Step should be rejected"); + + assert_matches!(err, FungibleFaucetError::AccountError(AccountError::BuildError(_, Some(source))) => { + assert_matches!(*source, AccountError::UnsatisfiedComponentDependency { slot_name, .. } => { + assert_eq!(&slot_name, Ownable2Step::slot_name()); + }); + }); +} + +/// A network faucet installs `Ownable2Step` through its access control, so the owner-only policy +/// family finds the owner slot and the dependency check passes. +#[test] +fn network_fungible_faucet_accepts_owner_only_policies() { + use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE; + + let owner = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE).unwrap(); + + let token_policy_manager = TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::owner_only()) + .active_burn_policy(BurnPolicy::owner_only()) + .build(); + + let account = create_network_fungible_faucet( + [13u8; 32], + sample_faucet(), + AccessControl::Ownable2Step { owner }, + token_policy_manager, + FeePolicyManager::mock(FungibleAsset::mock_issuer()), + ) + .expect("owner-only policies are satisfied by the Ownable2Step access control"); + + assert_eq!(Ownable2Step::try_from_storage(account.storage()).unwrap().owner(), Some(owner)); +} diff --git a/crates/miden-standards/src/account/faucets/non_fungible/tests.rs b/crates/miden-standards/src/account/faucets/non_fungible/tests.rs index 5296d4e2ba..5b5d0e1f3d 100644 --- a/crates/miden-standards/src/account/faucets/non_fungible/tests.rs +++ b/crates/miden-standards/src/account/faucets/non_fungible/tests.rs @@ -1,7 +1,15 @@ +use assert_matches::assert_matches; +use miden_protocol::account::AccountType; +use miden_protocol::account::auth::AuthScheme; use miden_protocol::asset::TokenSymbol; +use miden_protocol::errors::AccountError; +use miden_protocol::{Felt, Word}; -use super::NonFungibleFaucet; -use crate::account::faucets::TokenName; +use super::{NonFungibleFaucet, create_user_non_fungible_faucet}; +use crate::account::access::Ownable2Step; +use crate::account::auth::{Approver, AuthSingleSig}; +use crate::account::faucets::{NonFungibleFaucetError, TokenName}; +use crate::account::policies::{BurnPolicy, MintPolicy, TokenPolicyManager}; /// Building a faucet exposes the configured fields. #[test] @@ -32,3 +40,38 @@ fn compute_asset_commitment_is_salt_sensitive() { assert_eq!(c_a, NonFungibleFaucet::compute_asset_commitment(data, salt_a)); assert_ne!(c_a, c_b); } + +/// A user faucet installs no `Ownable2Step` component, so an owner-only mint policy has no owner +/// slot to read and every mint would abort. The factory must reject that configuration. +#[test] +fn user_non_fungible_faucet_rejects_owner_only_mint_policy() { + let faucet = NonFungibleFaucet::builder() + .name(TokenName::new("Example Collection").unwrap()) + .symbol(TokenSymbol::new("EC").unwrap()) + .build(); + + let auth_component = AuthSingleSig::new(Approver::new( + Word::new([Felt::ONE; 4]).into(), + AuthScheme::Falcon512Poseidon2, + )); + + let token_policy_manager = TokenPolicyManager::builder() + .active_mint_policy(MintPolicy::owner_only()) + .active_burn_policy(BurnPolicy::allow_all()) + .build(); + + let err = create_user_non_fungible_faucet( + [21u8; 32], + faucet, + auth_component, + token_policy_manager, + AccountType::Private, + ) + .expect_err("owner-only mint policy without Ownable2Step should be rejected"); + + assert_matches!(err, NonFungibleFaucetError::AccountCreationFailed(AccountError::BuildError(_, Some(source))) => { + assert_matches!(*source, AccountError::UnsatisfiedComponentDependency { slot_name, .. } => { + assert_eq!(&slot_name, Ownable2Step::slot_name()); + }); + }); +} diff --git a/crates/miden-standards/src/account/policies/burn/owner_only.rs b/crates/miden-standards/src/account/policies/burn/owner_only.rs index 808eecb3ed..ca93441d67 100644 --- a/crates/miden-standards/src/account/policies/burn/owner_only.rs +++ b/crates/miden-standards/src/account/policies/burn/owner_only.rs @@ -1,6 +1,12 @@ use miden_protocol::account::component::{AccountComponentCode, AccountComponentMetadata}; -use miden_protocol::account::{AccountComponent, AccountComponentName, AccountProcedureRoot}; +use miden_protocol::account::{ + AccountComponent, + AccountComponentName, + AccountProcedureRoot, + ComponentDependency, +}; +use crate::account::access::Ownable2Step; use crate::account::account_component_code; use crate::procedure_root; @@ -34,8 +40,9 @@ procedure_root!( /// the `Ownable2Step` component) may trigger burn operations. /// /// Companion components required: -/// - [`crate::account::access::Ownable2Step`] — provides the owner storage slot the auth check -/// reads. Without it, the faucet builds successfully but every burn reverts. +/// - [`Ownable2Step`] — provides the owner storage slot the auth check reads. The component +/// declares that slot as a [`ComponentDependency`], so building an account that installs this +/// policy without it fails instead of producing a faucet whose every burn reverts. #[derive(Debug, Clone, Copy, Default)] pub struct BurnOwnerOnly; @@ -64,9 +71,11 @@ impl BurnOwnerOnly { impl From for AccountComponent { fn from(_: BurnOwnerOnly) -> Self { - let metadata = AccountComponentMetadata::new(BurnOwnerOnly::NAME).with_description( - "`owner_only` burn policy (owner-controlled family) for fungible faucets", - ); + let metadata = AccountComponentMetadata::new(BurnOwnerOnly::NAME) + .with_description( + "`owner_only` burn policy (owner-controlled family) for fungible faucets", + ) + .with_dependency(ComponentDependency::StorageSlot(Ownable2Step::slot_name().clone())); AccountComponent::new(BurnOwnerOnly::code().clone(), vec![], metadata).expect( "`owner_only` burn policy component should satisfy the requirements of a valid account component", diff --git a/crates/miden-standards/src/account/policies/manager.rs b/crates/miden-standards/src/account/policies/manager.rs index 368e1bdb4a..7c6cb9c8fb 100644 --- a/crates/miden-standards/src/account/policies/manager.rs +++ b/crates/miden-standards/src/account/policies/manager.rs @@ -275,46 +275,26 @@ impl TokenPolicyManager { let mut policies: BTreeMap = BTreeMap::new(); - insert_policy( - &mut policies, - active_mint_policy_root, - active_mint_policy.into_iter().collect(), - PolicyKind::Mint, - ); - insert_policy( - &mut policies, - active_burn_policy_root, - active_burn_policy.into_iter().collect(), - PolicyKind::Burn, - ); + insert_policy(&mut policies, active_mint_policy_root, active_mint_policy, PolicyKind::Mint); + insert_policy(&mut policies, active_burn_policy_root, active_burn_policy, PolicyKind::Burn); if let Some(policy) = active_send_policy { - insert_policy( - &mut policies, - active_send_policy_root, - policy.into_iter().collect(), - PolicyKind::Send, - ); + insert_policy(&mut policies, active_send_policy_root, policy, PolicyKind::Send); } if let Some(policy) = active_receive_policy { - insert_policy( - &mut policies, - active_receive_policy_root, - policy.into_iter().collect(), - PolicyKind::Receive, - ); + insert_policy(&mut policies, active_receive_policy_root, policy, PolicyKind::Receive); } for (root, policy) in allowed_mint_policies { - insert_policy(&mut policies, root, policy.into_iter().collect(), PolicyKind::Mint); + insert_policy(&mut policies, root, policy, PolicyKind::Mint); } for (root, policy) in allowed_burn_policies { - insert_policy(&mut policies, root, policy.into_iter().collect(), PolicyKind::Burn); + insert_policy(&mut policies, root, policy, PolicyKind::Burn); } for (root, policy) in allowed_send_policies { - insert_policy(&mut policies, root, policy.into_iter().collect(), PolicyKind::Send); + insert_policy(&mut policies, root, policy, PolicyKind::Send); } for (root, policy) in allowed_receive_policies { - insert_policy(&mut policies, root, policy.into_iter().collect(), PolicyKind::Receive); + insert_policy(&mut policies, root, policy, PolicyKind::Receive); } Self { @@ -676,10 +656,10 @@ impl TokenPolicyManager { /// Inserts a policy entry into the unified `policies` map. The new kind is appended to the /// entry's kind set. The first call wins for the companion components, which guarantees a /// given root's companion components are not duplicated across kinds. -fn insert_policy( +fn insert_policy>( policies: &mut BTreeMap, root: AccountProcedureRoot, - components: Vec, + policy: P, kind: PolicyKind, ) { policies @@ -690,7 +670,10 @@ fn insert_policy( .or_insert_with(|| { let mut kinds = BTreeSet::new(); kinds.insert(kind); - PolicyConfig { components, kinds } + PolicyConfig { + components: policy.into_iter().collect(), + kinds, + } }); } diff --git a/crates/miden-standards/src/account/policies/mint/owner_only.rs b/crates/miden-standards/src/account/policies/mint/owner_only.rs index ee3c242495..38e1efde88 100644 --- a/crates/miden-standards/src/account/policies/mint/owner_only.rs +++ b/crates/miden-standards/src/account/policies/mint/owner_only.rs @@ -1,6 +1,12 @@ use miden_protocol::account::component::{AccountComponentCode, AccountComponentMetadata}; -use miden_protocol::account::{AccountComponent, AccountComponentName, AccountProcedureRoot}; +use miden_protocol::account::{ + AccountComponent, + AccountComponentName, + AccountProcedureRoot, + ComponentDependency, +}; +use crate::account::access::Ownable2Step; use crate::account::account_component_code; use crate::procedure_root; @@ -34,8 +40,9 @@ procedure_root!( /// the `Ownable2Step` component) may trigger mint operations. /// /// Companion components required: -/// - [`crate::account::access::Ownable2Step`] — provides the owner storage slot the auth check -/// reads. Without it, the faucet builds successfully but every mint reverts. +/// - [`Ownable2Step`] — provides the owner storage slot the auth check reads. The component +/// declares that slot as a [`ComponentDependency`], so building an account that installs this +/// policy without it fails instead of producing a faucet whose every mint reverts. #[derive(Debug, Clone, Copy, Default)] pub struct MintOwnerOnly; @@ -64,9 +71,11 @@ impl MintOwnerOnly { impl From for AccountComponent { fn from(_: MintOwnerOnly) -> Self { - let metadata = AccountComponentMetadata::new(MintOwnerOnly::NAME).with_description( - "`owner_only` mint policy (owner-controlled family) for fungible faucets", - ); + let metadata = AccountComponentMetadata::new(MintOwnerOnly::NAME) + .with_description( + "`owner_only` mint policy (owner-controlled family) for fungible faucets", + ) + .with_dependency(ComponentDependency::StorageSlot(Ownable2Step::slot_name().clone())); AccountComponent::new(MintOwnerOnly::code().clone(), vec![], metadata).expect( "`owner_only` mint policy component should satisfy the requirements of a valid account component",