From 732cab5d904ef5cc23729010656caf8b461c1bdc Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Wed, 24 Jun 2026 22:19:07 +0300 Subject: [PATCH 01/16] feat(agglayer): RBAC-based access control for bridge roles Replace the bridge's hard-coded admin / GER-injector / GER-remover account-ID storage slots with the miden-standards access-control stack (Ownable2Step + RoleBasedAccessControl + Authority). The role-gated procedures (register_faucet, store_faucet_metadata_hash, update_ger, remove_ger) now call authority::assert_authorized, which checks the note sender holds the FAUCET_ADMIN / GER_INJECTOR / GER_REMOVER role mapped to that procedure. A governance owner can grant/revoke roles and transfer ownership via Ownable2Step. - miden-standards: add RoleAssignment + RoleBasedAccessControl::with_roles to seed initial role members at construction; AccessControl::Rbac gains a `members` field. - bridge MASM: drop the three ID slots / bespoke assert_sender_is_* procs; gate the four privileged procedures via authority::assert_authorized. - bridge Rust: add BridgeRoleMember, role-symbol + procedure-root accessors, and the fixed procedure->role map; create_bridge_account now takes an owner and a Vec instead of three bare account IDs. - build.rs: include the RBAC stack when computing BRIDGE_CODE_COMMITMENT. - update SPEC.md, agglayer integration tests, and bench setups. On-chain role-management notes (grant/revoke role, transfer ownership) are a planned follow-up, so role rotation is not yet exercisable on-chain. Refs #2706. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/bench-transaction/src/context_setups.rs | 37 ++- crates/miden-agglayer/SPEC.md | 121 +++++---- .../asm/agglayer/bridge/bridge_config.masm | 113 ++------- crates/miden-agglayer/build.rs | 27 +- crates/miden-agglayer/src/bridge.rs | 235 +++++++++++++----- crates/miden-agglayer/src/lib.rs | 40 +-- .../miden-standards/src/account/access/mod.rs | 26 +- .../src/account/access/rbac.rs | 140 ++++++++++- .../miden-testing/tests/agglayer/bridge_in.rs | 18 +- .../tests/agglayer/bridge_out.rs | 19 +- .../tests/agglayer/config_bridge.rs | 33 ++- .../tests/agglayer/faucet_helpers.rs | 10 +- .../agglayer/network_account_regression.rs | 13 +- .../tests/agglayer/remove_ger.rs | 17 +- .../tests/agglayer/test_utils.rs | 37 ++- .../tests/agglayer/update_ger.rs | 17 +- .../miden-testing/tests/scripts/authority.rs | 2 +- .../miden-testing/tests/scripts/pausable.rs | 2 +- crates/miden-testing/tests/scripts/rbac.rs | 65 ++++- 19 files changed, 660 insertions(+), 312 deletions(-) diff --git a/bin/bench-transaction/src/context_setups.rs b/bin/bench-transaction/src/context_setups.rs index d998176cf0..1becf82e09 100644 --- a/bin/bench-transaction/src/context_setups.rs +++ b/bin/bench-transaction/src/context_setups.rs @@ -3,6 +3,7 @@ pub use miden_agglayer::testing::ClaimDataSource; use miden_agglayer::{ AggLayerBridge, B2AggNote, + BridgeRoleMember, ClaimNote, ClaimNoteStorage, ConfigAggBridgeNote, @@ -14,7 +15,7 @@ use miden_agglayer::{ create_existing_bridge_account, }; use miden_protocol::account::auth::AuthScheme; -use miden_protocol::account::{Account, StorageMapKey}; +use miden_protocol::account::{Account, AccountId, AccountIdVersion, AccountType, StorageMapKey}; use miden_protocol::asset::{Asset, FungibleAsset}; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::note::{NoteAssets, NoteType}; @@ -26,6 +27,30 @@ use miden_standards::note::StandardNote; use miden_testing::{Auth, MockChain, TransactionContext}; use rand::Rng; +// BRIDGE ACCOUNT HELPER +// ================================================================================================ + +/// Builds an existing bridge account seeded with the three operational roles (`FAUCET_ADMIN`, +/// `GER_INJECTOR`, `GER_REMOVER`) and a fixed dummy governance owner. Benchmark accounts do not +/// exercise the owner's role-management powers. +fn bench_bridge_account( + seed: Word, + faucet_admin: AccountId, + ger_injector: AccountId, + ger_remover: AccountId, +) -> Account { + let owner = AccountId::dummy([0xee; 15], AccountIdVersion::Version1, AccountType::Public); + create_existing_bridge_account( + seed, + owner, + vec![ + BridgeRoleMember::FaucetAdmin(faucet_admin), + BridgeRoleMember::GerInjector(ger_injector), + BridgeRoleMember::GerRemover(ger_remover), + ], + ) +} + // P2ID NOTE SETUPS // ================================================================================================ @@ -194,12 +219,8 @@ pub async fn tx_consume_claim_note(data_source: ClaimDataSource) -> Result) -> Result [1, 0, 0, 0]` in the `ger_map`, marking the GER as known. 4. Reverts if the GER was already present in the map (duplicate insertions are rejected). @@ -123,13 +123,14 @@ to be valid. > `UPDATE_GER` note causes the consuming transaction to revert. Because `UPDATE_GER` is a > network note (consumed by the note nullifier mechanism), a duplicate would become > permanently unconsumable rather than silently accepted. Rejecting duplicates makes the -> failure explicit and prevents the GER injector from accidentally creating unconsumed notes. +> failure explicit and prevents the `GER_INJECTOR` role holder from accidentally creating unconsumed notes. -A separate GER Remover role can revoke a previously-registered GER by sending a +A separate `GER_REMOVER` role can revoke a previously-registered GER by sending a [`REMOVE_GER`](#45-remove_ger) note. The bridge consumes such a note and: -1. Asserts the note sender is the designated GER remover (a role distinct from the GER - injector so that insertion and revocation authority can be split). +1. Asserts (via `authority::assert_authorized`) that the note sender holds the `GER_REMOVER` + role (a role distinct from the `GER_INJECTOR` role so that insertion and revocation + authority can be split). 2. Computes `KEY = poseidon2::merge(GER_LOWER, GER_UPPER)`. 3. Asserts that `ger_map[KEY] == [1, 0, 0, 0]`, i.e. that the GER is currently known. 4. Overwrites `ger_map[KEY]` with `[0, 0, 0, 0]`, the Miden equivalent of Solidity's @@ -153,11 +154,11 @@ Claims that were already processed against the GER are not reversed - removal on prevents future claims against that root. Note that removal does not blocklist a GER permanently: because the map entry is reset -to the empty word, the GER injector can re-register the same GER via a subsequent +to the empty word, the `GER_INJECTOR` role holder can re-register the same GER via a subsequent `UPDATE_GER` note (re-insertion does not touch the removal chain). This is a security -caveat worth calling out: a compromised or faulty GER injector can undo a `REMOVE_GER` +caveat worth calling out: a compromised or faulty `GER_INJECTOR` role holder can undo a `REMOVE_GER` emergency patch and re-open the very claim window the removal was meant to close. The -split between the injector and remover roles bounds this only if the offending role can be +split between the `GER_INJECTOR` and `GER_REMOVER` roles bounds this only if the offending role can be rotated out, which is not yet supported ([#2706](https://github.com/0xMiden/protocol/issues/2706)). The removed-GER hash chain is therefore an append-only log of removal events, not a registry of currently revoked GERs @@ -173,8 +174,9 @@ TODO: No hash chain tracks GER insertions for proof generation Each bridged token (wrapped or Miden-native) requires registration in the bridge's registries. The Bridge Operator creates [`CONFIG_AGG_BRIDGE`](#43-config_agg_bridge) notes carrying the faucet's account ID, the origin token address, the origin network, the scale -factor, the metadata hash, and an `is_native` flag. The bridge consumes the note (asserting -the sender is the bridge admin) and runs two calls back-to-back: +factor, the metadata hash, and an `is_native` flag. The bridge consumes the note (asserting, +via `authority::assert_authorized`, that the sender holds the `FAUCET_ADMIN` role) and runs +two calls back-to-back: - `bridge_config::register_faucet` writes the registration flag plus `is_native` into `faucet_registry_map`, the conversion metadata into `faucet_metadata_map` (sub-keys 0 and @@ -196,20 +198,30 @@ TODO: Faucet existence and code commitment are not validated during registration ### 2.5 Administration -The bridge has three administrative roles set at account creation time: - -- **Bridge admin** (`admin_account_id`): authorizes faucet registration via - [`CONFIG_AGG_BRIDGE`](#43-config_agg_bridge) notes. -- **GER injector** (`ger_injector_account_id`): authorizes GER updates via [`UPDATE_GER`](#44-update_ger) - notes. -- **GER remover** (`ger_remover_account_id`): authorizes GER removals via - [`REMOVE_GER`](#45-remove_ger) notes. Kept distinct from the GER injector so that insertion - and revocation authority can be split. - -All roles are verified by checking the note sender against the stored account ID. - -TODO: Administrative roles cannot be transferred after account creation -([#2706](https://github.com/0xMiden/protocol/issues/2706)). +The bridge uses role-based access control (RBAC) for its privileged operations, built on the +`miden-standards` access-control stack (`Ownable2Step` + `RoleBasedAccessControl` + `Authority`) +installed on the bridge account alongside the bridge component. + +- **Governance owner** (`Ownable2Step`): the top-level authority. The owner can grant and revoke + the operational roles below, and can transfer its position via the two-step + `transfer_ownership` / `accept_ownership` flow. +- **`FAUCET_ADMIN` role**: authorizes faucet registration via + [`CONFIG_AGG_BRIDGE`](#43-config_agg_bridge) notes (`register_faucet`, + `store_faucet_metadata_hash`). +- **`GER_INJECTOR` role**: authorizes GER injection via [`UPDATE_GER`](#44-update_ger) notes + (`update_ger`). +- **`GER_REMOVER` role**: authorizes GER removal via [`REMOVE_GER`](#45-remove_ger) notes + (`remove_ger`). Kept distinct from the `GER_INJECTOR` role so that insertion and revocation + authority can be split. + +Each role-gated procedure calls `authority::assert_authorized`, which resolves the calling +procedure's required role from the account's `Authority` procedure-to-role map and asserts that the +note sender holds that role (a role may have multiple holders). The governance owner and the +initial role holders are seeded at account creation. + +TODO: On-chain role management — notes that call `grant_role` / `revoke_role` / +`transfer_ownership` — is not yet part of the bridge's accepted-note allowlist; it is planned as a +follow-up to [#2706](https://github.com/0xMiden/protocol/issues/2706). TODO: No emergency pause mechanism exists ([#2696](https://github.com/0xMiden/protocol/issues/2696)). @@ -258,10 +270,10 @@ Bridges an asset out of Miden into the AggLayer: | **Inputs** | `[origin_token_addr(5), origin_network, faucet_id_suffix, faucet_id_prefix, pad(8)]` | | **Outputs** | `[pad(16)]` | | **Context** | Consuming a `CONFIG_AGG_BRIDGE` note on the bridge account | -| **Panics** | Note sender is not the bridge admin | +| **Panics** | Note sender does not hold the `FAUCET_ADMIN` role | -Asserts the note sender matches the bridge admin stored in -`agglayer::bridge::admin_account_id`, then performs a two-step registration: +Asserts (via `authority::assert_authorized`) that the note sender holds the `FAUCET_ADMIN` +role, then performs a two-step registration: 1. Writes `[0, 0, faucet_id_suffix, faucet_id_prefix] -> [1, 0, 0, 0]` into the `faucet_registry_map` map slot. @@ -281,10 +293,10 @@ Asserts the note sender matches the bridge admin stored in | **Inputs** | `[GER_LOWER(4), GER_UPPER(4), pad(8)]` | | **Outputs** | `[pad(16)]` | | **Context** | Consuming an `UPDATE_GER` note on the bridge account | -| **Panics** | Note sender is not the GER injector; GER has already been registered in storage | +| **Panics** | Note sender does not hold the `GER_INJECTOR` role; GER has already been registered in storage | -Asserts the note sender matches the GER injector stored in -`agglayer::bridge::ger_injector_account_id`, then computes +Asserts (via `authority::assert_authorized`) that the note sender holds the `GER_INJECTOR` +role, then computes `KEY = poseidon2::merge(GER_LOWER, GER_UPPER)` and stores `KEY -> [1, 0, 0, 0]` in the `ger_map` map slot. This marks the GER as "known". Duplicate insertions (same GER value) are explicitly rejected: if the key already exists @@ -342,15 +354,17 @@ Validates a bridge-in claim and creates a MINT note targeting the faucet: | `agglayer::bridge::claim_nullifiers` | Map | `Poseidon2::hash_elements(leaf_index, source_bridge_network)` | `[1, 0, 0, 0]` if claimed | Prevents double-claiming of bridge-in deposits | | `agglayer::bridge::cgi_chain_hash_lo` | Value | -- | Lower word of the CGI chain hash | CGI chain hash low word (Keccak-256 lower 16 bytes) | | `agglayer::bridge::cgi_chain_hash_hi` | Value | -- | Upper word of the CGI chain hash | CGI chain hash high word (Keccak-256 upper 16 bytes) | -| `agglayer::bridge::admin_account_id` | Value | -- | `[0, 0, admin_suffix, admin_prefix]` | Bridge admin account ID for CONFIG note authorization | -| `agglayer::bridge::ger_injector_account_id` | Value | -- | `[0, 0, mgr_suffix, mgr_prefix]` | GER injector account ID for UPDATE_GER note authorization | -| `agglayer::bridge::ger_remover_account_id` | Value | -- | `[0, 0, rem_suffix, rem_prefix]` | GER remover account ID for REMOVE_GER note authorization | | `agglayer::bridge::removed_ger_hash_chain_lo` | Value | -- | Lower word of the removed-GER hash chain | Removed-GER hash chain low word (Keccak-256 lower 16 bytes) | | `agglayer::bridge::removed_ger_hash_chain_hi` | Value | -- | Upper word of the removed-GER hash chain | Removed-GER hash chain high word (Keccak-256 upper 16 bytes) | -Initial state: all map slots empty, all value slots `[0, 0, 0, 0]` except -`admin_account_id`, `ger_injector_account_id`, and `ger_remover_account_id` (set at account -creation time). +The privileged-role state is held by the access-control components installed on the bridge account +(`Ownable2Step` owner config, `RoleBasedAccessControl` role config/membership maps, and the +`Authority` procedure-to-role map), documented in `miden-standards`, rather than in dedicated bridge +slots. See [Administration](#25-administration). + +Initial state: all map slots empty, all value slots `[0, 0, 0, 0]`. The governance owner and the +initial `FAUCET_ADMIN` / `GER_INJECTOR` / `GER_REMOVER` role holders are seeded into the +access-control components at account creation time. ### 3.2 Faucet Account Component @@ -604,7 +618,7 @@ The storage is divided into three logical regions: proof data (felts 0-535), lea | Field | Value | |-------|-------| -| `sender` | Bridge admin (sender authorization enforced by the bridge's `register_faucet` procedure) | +| `sender` | Holder of the `FAUCET_ADMIN` role (sender authorization enforced by the bridge's `register_faucet` procedure via `authority::assert_authorized`) | | `note_type` | `NoteType::Public` | | `tag` | `NoteTag::default()` | | `attachment` | `NetworkAccountTarget` -- target is the bridge account; execution hint: Always | @@ -631,14 +645,15 @@ The storage is divided into three logical regions: proof data (felts 0-535), lea | 7 | `faucet_id_prefix` | Felt (AccountId prefix) | **Consumption:** Script validates attachment target, loads storage, and calls -`bridge_config::register_faucet` (which asserts sender is bridge admin and performs -two-step registration into `faucet_registry_map` and `token_registry_map`). +`bridge_config::register_faucet` (which asserts, via `authority::assert_authorized`, that the +sender holds the `FAUCET_ADMIN` role and performs two-step registration into +`faucet_registry_map` and `token_registry_map`). #### Permissions | Role | Enforcement | |------|------------| -| **Issuer** | Bridge admin only -- **enforced** by `bridge_config::register_faucet` procedure | +| **Issuer** | Holders of the `FAUCET_ADMIN` role only -- **enforced** by `bridge_config::register_faucet` via `authority::assert_authorized` | | **Consumer** | Bridge account -- **enforced** via `NetworkAccountTarget` attachment | ### 4.4 UPDATE_GER @@ -652,7 +667,7 @@ CLAIM notes can be verified against it. | Field | Value | |-------|-------| -| `sender` | GER injector (sender authorization enforced by the bridge's `update_ger` procedure) | +| `sender` | Holder of the `GER_INJECTOR` role (sender authorization enforced by the bridge's `update_ger` procedure via `authority::assert_authorized`) | | `note_type` | `NoteType::Public` | | `tag` | `NoteTag::default()` | | `attachment` | `NetworkAccountTarget` -- target is the bridge account; execution hint: Always | @@ -677,14 +692,15 @@ CLAIM notes can be verified against it. | 4-7 | `GER_UPPER` | Last 16 bytes as 4 x u32 felts | **Consumption:** Script validates attachment target, loads storage, and calls -`bridge_config::update_ger` (which asserts sender is GER injector), which computes +`bridge_config::update_ger` (which asserts, via `authority::assert_authorized`, that the +sender holds the `GER_INJECTOR` role), which computes `poseidon2::merge(GER_LOWER, GER_UPPER)` and stores the result in the GER map. #### Permissions | Role | Enforcement | |------|------------| -| **Issuer** | GER injector only -- **enforced** by `bridge_config::update_ger` procedure | +| **Issuer** | Holders of the `GER_INJECTOR` role only -- **enforced** by `bridge_config::update_ger` via `authority::assert_authorized` | | **Consumer** | Bridge account -- **enforced** via `NetworkAccountTarget` attachment | ### 4.5 REMOVE_GER @@ -699,7 +715,7 @@ removed-GER keccak256 hash chain. | Field | Value | |-------|-------| -| `sender` | GER remover (sender authorization enforced by the bridge's `remove_ger` procedure) | +| `sender` | Holder of the `GER_REMOVER` role (sender authorization enforced by the bridge's `remove_ger` procedure via `authority::assert_authorized`) | | `note_type` | `NoteType::Public` | | `tag` | `NoteTag::default()` | | `attachment` | `NetworkAccountTarget` -- target is the bridge account; execution hint: Always | @@ -724,7 +740,8 @@ removed-GER keccak256 hash chain. | 4-7 | `GER_UPPER` | Last 16 bytes as 4 x u32 felts | **Consumption:** Script validates attachment target, loads storage, and calls -`bridge_config::remove_ger` (which asserts sender is GER remover), which computes +`bridge_config::remove_ger` (which asserts, via `authority::assert_authorized`, that the +sender holds the `GER_REMOVER` role), which computes `poseidon2::merge(GER_LOWER, GER_UPPER)`, asserts the GER map entry equals `[1, 0, 0, 0]` while overwriting it with `[0, 0, 0, 0]`, and updates the removed-GER hash chain as `keccak256(prev_chain || GER)` (see [Section 2.3](#23-ger-injection)). @@ -733,7 +750,7 @@ while overwriting it with `[0, 0, 0, 0]`, and updates the removed-GER hash chain | Role | Enforcement | |------|------------| -| **Issuer** | GER remover only -- **enforced** by `bridge_config::remove_ger` procedure | +| **Issuer** | Holders of the `GER_REMOVER` role only -- **enforced** by `bridge_config::remove_ger` via `authority::assert_authorized` | | **Consumer** | Bridge account -- **enforced** via `NetworkAccountTarget` attachment | ### 4.6 BURN (generated) @@ -1173,7 +1190,7 @@ token metadata — symbol, decimals, max supply, and token supply Conversion metadata (origin address, origin network, scale, and metadata hash) is *not* stored on the faucet; it is carried by the `CONFIG_AGG_BRIDGE` note at registration time and written directly into the bridge's `faucet_metadata_map`. The metadata hash is -precomputed by the bridge admin and is currently not verified onchain +precomputed by the `FAUCET_ADMIN` role holder and is currently not verified onchain (TODO Verify metadata hash onchain ([#2586](https://github.com/0xMiden/protocol/issues/2586))). Registration is performed via [`CONFIG_AGG_BRIDGE`](#43-config_agg_bridge) notes. The bridge @@ -1199,7 +1216,7 @@ data and calls `bridge_config::lookup_faucet_by_token_address` to find the regis faucet. If the `(origin_token_address, origin_network)` pair is not registered, the `CLAIM` note consumption will fail. -The bridge admin is a trusted role, and is the sole entity that can register faucets on +The `FAUCET_ADMIN` role holder is trusted, and is the sole entity that can register faucets on the Miden side (enforced by the caller restriction on [`bridge_config::register_faucet`](#bridge_configregister_faucet)). @@ -1221,8 +1238,8 @@ operations against them — *not* how they are registered. `origin_token_address` is the faucet's own `AccountId` in the [Embedded Format](#62-embedded-format), and `origin_network` is Miden's own network ID. -In both cases the bridge admin drives registration via the same `CONFIG_AGG_BRIDGE` note; -the bridge admin is responsible for setting `is_native` correctly for the faucet at hand. +In both cases the `FAUCET_ADMIN` role holder drives registration via the same `CONFIG_AGG_BRIDGE` note; +the `FAUCET_ADMIN` role holder is responsible for setting `is_native` correctly for the faucet at hand. ### 7.2 Bridging-out: How tokens are registered on other chains diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm index 5c27768e8f..748cef8094 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm @@ -3,8 +3,8 @@ use miden::core::crypto::hashes::poseidon2 use miden::core::word use miden::protocol::account_id use miden::protocol::active_account -use miden::protocol::active_note use miden::protocol::native_account +use miden::standards::access::authority use agglayer::common::utils # ERRORS @@ -14,17 +14,16 @@ const ERR_GER_NOT_FOUND = "GER not found in storage" const ERR_GER_ALREADY_REGISTERED = "GER is already registered in storage" const ERR_FAUCET_NOT_REGISTERED = "faucet is not registered in the bridge's faucet registry" const ERR_TOKEN_NOT_REGISTERED = "(origin token address, origin network) pair is not registered in the bridge's token registry" -const ERR_SENDER_NOT_BRIDGE_ADMIN = "note sender is not the bridge admin" -const ERR_SENDER_NOT_GER_INJECTOR = "note sender is not the global exit root injector" -const ERR_SENDER_NOT_GER_REMOVER = "note sender is not the global exit root remover" # CONSTANTS # ================================================================================================= -# Storage slots -const BRIDGE_ADMIN_SLOT = word("agglayer::bridge::admin_account_id") -const GER_INJECTOR_SLOT = word("agglayer::bridge::ger_injector_account_id") -const GER_REMOVER_SLOT = word("agglayer::bridge::ger_remover_account_id") +# Storage slots. +# +# The privileged roles (faucet admin, GER injector, GER remover) are no longer stored as plain +# account IDs here. Authorization for the role-gated procedures below is delegated to the account's +# `Authority` component (RBAC-controlled), which maps each procedure root to the role required to +# invoke it. See `authority::assert_authorized`. const GER_MAP_STORAGE_SLOT = word("agglayer::bridge::ger_map") const FAUCET_REGISTRY_MAP_SLOT = word("agglayer::bridge::faucet_registry_map") const TOKEN_REGISTRY_MAP_SLOT = word("agglayer::bridge::token_registry_map") @@ -79,8 +78,8 @@ const FAUCET_METADATA_SUBKEY_HASH_HI = 3 # METADATA_HASH_HI[4] #! #! Invocation: call pub proc update_ger - # assert the note sender is the global exit root injector. - exec.assert_sender_is_ger_injector + # assert the note sender is authorized for this procedure (holds the GER injector role). + exec.authority::assert_authorized # => [GER_LOWER[4], GER_UPPER[4], pad(8)] # compute hash(GER) = poseidon2::merge(GER_LOWER, GER_UPPER) @@ -122,8 +121,8 @@ end #! #! Invocation: call pub proc remove_ger - # assert the note sender is the global exit root remover. - exec.assert_sender_is_ger_remover + # assert the note sender is authorized for this procedure (holds the GER remover role). + exec.authority::assert_authorized # => [GER_LOWER[4], GER_UPPER[4], pad(8)] # duplicate the GER (16 felts) so we can use one copy to compute the map key @@ -211,7 +210,8 @@ end #! Invocation: call @locals(14) pub proc register_faucet - exec.assert_sender_is_bridge_admin + # assert the note sender is authorized for this procedure (holds the faucet admin role). + exec.authority::assert_authorized # => [addr0, addr1, addr2, addr3, addr4, faucet_id_suffix, faucet_id_prefix, scale, origin_network, is_native, pad(6)] # Save non-address data to locals. @@ -324,7 +324,8 @@ end #! #! Invocation: call pub proc store_faucet_metadata_hash - exec.assert_sender_is_bridge_admin + # assert the note sender is authorized for this procedure (holds the faucet admin role). + exec.authority::assert_authorized # => [faucet_id_suffix, faucet_id_prefix, MH_LO, MH_HI, pad(6)] # --- Store METADATA_HASH_LO at key [SUBKEY_HASH_LO, 0, faucet_id_suffix, faucet_id_prefix] --- @@ -553,90 +554,6 @@ proc hash_token_address # => [TOKEN_ADDR_HASH] end -#! Asserts that the note sender matches the bridge admin stored in account storage. -#! -#! Reads the bridge admin account ID from BRIDGE_ADMIN_SLOT and compares it against the sender of -#! the currently executing note. -#! -#! Inputs: [pad(16)] -#! Outputs: [pad(16)] -#! -#! Panics if: -#! - the note sender does not match the bridge admin account ID. -#! -#! Invocation: exec -proc assert_sender_is_bridge_admin - push.BRIDGE_ADMIN_SLOT[0..2] - exec.active_account::get_item - # => [0, 0, admin_suffix, admin_prefix, pad(16)] - - drop drop - # => [admin_suffix, admin_prefix, pad(16)] - - exec.active_note::get_sender - # => [sender_suffix, sender_prefix, admin_suffix, admin_prefix, pad(16)] - - exec.account_id::is_equal - assert.err=ERR_SENDER_NOT_BRIDGE_ADMIN - # => [pad(16)] -end - -#! Asserts that the note sender matches the global exit root injector stored in account storage. -#! -#! Reads the GER injector account ID from GER_INJECTOR_SLOT and compares it against the sender of the -#! currently executing note. -#! -#! Inputs: [pad(16)] -#! Outputs: [pad(16)] -#! -#! Panics if: -#! - the note sender does not match the GER injector account ID. -#! -#! Invocation: exec -proc assert_sender_is_ger_injector - push.GER_INJECTOR_SLOT[0..2] - exec.active_account::get_item - # => [0, 0, mgr_suffix, mgr_prefix, pad(16)] - - drop drop - # => [mgr_suffix, mgr_prefix, pad(16)] - - exec.active_note::get_sender - # => [sender_suffix, sender_prefix, mgr_suffix, mgr_prefix, pad(16)] - - exec.account_id::is_equal - assert.err=ERR_SENDER_NOT_GER_INJECTOR - # => [pad(16)] -end - -#! Asserts that the note sender matches the global exit root remover stored in account storage. -#! -#! Reads the GER remover account ID from GER_REMOVER_SLOT and compares it against the sender of the -#! currently executing note. -#! -#! Inputs: [pad(16)] -#! Outputs: [pad(16)] -#! -#! Panics if: -#! - the note sender does not match the GER remover account ID. -#! -#! Invocation: exec -proc assert_sender_is_ger_remover - push.GER_REMOVER_SLOT[0..2] - exec.active_account::get_item - # => [0, 0, rem_suffix, rem_prefix, pad(16)] - - drop drop - # => [rem_suffix, rem_prefix, pad(16)] - - exec.active_note::get_sender - # => [sender_suffix, sender_prefix, rem_suffix, rem_prefix, pad(16)] - - exec.account_id::is_equal - assert.err=ERR_SENDER_NOT_GER_REMOVER - # => [pad(16)] -end - #! Updates the removed-GER keccak256 hash chain by folding in the provided GER. #! #! Computes NEW_CHAIN = keccak256::merge(OLD_CHAIN, GER), then writes the new chain to the diff --git a/crates/miden-agglayer/build.rs b/crates/miden-agglayer/build.rs index 64a2334c4d..32af37e91e 100644 --- a/crates/miden-agglayer/build.rs +++ b/crates/miden-agglayer/build.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeSet, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::env; use std::fmt::Write; use std::path::Path; @@ -12,7 +12,7 @@ use miden_crypto::hash::keccak::{Keccak256, Keccak256Digest}; use miden_protocol::account::{AccountCode, AccountComponent, AccountComponentMetadata}; use miden_protocol::note::NoteScriptRoot; use miden_protocol::transaction::TransactionKernel; -use miden_standards::account::access::Authority; +use miden_standards::account::access::{AccessControl, Authority}; use miden_standards::account::auth::AuthNetworkAccount; use miden_standards::account::policies::{ BurnPolicy, @@ -328,14 +328,25 @@ fn generate_agglayer_constants( let placeholder_allowlist = BTreeSet::from([NoteScriptRoot::from_raw(Word::default())]); let auth_component = AuthNetworkAccount::with_allowed_notes(placeholder_allowlist) .expect("placeholder allowlist is non-empty"); + // Use a dummy owner for commitment computation - the actual owner is set at runtime. Only + // the component code (not storage) contributes to the code commitment. + let dummy_owner = miden_protocol::account::AccountId::try_from( + miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, + ) + .unwrap(); + let mut components: Vec = vec![AccountComponent::from(auth_component), agglayer_component]; - if lib_name == "faucet" { - // Use a dummy owner for commitment computation - the actual owner is set at runtime - let dummy_owner = miden_protocol::account::AccountId::try_from( - miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, - ) - .unwrap(); + if lib_name == "bridge" { + // The bridge installs the RBAC access-control stack (Ownable2Step + RBAC + + // Authority::RbacControlled), matching `create_bridge_account_builder` in lib.rs. Empty + // role config / members suffice here since only component code affects the commitment. + components.extend(AccessControl::Rbac { + owner: dummy_owner, + roles: BTreeMap::new(), + members: Vec::new(), + }); + } else if lib_name == "faucet" { components.push(AccountComponent::from( miden_standards::account::access::Ownable2Step::new(dummy_owner), )); diff --git a/crates/miden-agglayer/src/bridge.rs b/crates/miden-agglayer/src/bridge.rs index 3b7588cc3e..17ec0bff16 100644 --- a/crates/miden-agglayer/src/bridge.rs +++ b/crates/miden-agglayer/src/bridge.rs @@ -1,22 +1,24 @@ extern crate alloc; -use alloc::collections::BTreeSet; +use alloc::collections::{BTreeMap, BTreeSet}; use alloc::vec; use alloc::vec::Vec; use miden_core::{Felt, ONE, Word, ZERO}; -use miden_protocol::account::component::AccountComponentMetadata; +use miden_protocol::account::component::{AccountComponentCode, AccountComponentMetadata}; use miden_protocol::account::{ Account, AccountComponent, AccountId, + AccountProcedureRoot, + RoleSymbol, StorageMapKey, StorageSlot, StorageSlotName, }; -use miden_protocol::block::account_tree::AccountIdKey; use miden_protocol::crypto::hash::poseidon2::Poseidon2; use miden_protocol::note::NoteScriptRoot; +use miden_standards::account::access::RoleAssignment; use miden_utils_sync::LazyLock; use thiserror::Error; @@ -57,18 +59,6 @@ include!(concat!(env!("OUT_DIR"), "/agglayer_constants.rs")); // bridge config // ------------------------------------------------------------------------------------------------ -static BRIDGE_ADMIN_ID_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("agglayer::bridge::admin_account_id") - .expect("bridge admin account ID storage slot name should be valid") -}); -static GER_INJECTOR_ID_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("agglayer::bridge::ger_injector_account_id") - .expect("GER injector account ID storage slot name should be valid") -}); -static GER_REMOVER_ID_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("agglayer::bridge::ger_remover_account_id") - .expect("GER remover account ID storage slot name should be valid") -}); static GER_MAP_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("agglayer::bridge::ger_map") .expect("GER map storage slot name should be valid") @@ -130,6 +120,72 @@ static LET_NUM_LEAVES_SLOT_NAME: LazyLock = LazyLock::new(|| { .expect("LET num_leaves storage slot name should be valid") }); +// BRIDGE RBAC ROLES +// ================================================================================================ + +// Namespace of the assembled bridge account component library (the `asm/components/bridge.masm` +// wrapper). Procedure roots are resolved as `::`. +const BRIDGE_COMPONENT_NAMESPACE: &str = "bridge"; + +static FAUCET_ADMIN_ROLE: LazyLock = LazyLock::new(|| { + RoleSymbol::new("FAUCET_ADMIN").expect("FAUCET_ADMIN role symbol should be valid") +}); +static GER_INJECTOR_ROLE: LazyLock = LazyLock::new(|| { + RoleSymbol::new("GER_INJECTOR").expect("GER_INJECTOR role symbol should be valid") +}); +static GER_REMOVER_ROLE: LazyLock = LazyLock::new(|| { + RoleSymbol::new("GER_REMOVER").expect("GER_REMOVER role symbol should be valid") +}); + +static REGISTER_FAUCET_ROOT: LazyLock = + LazyLock::new(|| bridge_procedure_root("register_faucet")); +static STORE_FAUCET_METADATA_HASH_ROOT: LazyLock = + LazyLock::new(|| bridge_procedure_root("store_faucet_metadata_hash")); +static UPDATE_GER_ROOT: LazyLock = + LazyLock::new(|| bridge_procedure_root("update_ger")); +static REMOVE_GER_ROOT: LazyLock = + LazyLock::new(|| bridge_procedure_root("remove_ger")); + +/// A privileged AggLayer bridge role together with the account that initially holds it. +/// +/// Used to seed the bridge account's RBAC role membership at creation. Each variant maps to a +/// distinct role symbol and gates a specific set of bridge procedures: +/// - [`BridgeRoleMember::FaucetAdmin`] (`FAUCET_ADMIN`) gates `register_faucet` and +/// `store_faucet_metadata_hash`. +/// - [`BridgeRoleMember::GerInjector`] (`GER_INJECTOR`) gates `update_ger`. +/// - [`BridgeRoleMember::GerRemover`] (`GER_REMOVER`) gates `remove_ger`. +/// +/// Pass several entries with the same variant to seed multiple holders of a role. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BridgeRoleMember { + /// Holder of the `FAUCET_ADMIN` role. + FaucetAdmin(AccountId), + /// Holder of the `GER_INJECTOR` role. + GerInjector(AccountId), + /// Holder of the `GER_REMOVER` role. + GerRemover(AccountId), +} + +impl BridgeRoleMember { + /// Returns the role symbol of this role. + pub fn role_symbol(&self) -> RoleSymbol { + match self { + BridgeRoleMember::FaucetAdmin(_) => AggLayerBridge::faucet_admin_role(), + BridgeRoleMember::GerInjector(_) => AggLayerBridge::ger_injector_role(), + BridgeRoleMember::GerRemover(_) => AggLayerBridge::ger_remover_role(), + } + } + + /// Returns the account ID that holds the role. + pub fn account_id(&self) -> AccountId { + match self { + BridgeRoleMember::FaucetAdmin(id) + | BridgeRoleMember::GerInjector(id) + | BridgeRoleMember::GerRemover(id) => *id, + } + } +} + /// An [`AccountComponent`] implementing the AggLayer Bridge. /// /// It reexports the procedures from `agglayer::bridge`. When linking against this @@ -143,11 +199,17 @@ static LET_NUM_LEAVES_SLOT_NAME: LazyLock = LazyLock::new(|| { /// - `claim`, which validates a claim against the AggLayer bridge and creates a MINT note for the /// AggLayer Faucet. /// +/// ## Access control +/// +/// The bridge's privileged roles are managed by the account's RBAC stack (`Ownable2Step` + +/// `RoleBasedAccessControl` + `Authority`), installed alongside this component at account +/// creation, rather than stored as plain account IDs in the bridge component. The role-gated +/// procedures (`register_faucet`, `store_faucet_metadata_hash`, `update_ger`, `remove_ger`) call +/// `authority::assert_authorized`, which requires the note sender to hold the role mapped to the +/// procedure. See [`BridgeRoleMember`] and [`AggLayerBridge::procedure_roles`]. +/// /// ## Storage Layout /// -/// - [`Self::bridge_admin_id_slot_name`]: Stores the bridge admin account ID. -/// - [`Self::ger_injector_id_slot_name`]: Stores the GER injector account ID. -/// - [`Self::ger_remover_id_slot_name`]: Stores the GER remover account ID. /// - [`Self::ger_map_slot_name`]: Stores the GERs. /// - [`Self::removed_ger_hash_chain_lo_slot_name`]: Stores the lower 128 bits of the removed-GER /// keccak256 hash chain. @@ -173,12 +235,8 @@ static LET_NUM_LEAVES_SLOT_NAME: LazyLock = LazyLock::new(|| { /// Claim validation compares the leaf's `destination_network` to the global MASM constant /// `agglayer::common::constants::MIDEN_NETWORK_ID`. Rust exposes the same value as /// [`Self::MIDEN_NETWORK_ID`] from generated `agglayer_constants.rs` file. -#[derive(Debug, Clone)] -pub struct AggLayerBridge { - bridge_admin_id: AccountId, - ger_injector_id: AccountId, - ger_remover_id: AccountId, -} +#[derive(Debug, Clone, Copy, Default)] +pub struct AggLayerBridge; impl AggLayerBridge { // CONSTANTS @@ -194,39 +252,99 @@ impl AggLayerBridge { // CONSTRUCTORS // -------------------------------------------------------------------------------------------- - /// Creates a new AggLayer bridge component with the standard configuration. - pub fn new( - bridge_admin_id: AccountId, - ger_injector_id: AccountId, - ger_remover_id: AccountId, - ) -> Self { - Self { - bridge_admin_id, - ger_injector_id, - ger_remover_id, - } + /// Creates a new AggLayer bridge component. + /// + /// The bridge's privileged roles (faucet admin, GER injector, GER remover) are not part of + /// this component; they are managed by the account's RBAC / `Authority` components and seeded + /// at account creation. See [`create_bridge_account`][crate::create_bridge_account]. + pub fn new() -> Self { + Self } - // PUBLIC ACCESSORS + // RBAC ROLES // -------------------------------------------------------------------------------------------- - // --- bridge config ---- + /// Returns the `FAUCET_ADMIN` role symbol. Holders may register faucets and store faucet + /// metadata (`register_faucet`, `store_faucet_metadata_hash`). + pub fn faucet_admin_role() -> RoleSymbol { + FAUCET_ADMIN_ROLE.clone() + } + + /// Returns the `GER_INJECTOR` role symbol. Holders may inject GERs (`update_ger`). + pub fn ger_injector_role() -> RoleSymbol { + GER_INJECTOR_ROLE.clone() + } + + /// Returns the `GER_REMOVER` role symbol. Holders may remove GERs (`remove_ger`). + pub fn ger_remover_role() -> RoleSymbol { + GER_REMOVER_ROLE.clone() + } + + /// Returns the procedure root of the bridge's `register_faucet` procedure. + pub fn register_faucet_root() -> AccountProcedureRoot { + *REGISTER_FAUCET_ROOT + } + + /// Returns the procedure root of the bridge's `store_faucet_metadata_hash` procedure. + pub fn store_faucet_metadata_hash_root() -> AccountProcedureRoot { + *STORE_FAUCET_METADATA_HASH_ROOT + } - /// Storage slot name for the bridge admin account ID. - pub fn bridge_admin_id_slot_name() -> &'static StorageSlotName { - &BRIDGE_ADMIN_ID_SLOT_NAME + /// Returns the procedure root of the bridge's `update_ger` procedure. + pub fn update_ger_root() -> AccountProcedureRoot { + *UPDATE_GER_ROOT } - /// Storage slot name for the GER injector account ID. - pub fn ger_injector_id_slot_name() -> &'static StorageSlotName { - &GER_INJECTOR_ID_SLOT_NAME + /// Returns the procedure root of the bridge's `remove_ger` procedure. + pub fn remove_ger_root() -> AccountProcedureRoot { + *REMOVE_GER_ROOT } - /// Storage slot name for the GER remover account ID. - pub fn ger_remover_id_slot_name() -> &'static StorageSlotName { - &GER_REMOVER_ID_SLOT_NAME + /// Returns the fixed procedure-to-role map used to configure the account's `Authority` + /// (`RbacControlled`) component. Each role-gated bridge procedure is mapped to the role + /// required to invoke it. + pub fn procedure_roles() -> BTreeMap { + BTreeMap::from([ + (Self::register_faucet_root(), Self::faucet_admin_role()), + (Self::store_faucet_metadata_hash_root(), Self::faucet_admin_role()), + (Self::update_ger_root(), Self::ger_injector_role()), + (Self::remove_ger_root(), Self::ger_remover_role()), + ]) + } + + /// Groups the given role members into [`RoleAssignment`]s for seeding the account's RBAC + /// component (one assignment per role, in faucet-admin / GER-injector / GER-remover order). + /// Roles with no provided members are omitted. + pub fn rbac_role_assignments(members: &[BridgeRoleMember]) -> Vec { + let mut faucet_admins = Vec::new(); + let mut ger_injectors = Vec::new(); + let mut ger_removers = Vec::new(); + for member in members { + match member { + BridgeRoleMember::FaucetAdmin(id) => faucet_admins.push(*id), + BridgeRoleMember::GerInjector(id) => ger_injectors.push(*id), + BridgeRoleMember::GerRemover(id) => ger_removers.push(*id), + } + } + + let mut assignments = Vec::new(); + if !faucet_admins.is_empty() { + assignments.push(RoleAssignment::new(Self::faucet_admin_role(), faucet_admins)); + } + if !ger_injectors.is_empty() { + assignments.push(RoleAssignment::new(Self::ger_injector_role(), ger_injectors)); + } + if !ger_removers.is_empty() { + assignments.push(RoleAssignment::new(Self::ger_remover_role(), ger_removers)); + } + assignments } + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + // --- bridge config ---- + /// Storage slot name for the GERs map. pub fn ger_map_slot_name() -> &'static StorageSlotName { &GER_MAP_SLOT_NAME @@ -534,9 +652,6 @@ impl AggLayerBridge { &*FAUCET_REGISTRY_MAP_SLOT_NAME, &*TOKEN_REGISTRY_MAP_SLOT_NAME, &*FAUCET_METADATA_MAP_SLOT_NAME, - &*BRIDGE_ADMIN_ID_SLOT_NAME, - &*GER_INJECTOR_ID_SLOT_NAME, - &*GER_REMOVER_ID_SLOT_NAME, &*REMOVED_GER_HASH_CHAIN_LO_SLOT_NAME, &*REMOVED_GER_HASH_CHAIN_HI_SLOT_NAME, &*CGI_CHAIN_HASH_LO_SLOT_NAME, @@ -547,11 +662,7 @@ impl AggLayerBridge { } impl From for AccountComponent { - fn from(bridge: AggLayerBridge) -> Self { - let bridge_admin_word = AccountIdKey::new(bridge.bridge_admin_id).as_word(); - let ger_injector_word = AccountIdKey::new(bridge.ger_injector_id).as_word(); - let ger_remover_word = AccountIdKey::new(bridge.ger_remover_id).as_word(); - + fn from(_bridge: AggLayerBridge) -> Self { let bridge_storage_slots = vec![ StorageSlot::with_empty_map(GER_MAP_SLOT_NAME.clone()), StorageSlot::with_empty_map(LET_FRONTIER_SLOT_NAME.clone()), @@ -561,9 +672,6 @@ impl From for AccountComponent { StorageSlot::with_empty_map(FAUCET_REGISTRY_MAP_SLOT_NAME.clone()), StorageSlot::with_empty_map(TOKEN_REGISTRY_MAP_SLOT_NAME.clone()), StorageSlot::with_empty_map(FAUCET_METADATA_MAP_SLOT_NAME.clone()), - StorageSlot::with_value(BRIDGE_ADMIN_ID_SLOT_NAME.clone(), bridge_admin_word), - StorageSlot::with_value(GER_INJECTOR_ID_SLOT_NAME.clone(), ger_injector_word), - StorageSlot::with_value(GER_REMOVER_ID_SLOT_NAME.clone(), ger_remover_word), StorageSlot::with_value(REMOVED_GER_HASH_CHAIN_LO_SLOT_NAME.clone(), Word::empty()), StorageSlot::with_value(REMOVED_GER_HASH_CHAIN_HI_SLOT_NAME.clone(), Word::empty()), StorageSlot::with_value(CGI_CHAIN_HASH_LO_SLOT_NAME.clone(), Word::empty()), @@ -593,6 +701,19 @@ pub enum AgglayerBridgeError { // HELPER FUNCTIONS // ================================================================================================ +/// Resolves the [`AccountProcedureRoot`] of a procedure exported by the bridge account component +/// library, by its name (e.g. `update_ger`). +/// +/// # Panics +/// +/// Panics if the bridge component library does not export a procedure with the given name. +fn bridge_procedure_root(proc_name: &str) -> AccountProcedureRoot { + let code = AccountComponentCode::from(agglayer_bridge_component_library()); + let path = alloc::format!("{BRIDGE_COMPONENT_NAMESPACE}::{proc_name}"); + code.get_procedure_root_by_path(path.as_str()) + .unwrap_or_else(|| panic!("bridge component library should export `{path}`")) +} + /// Creates an AggLayer Bridge component with the specified storage slots. fn bridge_component(storage_slots: Vec) -> AccountComponent { let library = agglayer_bridge_component_library(); diff --git a/crates/miden-agglayer/src/lib.rs b/crates/miden-agglayer/src/lib.rs index 3fab13acc9..acc9a49178 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -2,12 +2,14 @@ extern crate alloc; +use alloc::vec::Vec; + use miden_assembly::Library; use miden_assembly::serde::Deserializable; use miden_core::{Felt, Word}; use miden_protocol::account::{Account, AccountBuilder, AccountComponent, AccountId, AccountType}; use miden_protocol::asset::TokenSymbol; -use miden_standards::account::access::{Authority, Ownable2Step}; +use miden_standards::account::access::{AccessControl, Authority, Ownable2Step}; use miden_standards::account::auth::AuthNetworkAccount; use miden_standards::account::policies::{ BurnAllowAll, @@ -33,7 +35,7 @@ pub mod update_ger_note; pub mod utils; pub use b2agg_note::B2AggNote; -pub use bridge::{AggLayerBridge, AgglayerBridgeError, RemovedGerHashChain}; +pub use bridge::{AggLayerBridge, AgglayerBridgeError, BridgeRoleMember, RemovedGerHashChain}; pub use claim_note::{ CgiChainHash, ClaimNote, @@ -130,17 +132,26 @@ fn create_agglayer_faucet_component( /// The bridge starts with an empty faucet registry. Faucets are registered at runtime /// via CONFIG_AGG_BRIDGE notes that call `bridge_config::register_faucet`. /// +/// Access control is provided by the RBAC stack: `owner` becomes the account's `Ownable2Step` +/// governance owner (able to grant/revoke roles and transfer ownership), and `role_members` seeds +/// the initial holders of the `FAUCET_ADMIN`, `GER_INJECTOR`, and `GER_REMOVER` roles that gate +/// the bridge's privileged procedures. +/// /// The builder is pre-wired with the [`AuthNetworkAccount`] auth component, initialized with /// [`AggLayerBridge::allowed_notes()`] so the bridge only accepts its sanctioned input notes. fn create_bridge_account_builder( seed: Word, - bridge_admin_id: AccountId, - ger_injector_id: AccountId, - ger_remover_id: AccountId, + owner: AccountId, + role_members: Vec, ) -> AccountBuilder { Account::builder(seed.into()) .account_type(AccountType::Public) - .with_component(AggLayerBridge::new(bridge_admin_id, ger_injector_id, ger_remover_id)) + .with_component(AggLayerBridge::new()) + .with_components(AccessControl::Rbac { + owner, + roles: AggLayerBridge::procedure_roles(), + members: AggLayerBridge::rbac_role_assignments(&role_members), + }) .with_auth_component( AuthNetworkAccount::with_allowed_notes(AggLayerBridge::allowed_notes()) .expect("bridge note allowlist is non-empty"), @@ -149,14 +160,14 @@ fn create_bridge_account_builder( /// Creates a new bridge account with the standard configuration. /// -/// This creates a new account suitable for production use. +/// This creates a new account suitable for production use. `owner` is the governance owner; the +/// initial role holders are seeded from `role_members` (see [`BridgeRoleMember`]). pub fn create_bridge_account( seed: Word, - bridge_admin_id: AccountId, - ger_injector_id: AccountId, - ger_remover_id: AccountId, + owner: AccountId, + role_members: Vec, ) -> Account { - create_bridge_account_builder(seed, bridge_admin_id, ger_injector_id, ger_remover_id) + create_bridge_account_builder(seed, owner, role_members) .build() .expect("bridge account should be valid") } @@ -167,11 +178,10 @@ pub fn create_bridge_account( #[cfg(any(feature = "testing", test))] pub fn create_existing_bridge_account( seed: Word, - bridge_admin_id: AccountId, - ger_injector_id: AccountId, - ger_remover_id: AccountId, + owner: AccountId, + role_members: Vec, ) -> Account { - create_bridge_account_builder(seed, bridge_admin_id, ger_injector_id, ger_remover_id) + create_bridge_account_builder(seed, owner, role_members) .build_existing() .expect("bridge account should be valid") } diff --git a/crates/miden-standards/src/account/access/mod.rs b/crates/miden-standards/src/account/access/mod.rs index 53d245ee57..9898fe8241 100644 --- a/crates/miden-standards/src/account/access/mod.rs +++ b/crates/miden-standards/src/account/access/mod.rs @@ -1,5 +1,6 @@ use alloc::collections::BTreeMap; use alloc::vec; +use alloc::vec::Vec; use miden_protocol::account::{AccountComponent, AccountId, AccountProcedureRoot, RoleSymbol}; @@ -20,7 +21,8 @@ pub mod rbac; /// setter gate enforces `sender == owner`. /// - [`AccessControl::Rbac`] → [`Ownable2Step`] + [`RoleBasedAccessControl`] + /// [`Authority::RbacControlled`]. The `roles` map assigns a role to individual gated procedures -/// (keyed by procedure root); procedures without a mapping fall back to the `owner` check. +/// (keyed by procedure root); procedures without a mapping fall back to the `owner` check. The +/// `members` list seeds initial role holders at account creation. /// /// Pass to /// [`AccountBuilder::with_components`][miden_protocol::account::AccountBuilder::with_components] @@ -33,8 +35,11 @@ pub mod rbac; /// use miden_standards::account::access::AccessControl; /// # let owner: miden_protocol::account::AccountId = unimplemented!(); /// # let init_seed = [0u8; 32]; -/// AccountBuilder::new(init_seed) -/// .with_components(AccessControl::Rbac { owner, roles: BTreeMap::new() }); +/// AccountBuilder::new(init_seed).with_components(AccessControl::Rbac { +/// owner, +/// roles: BTreeMap::new(), +/// members: Vec::new(), +/// }); /// ``` /// /// For accounts that don't use the [`AccessControl`] convenience but want to install the @@ -50,11 +55,14 @@ pub enum AccessControl { /// /// `roles` assigns a role to individual authority-gated procedures, keyed by procedure root /// (e.g. `PausableManager::pause_root()` → `PAUSER`, `unpause_root()` → `UNPAUSER`). A gated - /// procedure without an entry in `roles` falls back to the `owner` check. Role membership is - /// managed through the standard RBAC API on the [`RoleBasedAccessControl`] component. + /// procedure without an entry in `roles` falls back to the `owner` check. `members` seeds the + /// initial role holders at account creation (equivalent to the owner calling `grant_role` for + /// each member). Role membership is otherwise managed through the standard RBAC API on the + /// [`RoleBasedAccessControl`] component. Rbac { owner: AccountId, roles: BTreeMap, + members: Vec, }, } @@ -70,9 +78,11 @@ impl IntoIterator for AccessControl { AccessControl::Ownable2Step { owner } => { vec![Ownable2Step::new(owner).into(), Authority::OwnerControlled.into()].into_iter() }, - AccessControl::Rbac { owner, roles } => vec![ + AccessControl::Rbac { owner, roles, members } => vec![ Ownable2Step::new(owner).into(), - RoleBasedAccessControl::empty().into(), + RoleBasedAccessControl::with_roles(members) + .expect("initial RBAC role assignments should be valid") + .into(), Authority::RbacControlled { roles }.into(), ] .into_iter(), @@ -83,4 +93,4 @@ impl IntoIterator for AccessControl { pub use authority::{Authority, AuthorityError}; pub use ownable2step::{Ownable2Step, Ownable2StepError}; pub use pausable::{Pausable, PausableManager, PausableStorage}; -pub use rbac::RoleBasedAccessControl; +pub use rbac::{RbacError, RoleAssignment, RoleBasedAccessControl}; diff --git a/crates/miden-standards/src/account/access/rbac.rs b/crates/miden-standards/src/account/access/rbac.rs index 7a53bd9fa1..70edf337eb 100644 --- a/crates/miden-standards/src/account/access/rbac.rs +++ b/crates/miden-standards/src/account/access/rbac.rs @@ -1,4 +1,6 @@ +use alloc::collections::BTreeSet; use alloc::vec; +use alloc::vec::Vec; use miden_protocol::account::component::{ AccountComponentCode, @@ -10,11 +12,15 @@ use miden_protocol::account::component::{ use miden_protocol::account::{ AccountComponent, AccountComponentName, + AccountId, + RoleSymbol, StorageMap, + StorageMapKey, StorageSlot, StorageSlotName, }; use miden_protocol::utils::sync::LazyLock; +use miden_protocol::{Felt, Word}; use crate::account::account_component_code; @@ -107,7 +113,38 @@ static ROLE_MEMBERSHIP_SLOT_NAME: LazyLock = LazyLock::new(|| { /// /// [`RoleSymbol`]: miden_protocol::account::RoleSymbol #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct RoleBasedAccessControl; +pub struct RoleBasedAccessControl { + /// Initial role assignments seeded at account creation. Empty for a runtime-populated + /// component (see [`RoleBasedAccessControl::empty`]). + roles: Vec, +} + +/// A single initial role assignment used to seed a [`RoleBasedAccessControl`] component at +/// account creation. +/// +/// Seeding a role with these members is equivalent to the owner calling `grant_role` for each +/// member at runtime: it sets each member's membership flag and the role's member count. Seeded +/// roles are owner-managed (no delegated admin); delegated admins can be configured later via the +/// `set_role_admin` procedure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RoleAssignment { + /// The role to grant. + pub role: RoleSymbol, + /// The accounts that initially hold the role. Must be non-empty and free of duplicates. + pub members: Vec, +} + +impl RoleAssignment { + /// Creates a role assignment granting `role` to the given `members`. + pub fn new(role: RoleSymbol, members: Vec) -> Self { + Self { role, members } + } + + /// Creates a role assignment granting `role` to a single `member`. + pub fn single(role: RoleSymbol, member: AccountId) -> Self { + Self { role, members: vec![member] } + } +} impl RoleBasedAccessControl { pub const NAME: &'static str = "miden::standards::components::access::rbac"; @@ -125,7 +162,48 @@ impl RoleBasedAccessControl { /// Returns an empty RBAC component. Roles are populated at runtime via the /// `grant_role`, `set_role_admin`, etc. procedures exposed by the component. pub fn empty() -> Self { - Self + Self::default() + } + + /// Returns an RBAC component seeded with the given initial role assignments. + /// + /// Each [`RoleAssignment`] grants its role to one or more accounts at account creation, + /// exactly as if the owner had called `grant_role` for each member at runtime. All seeded + /// roles are owner-managed; delegated admins can be configured later via `set_role_admin`. + /// + /// # Errors + /// + /// Returns an error if a role is assigned more than once, an assignment has no members, the + /// same account is listed twice for a role, or a role has more members than `u32::MAX`. + pub fn with_roles(roles: Vec) -> Result { + let mut seen_roles = BTreeSet::new(); + for assignment in &roles { + let role_key = Felt::from(&assignment.role).as_canonical_u64(); + if !seen_roles.insert(role_key) { + return Err(RbacError::DuplicateRole(assignment.role.clone())); + } + if assignment.members.is_empty() { + return Err(RbacError::EmptyRole(assignment.role.clone())); + } + if u32::try_from(assignment.members.len()).is_err() { + return Err(RbacError::TooManyMembers(assignment.role.clone())); + } + let mut seen_members = BTreeSet::new(); + for member in &assignment.members { + let member_key = ( + member.suffix().as_canonical_u64(), + member.prefix().as_felt().as_canonical_u64(), + ); + if !seen_members.insert(member_key) { + return Err(RbacError::DuplicateMember { + role: assignment.role.clone(), + account: *member, + }); + } + } + } + + Ok(Self { roles }) } /// Returns the storage slot name for the per-role config map. @@ -177,14 +255,50 @@ impl RoleBasedAccessControl { } impl From for AccountComponent { - fn from(_rbac: RoleBasedAccessControl) -> Self { + fn from(rbac: RoleBasedAccessControl) -> Self { + // Build the per-role config and membership map entries from the seeded assignments. The + // key/value layout mirrors what the RBAC MASM writes via `grant_role`: + // - role_config: [0, 0, 0, role] -> [member_count, admin_role_symbol(=0), 0, 0] + // - role_membership: [0, role, account_suffix, account_prefix] -> [1, 0, 0, 0] + let mut config_entries = Vec::new(); + let mut membership_entries = Vec::new(); + + for assignment in &rbac.roles { + let role_felt = Felt::from(&assignment.role); + let member_count = u32::try_from(assignment.members.len()) + .expect("member count fits into u32 (validated in with_roles)"); + + config_entries.push(( + StorageMapKey::from_raw(Word::from([ + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + role_felt, + ])), + Word::from([Felt::from(member_count), Felt::ZERO, Felt::ZERO, Felt::ZERO]), + )); + + for member in &assignment.members { + membership_entries.push(( + StorageMapKey::from_raw(Word::from([ + Felt::ZERO, + role_felt, + member.suffix(), + member.prefix().as_felt(), + ])), + Word::from([Felt::ONE, Felt::ZERO, Felt::ZERO, Felt::ZERO]), + )); + } + } + let role_config_slot = StorageSlot::with_map( RoleBasedAccessControl::role_config_slot().clone(), - StorageMap::with_entries(vec![]).expect("empty role config map should be valid"), + StorageMap::with_entries(config_entries).expect("role config map should be valid"), ); let role_membership_slot = StorageSlot::with_map( RoleBasedAccessControl::role_membership_slot().clone(), - StorageMap::with_entries(vec![]).expect("empty role membership map should be valid"), + StorageMap::with_entries(membership_entries) + .expect("role membership map should be valid"), ); AccountComponent::new( @@ -195,3 +309,19 @@ impl From for AccountComponent { .expect("RBAC component should satisfy the requirements of a valid account component") } } + +// RBAC ERROR +// ================================================================================================ + +/// Errors that can occur when seeding a [`RoleBasedAccessControl`] component with initial roles. +#[derive(Debug, thiserror::Error)] +pub enum RbacError { + #[error("role {0} is assigned more than once in the initial role assignments")] + DuplicateRole(RoleSymbol), + #[error("role {0} has no initial members")] + EmptyRole(RoleSymbol), + #[error("account {account} is listed more than once for role {role}")] + DuplicateMember { role: RoleSymbol, account: AccountId }, + #[error("role {0} has more members than the u32 maximum")] + TooManyMembers(RoleSymbol), +} diff --git a/crates/miden-testing/tests/agglayer/bridge_in.rs b/crates/miden-testing/tests/agglayer/bridge_in.rs index 90d8a9e8c0..f9ba6434c0 100644 --- a/crates/miden-testing/tests/agglayer/bridge_in.rs +++ b/crates/miden-testing/tests/agglayer/bridge_in.rs @@ -26,7 +26,6 @@ use miden_agglayer::{ UpdateGerNote, agglayer_library, create_existing_agglayer_faucet, - create_existing_bridge_account, }; use miden_protocol::Felt; use miden_protocol::account::auth::AuthScheme; @@ -58,6 +57,7 @@ use super::test_utils::{ ClaimDataSource, MerkleProofVerificationFile, SOLIDITY_MERKLE_PROOF_VECTORS, + create_existing_bridge_account_with_roles, }; // CONSTANTS @@ -161,7 +161,7 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a // CREATE BRIDGE ACCOUNT // -------------------------------------------------------------------------------------------- let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = create_existing_bridge_account( + let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, bridge_admin.id(), ger_injector.id(), @@ -455,7 +455,7 @@ async fn test_mint_cannot_be_consumed_by_unrelated_faucet() -> anyhow::Result<() })?; let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = create_existing_bridge_account( + let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, bridge_admin.id(), ger_injector.id(), @@ -657,7 +657,7 @@ async fn test_claim_rejects_wrong_destination_network() -> anyhow::Result<()> { // CREATE BRIDGE ACCOUNT // -------------------------------------------------------------------------------------------- let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = create_existing_bridge_account( + let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, bridge_admin.id(), ger_injector.id(), @@ -803,7 +803,7 @@ async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { // CREATE BRIDGE ACCOUNT let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = create_existing_bridge_account( + let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, bridge_admin.id(), ger_injector.id(), @@ -970,7 +970,7 @@ async fn test_claim_rejects_removed_ger() -> anyhow::Result<()> { // CREATE BRIDGE ACCOUNT let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = create_existing_bridge_account( + let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, bridge_admin.id(), ger_injector.id(), @@ -1118,7 +1118,7 @@ async fn bridge_in_unlock_native_token() -> anyhow::Result<()> { let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - let mut bridge_account = create_existing_bridge_account( + let mut bridge_account = create_existing_bridge_account_with_roles( bridge_seed, bridge_admin.id(), ger_injector.id(), @@ -1382,7 +1382,7 @@ async fn bridge_in_unlock_native_duplicate_rejected() -> anyhow::Result<()> { let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - let mut bridge_account = create_existing_bridge_account( + let mut bridge_account = create_existing_bridge_account_with_roles( bridge_seed, bridge_admin.id(), ger_injector.id(), @@ -1622,7 +1622,7 @@ async fn test_claim_fails_when_origin_network_unregistered() -> anyhow::Result<( let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - let bridge_account = create_existing_bridge_account( + let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, bridge_admin.id(), ger_injector.id(), diff --git a/crates/miden-testing/tests/agglayer/bridge_out.rs b/crates/miden-testing/tests/agglayer/bridge_out.rs index 39a9a2a0a9..dea8050bd1 100644 --- a/crates/miden-testing/tests/agglayer/bridge_out.rs +++ b/crates/miden-testing/tests/agglayer/bridge_out.rs @@ -16,7 +16,6 @@ use miden_agglayer::{ Keccak256Output, MetadataHash, create_existing_agglayer_faucet, - create_existing_bridge_account, }; use miden_crypto::hash::keccak::Keccak256Digest; use miden_crypto::rand::FeltRng; @@ -43,7 +42,7 @@ use rand::rngs::StdRng; use rand::{Rng, SeedableRng}; use super::merkle_tree_frontier::MerkleTreeFrontier32; -use super::test_utils::SOLIDITY_MTF_VECTORS; +use super::test_utils::{SOLIDITY_MTF_VECTORS, create_existing_bridge_account_with_roles}; /// Tests that 32 sequential B2AGG note consumptions match all 32 Solidity MTF roots. /// @@ -94,7 +93,7 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - let mut bridge_account = create_existing_bridge_account( + let mut bridge_account = create_existing_bridge_account_with_roles( builder.rng_mut().draw_word(), bridge_admin.id(), ger_injector.id(), @@ -377,7 +376,7 @@ async fn bridge_out_at_high_num_leaves(#[case] initial_num_leaves: u32) -> anyho let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - let mut bridge_account = create_existing_bridge_account( + let mut bridge_account = create_existing_bridge_account_with_roles( bridge_seed, bridge_admin.id(), ger_injector.id(), @@ -509,7 +508,7 @@ async fn test_bridge_out_fails_with_unregistered_faucet() -> anyhow::Result<()> // CREATE BRIDGE ACCOUNT (empty faucet registry — no faucets registered) // -------------------------------------------------------------------------------------------- - let bridge_account = create_existing_bridge_account( + let bridge_account = create_existing_bridge_account_with_roles( builder.rng_mut().draw_word(), bridge_admin.id(), ger_injector.id(), @@ -607,7 +606,7 @@ async fn test_bridge_out_rejects_invalid_b2agg_note( let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - let mut bridge_account = create_existing_bridge_account( + let mut bridge_account = create_existing_bridge_account_with_roles( bridge_seed, bridge_admin.id(), ger_injector.id(), @@ -777,7 +776,7 @@ async fn b2agg_note_reclaim_scenario() -> anyhow::Result<()> { })?; // Create a bridge account (includes a `bridge` component) - let bridge_account = create_existing_bridge_account( + let bridge_account = create_existing_bridge_account_with_roles( builder.rng_mut().draw_word(), bridge_admin.id(), ger_injector.id(), @@ -897,7 +896,7 @@ async fn b2agg_note_non_target_account_cannot_consume() -> anyhow::Result<()> { })?; // Create a bridge account as the designated TARGET for the B2AGG note - let bridge_account = create_existing_bridge_account( + let bridge_account = create_existing_bridge_account_with_roles( builder.rng_mut().draw_word(), bridge_admin.id(), ger_injector.id(), @@ -911,7 +910,7 @@ async fn b2agg_note_non_target_account_cannot_consume() -> anyhow::Result<()> { })?; // Create a "malicious" account with a bridge interface - let malicious_account = create_existing_bridge_account( + let malicious_account = create_existing_bridge_account_with_roles( builder.rng_mut().draw_word(), bridge_admin.id(), ger_injector.id(), @@ -980,7 +979,7 @@ async fn bridge_out_lock_native_token() -> anyhow::Result<()> { let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - let mut bridge_account = create_existing_bridge_account( + let mut bridge_account = create_existing_bridge_account_with_roles( bridge_seed, bridge_admin.id(), ger_injector.id(), diff --git a/crates/miden-testing/tests/agglayer/config_bridge.rs b/crates/miden-testing/tests/agglayer/config_bridge.rs index cbfc92cc04..19b22ee6e4 100644 --- a/crates/miden-testing/tests/agglayer/config_bridge.rs +++ b/crates/miden-testing/tests/agglayer/config_bridge.rs @@ -8,7 +8,6 @@ use miden_agglayer::{ ConversionMetadata, EthAddress, MetadataHash, - create_existing_bridge_account, }; use miden_protocol::account::auth::AuthScheme; use miden_protocol::account::{AccountId, AccountIdVersion, AccountType, StorageMapKey}; @@ -18,6 +17,8 @@ use miden_protocol::transaction::RawOutputNote; use miden_protocol::{Felt, Hasher, Word}; use miden_testing::{Auth, MockChain}; +use super::test_utils::create_existing_bridge_account_with_roles; + /// Computes the `token_registry_map` key for a given (origin_token_address, origin_network) pair. /// /// Mirrors `bridge_config::hash_token_address` in `bridge_config.masm`: hashes the 5-felt token @@ -29,6 +30,32 @@ fn token_registry_key(origin_token_address: &EthAddress, origin_network: u32) -> StorageMapKey::from_raw(Hasher::hash_elements(&elements)) } +/// Pins the bridge's procedure-to-role authorization map: each role-gated procedure must map to +/// the expected role. Resolving the procedure roots also exercises the component-library lookup, +/// so a broken namespace or missing export would fail here. +#[test] +fn test_bridge_procedure_roles_mapping() { + let roles = AggLayerBridge::procedure_roles(); + + assert_eq!(roles.len(), 4, "exactly the four role-gated procedures must be mapped"); + assert_eq!( + roles.get(&AggLayerBridge::register_faucet_root()), + Some(&AggLayerBridge::faucet_admin_role()), + ); + assert_eq!( + roles.get(&AggLayerBridge::store_faucet_metadata_hash_root()), + Some(&AggLayerBridge::faucet_admin_role()), + ); + assert_eq!( + roles.get(&AggLayerBridge::update_ger_root()), + Some(&AggLayerBridge::ger_injector_role()), + ); + assert_eq!( + roles.get(&AggLayerBridge::remove_ger_root()), + Some(&AggLayerBridge::ger_remover_role()), + ); +} + /// Tests that a CONFIG_AGG_BRIDGE note registers a faucet in the bridge's faucet registry. /// /// Flow: @@ -57,7 +84,7 @@ async fn test_config_agg_bridge_registers_faucet() -> anyhow::Result<()> { })?; // CREATE BRIDGE ACCOUNT (starts with empty faucet registry) - let bridge_account = create_existing_bridge_account( + let bridge_account = create_existing_bridge_account_with_roles( builder.rng_mut().draw_word(), bridge_admin.id(), ger_injector.id(), @@ -150,7 +177,7 @@ async fn test_config_agg_bridge_distinguishes_origin_network() -> anyhow::Result let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - let bridge_account = create_existing_bridge_account( + let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, bridge_admin.id(), ger_injector.id(), diff --git a/crates/miden-testing/tests/agglayer/faucet_helpers.rs b/crates/miden-testing/tests/agglayer/faucet_helpers.rs index 1903968220..d344089d02 100644 --- a/crates/miden-testing/tests/agglayer/faucet_helpers.rs +++ b/crates/miden-testing/tests/agglayer/faucet_helpers.rs @@ -1,16 +1,14 @@ extern crate alloc; -use miden_agglayer::{ - AggLayerFaucet, - create_existing_agglayer_faucet, - create_existing_bridge_account, -}; +use miden_agglayer::{AggLayerFaucet, create_existing_agglayer_faucet}; use miden_protocol::Felt; use miden_protocol::account::auth::AuthScheme; use miden_protocol::asset::FungibleAsset; use miden_protocol::crypto::rand::FeltRng; use miden_testing::{Auth, MockChain}; +use super::test_utils::create_existing_bridge_account_with_roles; + #[test] fn test_faucet_helper_methods() -> anyhow::Result<()> { let mut builder = MockChain::builder(); @@ -25,7 +23,7 @@ fn test_faucet_helper_methods() -> anyhow::Result<()> { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - let bridge_account = create_existing_bridge_account( + let bridge_account = create_existing_bridge_account_with_roles( builder.rng_mut().draw_word(), bridge_admin.id(), ger_injector.id(), diff --git a/crates/miden-testing/tests/agglayer/network_account_regression.rs b/crates/miden-testing/tests/agglayer/network_account_regression.rs index e316bd38f1..6ea238f269 100644 --- a/crates/miden-testing/tests/agglayer/network_account_regression.rs +++ b/crates/miden-testing/tests/agglayer/network_account_regression.rs @@ -11,12 +11,7 @@ use core::slice; -use miden_agglayer::{ - ExitRoot, - UpdateGerNote, - create_existing_agglayer_faucet, - create_existing_bridge_account, -}; +use miden_agglayer::{ExitRoot, UpdateGerNote, create_existing_agglayer_faucet}; use miden_crypto::rand::FeltRng; use miden_protocol::Felt; use miden_protocol::account::auth::AuthScheme; @@ -29,6 +24,8 @@ use miden_standards::errors::standards::{ use miden_standards::testing::note::NoteBuilder; use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; +use super::test_utils::create_existing_bridge_account_with_roles; + /// Attack note script: trivial body whose root falls outside the bridge's allowlist. const ATTACK_NOTE_CODE: &str = "\ @note_script @@ -56,7 +53,7 @@ async fn bridge_rejects_tx_script() -> anyhow::Result<()> { let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - let bridge_account = create_existing_bridge_account( + let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, bridge_admin.id(), ger_injector.id(), @@ -107,7 +104,7 @@ async fn bridge_rejects_non_allowlisted_input_note() -> anyhow::Result<()> { let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - let bridge_account = create_existing_bridge_account( + let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, bridge_admin.id(), ger_injector.id(), diff --git a/crates/miden-testing/tests/agglayer/remove_ger.rs b/crates/miden-testing/tests/agglayer/remove_ger.rs index d31ee6407c..e44450e99a 100644 --- a/crates/miden-testing/tests/agglayer/remove_ger.rs +++ b/crates/miden-testing/tests/agglayer/remove_ger.rs @@ -1,20 +1,17 @@ extern crate alloc; -use miden_agglayer::errors::{ERR_GER_NOT_FOUND, ERR_SENDER_NOT_GER_REMOVER}; -use miden_agglayer::{ - AggLayerBridge, - ExitRoot, - RemoveGerNote, - UpdateGerNote, - create_existing_bridge_account, -}; +use miden_agglayer::errors::ERR_GER_NOT_FOUND; +use miden_agglayer::{AggLayerBridge, ExitRoot, RemoveGerNote, UpdateGerNote}; use miden_core_lib::handlers::keccak256::KeccakPreimage; use miden_protocol::account::Account; use miden_protocol::account::auth::AuthScheme; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::transaction::RawOutputNote; +use miden_standards::errors::standards::ERR_SENDER_LACKS_ROLE; use miden_testing::{Auth, MockChain, MockChainBuilder, assert_transaction_executor_error}; +use super::test_utils::create_existing_bridge_account_with_roles; + const GER_BYTES: [u8; 32] = [ 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, @@ -36,7 +33,7 @@ fn setup_bridge(builder: &mut MockChainBuilder) -> anyhow::Result<(Account, Acco })?; let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = create_existing_bridge_account( + let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, bridge_admin.id(), ger_injector.id(), @@ -375,7 +372,7 @@ async fn remove_ger_non_remover_sender_reverts() -> anyhow::Result<()> { .execute() .await; - assert_transaction_executor_error!(result, ERR_SENDER_NOT_GER_REMOVER); + assert_transaction_executor_error!(result, ERR_SENDER_LACKS_ROLE); Ok(()) } diff --git a/crates/miden-testing/tests/agglayer/test_utils.rs b/crates/miden-testing/tests/agglayer/test_utils.rs index b50f07491f..046d73eeab 100644 --- a/crates/miden-testing/tests/agglayer/test_utils.rs +++ b/crates/miden-testing/tests/agglayer/test_utils.rs @@ -1,8 +1,8 @@ extern crate alloc; use alloc::sync::Arc; +use alloc::vec; -use miden_agglayer::agglayer_library; pub use miden_agglayer::testing::{ ClaimDataSource, LEAF_VALUE_VECTORS_JSON, @@ -12,6 +12,7 @@ pub use miden_agglayer::testing::{ SOLIDITY_CANONICAL_ZEROS, SOLIDITY_MERKLE_PROOF_VECTORS, }; +use miden_agglayer::{BridgeRoleMember, agglayer_library, create_existing_bridge_account}; use miden_assembly::{Assembler, DefaultSourceManager}; use miden_core_lib::CoreLibrary; use miden_processor::advice::AdviceInputs; @@ -23,6 +24,8 @@ use miden_processor::{ Program, StackInputs, }; +use miden_protocol::Word; +use miden_protocol::account::{Account, AccountId, AccountIdVersion, AccountType}; use miden_protocol::transaction::TransactionKernel; use miden_protocol::utils::sync::LazyLock; @@ -42,6 +45,38 @@ pub static SOLIDITY_MTF_VECTORS: LazyLock = LazyLock::new(|| { serde_json::from_str(MTF_VECTORS_JSON).expect("failed to parse MTF vectors JSON") }); +// BRIDGE ACCOUNT HELPERS +// ================================================================================================ + +/// A fixed dummy governance owner used for bridge accounts in tests that don't exercise the +/// owner's role-management powers (granting/revoking roles, transferring ownership). +pub fn bridge_test_owner() -> AccountId { + AccountId::dummy([0xee; 15], AccountIdVersion::Version1, AccountType::Public) +} + +/// Creates an existing bridge account seeded with the three operational roles held by the given +/// accounts (`FAUCET_ADMIN`, `GER_INJECTOR`, `GER_REMOVER`) and the fixed [`bridge_test_owner`] +/// as the governance owner. +/// +/// Drop-in replacement for `create_existing_bridge_account` in tests: same `(seed, faucet_admin, +/// ger_injector, ger_remover)` arguments, but wires up the RBAC role seeding. +pub fn create_existing_bridge_account_with_roles( + seed: Word, + faucet_admin: AccountId, + ger_injector: AccountId, + ger_remover: AccountId, +) -> Account { + create_existing_bridge_account( + seed, + bridge_test_owner(), + vec![ + BridgeRoleMember::FaucetAdmin(faucet_admin), + BridgeRoleMember::GerInjector(ger_injector), + BridgeRoleMember::GerRemover(ger_remover), + ], + ) +} + // HELPER FUNCTIONS // ================================================================================================ diff --git a/crates/miden-testing/tests/agglayer/update_ger.rs b/crates/miden-testing/tests/agglayer/update_ger.rs index f0111e9513..b06181380e 100644 --- a/crates/miden-testing/tests/agglayer/update_ger.rs +++ b/crates/miden-testing/tests/agglayer/update_ger.rs @@ -5,13 +5,7 @@ use alloc::sync::Arc; use alloc::vec::Vec; use miden_agglayer::errors::ERR_GER_ALREADY_REGISTERED; -use miden_agglayer::{ - AggLayerBridge, - ExitRoot, - UpdateGerNote, - agglayer_library, - create_existing_bridge_account, -}; +use miden_agglayer::{AggLayerBridge, ExitRoot, UpdateGerNote, agglayer_library}; use miden_assembly::{Assembler, DefaultSourceManager}; use miden_core_lib::CoreLibrary; use miden_core_lib::handlers::keccak256::KeccakPreimage; @@ -25,7 +19,10 @@ use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; use miden_tx::utils::hex_to_bytes; use serde::Deserialize; -use super::test_utils::execute_program_with_default_host; +use super::test_utils::{ + create_existing_bridge_account_with_roles, + execute_program_with_default_host, +}; // EXIT ROOT TEST VECTORS // ================================================================================================ @@ -74,7 +71,7 @@ async fn update_ger_note_updates_storage() -> anyhow::Result<()> { // CREATE BRIDGE ACCOUNT // -------------------------------------------------------------------------------------------- let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = create_existing_bridge_account( + let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, bridge_admin.id(), ger_injector.id(), @@ -299,7 +296,7 @@ async fn update_ger_rejects_duplicate() -> anyhow::Result<()> { // CREATE BRIDGE ACCOUNT let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = create_existing_bridge_account( + let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, bridge_admin.id(), ger_injector.id(), diff --git a/crates/miden-testing/tests/scripts/authority.rs b/crates/miden-testing/tests/scripts/authority.rs index fd5dbad8e5..e4a4e4346f 100644 --- a/crates/miden-testing/tests/scripts/authority.rs +++ b/crates/miden-testing/tests/scripts/authority.rs @@ -82,7 +82,7 @@ fn add_rbac_faucet( let account_builder = AccountBuilder::new([seed; 32]) .account_type(AccountType::Public) .with_component(faucet) - .with_components(AccessControl::Rbac { owner, roles }) + .with_components(AccessControl::Rbac { owner, roles, members: Vec::new() }) .with_component(Pausable::unpaused()) .with_component(PausableManager); diff --git a/crates/miden-testing/tests/scripts/pausable.rs b/crates/miden-testing/tests/scripts/pausable.rs index 30efc89201..a5e6fa80f0 100644 --- a/crates/miden-testing/tests/scripts/pausable.rs +++ b/crates/miden-testing/tests/scripts/pausable.rs @@ -305,7 +305,7 @@ fn add_rbac_faucet_with_pause( let account_builder = AccountBuilder::new([seed; 32]) .account_type(AccountType::Public) .with_component(faucet) - .with_components(AccessControl::Rbac { owner, roles }) + .with_components(AccessControl::Rbac { owner, roles, members: Vec::new() }) .with_component(Pausable::unpaused()) .with_component(PausableManager); diff --git a/crates/miden-testing/tests/scripts/rbac.rs b/crates/miden-testing/tests/scripts/rbac.rs index 68a47cdfea..7ede479c3b 100644 --- a/crates/miden-testing/tests/scripts/rbac.rs +++ b/crates/miden-testing/tests/scripts/rbac.rs @@ -17,7 +17,12 @@ use miden_protocol::account::{ use miden_protocol::errors::AccountIdError; use miden_protocol::note::{Note, NoteType}; use miden_protocol::{Felt, Word}; -use miden_standards::account::access::{AccessControl, Ownable2Step, RoleBasedAccessControl}; +use miden_standards::account::access::{ + AccessControl, + Ownable2Step, + RoleAssignment, + RoleBasedAccessControl, +}; use miden_standards::errors::standards::{ ERR_ACCOUNT_NOT_IN_ROLE, ERR_ROLE_SYMBOL_ZERO, @@ -34,7 +39,24 @@ fn create_rbac_account_with_owner(owner: AccountId) -> anyhow::Result { let account = AccountBuilder::new([9; 32]) .account_type(AccountType::Public) .with_auth_component(Auth::IncrNonce) - .with_components(AccessControl::Rbac { owner, roles: BTreeMap::new() }) + .with_components(AccessControl::Rbac { + owner, + roles: BTreeMap::new(), + members: Vec::new(), + }) + .build_existing()?; + + Ok(account) +} + +fn create_rbac_account_with_members( + owner: AccountId, + members: Vec, +) -> anyhow::Result { + let account = AccountBuilder::new([9; 32]) + .account_type(AccountType::Public) + .with_auth_component(Auth::IncrNonce) + .with_components(AccessControl::Rbac { owner, roles: BTreeMap::new(), members }) .build_existing()?; Ok(account) @@ -335,6 +357,45 @@ fn set_role_admin_raw_script(role: Felt, admin_role: Felt) -> String { // TESTS // ================================================================================================ +/// Seeding initial role members at construction (`with_roles` via `AccessControl::Rbac`) must +/// produce identical on-chain RBAC storage to building an empty component and granting the same +/// members at runtime. +#[tokio::test] +async fn test_rbac_with_roles_matches_runtime_grants() -> anyhow::Result<()> { + let owner = test_account_id(101); + let alice = test_account_id(102); + let bob = test_account_id(103); + + let minter = role("MINTER"); + + // Account seeded with two MINTER members at construction. + let seeded = create_rbac_account_with_members( + owner, + vec![RoleAssignment::new(minter.clone(), vec![alice, bob])], + )?; + + // Account built empty, then granted the same two members at runtime. + let empty = create_rbac_account_with_owner(owner)?; + let mut builder = MockChain::builder(); + builder.add_account(empty.clone())?; + let mock_chain = builder.build()?; + + let grant_alice = build_note(owner, grant_role_script(&minter, alice))?; + let granted = execute_note_and_apply(&mock_chain, &empty, &grant_alice).await?; + let grant_bob = build_note(owner, grant_role_script(&minter, bob))?; + let granted = execute_note_and_apply(&mock_chain, &granted, &grant_bob).await?; + + // The two accounts must have byte-identical RBAC storage. + assert_eq!(seeded.storage().to_commitment(), granted.storage().to_commitment()); + + // Spot-check the seeded entries directly. + assert_eq!(get_role_config(&seeded, &minter)?.0, Felt::from(2u32)); + assert!(is_role_member(&seeded, &minter, alice)?); + assert!(is_role_member(&seeded, &minter, bob)?); + + Ok(()) +} + #[tokio::test] async fn test_rbac_owner_role_management_and_lookup() -> anyhow::Result<()> { let owner = test_account_id(11); From 4e43d7d23020d48d2606454ba83a812151755c32 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Wed, 24 Jun 2026 22:33:53 +0300 Subject: [PATCH 02/16] docs: add changelog entry for bridge RBAC access control (#3130) Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b979014a98..4b8bc6a902 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changes +- [BREAKING] Replaced the AggLayer bridge's hard-coded admin/injector/remover account-ID authorization with an RBAC access-control stack (`Ownable2Step` + `RoleBasedAccessControl` + `Authority`); `create_bridge_account` now takes `(seed, owner, Vec)`, `AggLayerBridge::new()` is now stateless, and `AccessControl::Rbac` gains a `members` field for seeding initial role holders ([#3130](https://github.com/0xMiden/protocol/pull/3130)). - Added a skeleton batch kernel ([#1122](https://github.com/0xMiden/protocol/issues/1122)) wired through `LocalBatchProver::prove` and attached to `ProvenBatch` as an `ExecutionProof`. It does not yet perform any verification. - [BREAKING] Renamed `AccountStorageDelta` to `AccountStoragePatch` ([#3002](https://github.com/0xMiden/protocol/pull/3002)). - [BREAKING] Replaced the per-tree account and nullifier backend traits with shared `SmtBackend` and `SmtBackendReader` traits, split into read-only and read-write capabilities, enabling read-only `LargeSmt`-backed tree views via `reader()` ([#2755](https://github.com/0xMiden/protocol/pull/2755), [#3009](https://github.com/0xMiden/protocol/pull/3009)). From 2dfe41a4d388998e77a93e44e53ab133e25e6e33 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Wed, 1 Jul 2026 16:48:17 +0300 Subject: [PATCH 03/16] refactor(agglayer): drop redundant AggLayerBridge::new() AggLayerBridge is a zero-field component; use the bare value via its Default/From impl (matching PausableManager and BurnAllowAll) instead of an empty new(). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/miden-agglayer/src/bridge.rs | 12 ------------ crates/miden-agglayer/src/lib.rs | 2 +- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/crates/miden-agglayer/src/bridge.rs b/crates/miden-agglayer/src/bridge.rs index 17ec0bff16..7fa29fd589 100644 --- a/crates/miden-agglayer/src/bridge.rs +++ b/crates/miden-agglayer/src/bridge.rs @@ -249,18 +249,6 @@ impl AggLayerBridge { const REGISTERED_GER_MAP_VALUE: Word = Word::new([ONE, ZERO, ZERO, ZERO]); - // CONSTRUCTORS - // -------------------------------------------------------------------------------------------- - - /// Creates a new AggLayer bridge component. - /// - /// The bridge's privileged roles (faucet admin, GER injector, GER remover) are not part of - /// this component; they are managed by the account's RBAC / `Authority` components and seeded - /// at account creation. See [`create_bridge_account`][crate::create_bridge_account]. - pub fn new() -> Self { - Self - } - // RBAC ROLES // -------------------------------------------------------------------------------------------- diff --git a/crates/miden-agglayer/src/lib.rs b/crates/miden-agglayer/src/lib.rs index acc9a49178..060773b9ec 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -146,7 +146,7 @@ fn create_bridge_account_builder( ) -> AccountBuilder { Account::builder(seed.into()) .account_type(AccountType::Public) - .with_component(AggLayerBridge::new()) + .with_component(AggLayerBridge) .with_components(AccessControl::Rbac { owner, roles: AggLayerBridge::procedure_roles(), From 54a3f53b48cf348b09f8fce9a0bcb6016bc34fbf Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Wed, 1 Jul 2026 21:30:14 +0300 Subject: [PATCH 04/16] refactor(standards): model seeded RBAC roles as a map of sets Represent initial role membership as BTreeMap> instead of Vec. Duplicate roles and duplicate members are now impossible by construction, so with_roles drops the two dedup passes (and the DuplicateRole / DuplicateMember error variants), and the RoleAssignment type is removed. The bridge's role grouping collapses to a single entry()-based loop. No behavior change: seeding still equals runtime grant_role (equivalence test passes). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/miden-agglayer/build.rs | 2 +- crates/miden-agglayer/src/bridge.rs | 31 ++---- crates/miden-agglayer/src/lib.rs | 2 +- .../miden-standards/src/account/access/mod.rs | 11 +-- .../src/account/access/rbac.rs | 99 ++++++------------- .../miden-testing/tests/scripts/authority.rs | 2 +- .../miden-testing/tests/scripts/pausable.rs | 2 +- crates/miden-testing/tests/scripts/rbac.rs | 15 +-- 8 files changed, 49 insertions(+), 115 deletions(-) diff --git a/crates/miden-agglayer/build.rs b/crates/miden-agglayer/build.rs index 32af37e91e..96398ab247 100644 --- a/crates/miden-agglayer/build.rs +++ b/crates/miden-agglayer/build.rs @@ -344,7 +344,7 @@ fn generate_agglayer_constants( components.extend(AccessControl::Rbac { owner: dummy_owner, roles: BTreeMap::new(), - members: Vec::new(), + members: BTreeMap::new(), }); } else if lib_name == "faucet" { components.push(AccountComponent::from( diff --git a/crates/miden-agglayer/src/bridge.rs b/crates/miden-agglayer/src/bridge.rs index 7fa29fd589..fb31e6c9f9 100644 --- a/crates/miden-agglayer/src/bridge.rs +++ b/crates/miden-agglayer/src/bridge.rs @@ -18,7 +18,6 @@ use miden_protocol::account::{ }; use miden_protocol::crypto::hash::poseidon2::Poseidon2; use miden_protocol::note::NoteScriptRoot; -use miden_standards::account::access::RoleAssignment; use miden_utils_sync::LazyLock; use thiserror::Error; @@ -300,32 +299,16 @@ impl AggLayerBridge { ]) } - /// Groups the given role members into [`RoleAssignment`]s for seeding the account's RBAC - /// component (one assignment per role, in faucet-admin / GER-injector / GER-remover order). + /// Groups the given role members by role symbol for seeding the account's RBAC component. /// Roles with no provided members are omitted. - pub fn rbac_role_assignments(members: &[BridgeRoleMember]) -> Vec { - let mut faucet_admins = Vec::new(); - let mut ger_injectors = Vec::new(); - let mut ger_removers = Vec::new(); + pub fn rbac_role_members( + members: &[BridgeRoleMember], + ) -> BTreeMap> { + let mut roles: BTreeMap> = BTreeMap::new(); for member in members { - match member { - BridgeRoleMember::FaucetAdmin(id) => faucet_admins.push(*id), - BridgeRoleMember::GerInjector(id) => ger_injectors.push(*id), - BridgeRoleMember::GerRemover(id) => ger_removers.push(*id), - } + roles.entry(member.role_symbol()).or_default().insert(member.account_id()); } - - let mut assignments = Vec::new(); - if !faucet_admins.is_empty() { - assignments.push(RoleAssignment::new(Self::faucet_admin_role(), faucet_admins)); - } - if !ger_injectors.is_empty() { - assignments.push(RoleAssignment::new(Self::ger_injector_role(), ger_injectors)); - } - if !ger_removers.is_empty() { - assignments.push(RoleAssignment::new(Self::ger_remover_role(), ger_removers)); - } - assignments + roles } // PUBLIC ACCESSORS diff --git a/crates/miden-agglayer/src/lib.rs b/crates/miden-agglayer/src/lib.rs index 060773b9ec..536ba91804 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -150,7 +150,7 @@ fn create_bridge_account_builder( .with_components(AccessControl::Rbac { owner, roles: AggLayerBridge::procedure_roles(), - members: AggLayerBridge::rbac_role_assignments(&role_members), + members: AggLayerBridge::rbac_role_members(&role_members), }) .with_auth_component( AuthNetworkAccount::with_allowed_notes(AggLayerBridge::allowed_notes()) diff --git a/crates/miden-standards/src/account/access/mod.rs b/crates/miden-standards/src/account/access/mod.rs index 9898fe8241..1a7646bbbc 100644 --- a/crates/miden-standards/src/account/access/mod.rs +++ b/crates/miden-standards/src/account/access/mod.rs @@ -1,6 +1,5 @@ -use alloc::collections::BTreeMap; +use alloc::collections::{BTreeMap, BTreeSet}; use alloc::vec; -use alloc::vec::Vec; use miden_protocol::account::{AccountComponent, AccountId, AccountProcedureRoot, RoleSymbol}; @@ -22,7 +21,7 @@ pub mod rbac; /// - [`AccessControl::Rbac`] → [`Ownable2Step`] + [`RoleBasedAccessControl`] + /// [`Authority::RbacControlled`]. The `roles` map assigns a role to individual gated procedures /// (keyed by procedure root); procedures without a mapping fall back to the `owner` check. The -/// `members` list seeds initial role holders at account creation. +/// `members` map seeds initial role holders at account creation. /// /// Pass to /// [`AccountBuilder::with_components`][miden_protocol::account::AccountBuilder::with_components] @@ -38,7 +37,7 @@ pub mod rbac; /// AccountBuilder::new(init_seed).with_components(AccessControl::Rbac { /// owner, /// roles: BTreeMap::new(), -/// members: Vec::new(), +/// members: BTreeMap::new(), /// }); /// ``` /// @@ -62,7 +61,7 @@ pub enum AccessControl { Rbac { owner: AccountId, roles: BTreeMap, - members: Vec, + members: BTreeMap>, }, } @@ -93,4 +92,4 @@ impl IntoIterator for AccessControl { pub use authority::{Authority, AuthorityError}; pub use ownable2step::{Ownable2Step, Ownable2StepError}; pub use pausable::{Pausable, PausableManager, PausableStorage}; -pub use rbac::{RbacError, RoleAssignment, RoleBasedAccessControl}; +pub use rbac::{RbacError, RoleBasedAccessControl}; diff --git a/crates/miden-standards/src/account/access/rbac.rs b/crates/miden-standards/src/account/access/rbac.rs index 70edf337eb..eb2408efd4 100644 --- a/crates/miden-standards/src/account/access/rbac.rs +++ b/crates/miden-standards/src/account/access/rbac.rs @@ -1,4 +1,4 @@ -use alloc::collections::BTreeSet; +use alloc::collections::{BTreeMap, BTreeSet}; use alloc::vec; use alloc::vec::Vec; @@ -114,36 +114,15 @@ static ROLE_MEMBERSHIP_SLOT_NAME: LazyLock = LazyLock::new(|| { /// [`RoleSymbol`]: miden_protocol::account::RoleSymbol #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct RoleBasedAccessControl { - /// Initial role assignments seeded at account creation. Empty for a runtime-populated - /// component (see [`RoleBasedAccessControl::empty`]). - roles: Vec, -} - -/// A single initial role assignment used to seed a [`RoleBasedAccessControl`] component at -/// account creation. -/// -/// Seeding a role with these members is equivalent to the owner calling `grant_role` for each -/// member at runtime: it sets each member's membership flag and the role's member count. Seeded -/// roles are owner-managed (no delegated admin); delegated admins can be configured later via the -/// `set_role_admin` procedure. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RoleAssignment { - /// The role to grant. - pub role: RoleSymbol, - /// The accounts that initially hold the role. Must be non-empty and free of duplicates. - pub members: Vec, -} - -impl RoleAssignment { - /// Creates a role assignment granting `role` to the given `members`. - pub fn new(role: RoleSymbol, members: Vec) -> Self { - Self { role, members } - } - - /// Creates a role assignment granting `role` to a single `member`. - pub fn single(role: RoleSymbol, member: AccountId) -> Self { - Self { role, members: vec![member] } - } + /// Initial role members seeded at account creation, keyed by role symbol. Empty for a + /// runtime-populated component (see [`RoleBasedAccessControl::empty`]). + /// + /// Seeding a role with a set of members is equivalent to the owner calling `grant_role` for + /// each member at runtime: it sets each member's membership flag and the role's member count. + /// Modeling this as a map of sets makes duplicate roles and duplicate members impossible by + /// construction. Seeded roles are owner-managed (no delegated admin); delegated admins can be + /// configured later via the `set_role_admin` procedure. + roles: BTreeMap>, } impl RoleBasedAccessControl { @@ -165,42 +144,24 @@ impl RoleBasedAccessControl { Self::default() } - /// Returns an RBAC component seeded with the given initial role assignments. + /// Returns an RBAC component seeded with the given initial role members. /// - /// Each [`RoleAssignment`] grants its role to one or more accounts at account creation, - /// exactly as if the owner had called `grant_role` for each member at runtime. All seeded - /// roles are owner-managed; delegated admins can be configured later via `set_role_admin`. + /// Each entry grants a role to a set of accounts at account creation, exactly as if the owner + /// had called `grant_role` for each member at runtime. Because roles and members are modeled as + /// a map of sets, duplicate roles and duplicate members are impossible by construction. All + /// seeded roles are owner-managed; delegated admins can be configured later via + /// `set_role_admin`. /// /// # Errors /// - /// Returns an error if a role is assigned more than once, an assignment has no members, the - /// same account is listed twice for a role, or a role has more members than `u32::MAX`. - pub fn with_roles(roles: Vec) -> Result { - let mut seen_roles = BTreeSet::new(); - for assignment in &roles { - let role_key = Felt::from(&assignment.role).as_canonical_u64(); - if !seen_roles.insert(role_key) { - return Err(RbacError::DuplicateRole(assignment.role.clone())); - } - if assignment.members.is_empty() { - return Err(RbacError::EmptyRole(assignment.role.clone())); - } - if u32::try_from(assignment.members.len()).is_err() { - return Err(RbacError::TooManyMembers(assignment.role.clone())); - } - let mut seen_members = BTreeSet::new(); - for member in &assignment.members { - let member_key = ( - member.suffix().as_canonical_u64(), - member.prefix().as_felt().as_canonical_u64(), - ); - if !seen_members.insert(member_key) { - return Err(RbacError::DuplicateMember { - role: assignment.role.clone(), - account: *member, - }); - } + /// Returns an error if a role maps to an empty member set, or a role has more members than + /// `u32::MAX`. + pub fn with_roles(roles: BTreeMap>) -> Result { + for (role, members) in &roles { + if members.is_empty() { + return Err(RbacError::EmptyRole(role.clone())); } + u32::try_from(members.len()).map_err(|_| RbacError::TooManyMembers(role.clone()))?; } Ok(Self { roles }) @@ -256,16 +217,16 @@ impl RoleBasedAccessControl { impl From for AccountComponent { fn from(rbac: RoleBasedAccessControl) -> Self { - // Build the per-role config and membership map entries from the seeded assignments. The + // Build the per-role config and membership map entries from the seeded roles. The // key/value layout mirrors what the RBAC MASM writes via `grant_role`: // - role_config: [0, 0, 0, role] -> [member_count, admin_role_symbol(=0), 0, 0] // - role_membership: [0, role, account_suffix, account_prefix] -> [1, 0, 0, 0] let mut config_entries = Vec::new(); let mut membership_entries = Vec::new(); - for assignment in &rbac.roles { - let role_felt = Felt::from(&assignment.role); - let member_count = u32::try_from(assignment.members.len()) + for (role, members) in &rbac.roles { + let role_felt = Felt::from(role); + let member_count = u32::try_from(members.len()) .expect("member count fits into u32 (validated in with_roles)"); config_entries.push(( @@ -278,7 +239,7 @@ impl From for AccountComponent { Word::from([Felt::from(member_count), Felt::ZERO, Felt::ZERO, Felt::ZERO]), )); - for member in &assignment.members { + for member in members { membership_entries.push(( StorageMapKey::from_raw(Word::from([ Felt::ZERO, @@ -316,12 +277,8 @@ impl From for AccountComponent { /// Errors that can occur when seeding a [`RoleBasedAccessControl`] component with initial roles. #[derive(Debug, thiserror::Error)] pub enum RbacError { - #[error("role {0} is assigned more than once in the initial role assignments")] - DuplicateRole(RoleSymbol), #[error("role {0} has no initial members")] EmptyRole(RoleSymbol), - #[error("account {account} is listed more than once for role {role}")] - DuplicateMember { role: RoleSymbol, account: AccountId }, #[error("role {0} has more members than the u32 maximum")] TooManyMembers(RoleSymbol), } diff --git a/crates/miden-testing/tests/scripts/authority.rs b/crates/miden-testing/tests/scripts/authority.rs index e4a4e4346f..0e3d4f6190 100644 --- a/crates/miden-testing/tests/scripts/authority.rs +++ b/crates/miden-testing/tests/scripts/authority.rs @@ -82,7 +82,7 @@ fn add_rbac_faucet( let account_builder = AccountBuilder::new([seed; 32]) .account_type(AccountType::Public) .with_component(faucet) - .with_components(AccessControl::Rbac { owner, roles, members: Vec::new() }) + .with_components(AccessControl::Rbac { owner, roles, members: BTreeMap::new() }) .with_component(Pausable::unpaused()) .with_component(PausableManager); diff --git a/crates/miden-testing/tests/scripts/pausable.rs b/crates/miden-testing/tests/scripts/pausable.rs index a5e6fa80f0..cefc67fa84 100644 --- a/crates/miden-testing/tests/scripts/pausable.rs +++ b/crates/miden-testing/tests/scripts/pausable.rs @@ -305,7 +305,7 @@ fn add_rbac_faucet_with_pause( let account_builder = AccountBuilder::new([seed; 32]) .account_type(AccountType::Public) .with_component(faucet) - .with_components(AccessControl::Rbac { owner, roles, members: Vec::new() }) + .with_components(AccessControl::Rbac { owner, roles, members: BTreeMap::new() }) .with_component(Pausable::unpaused()) .with_component(PausableManager); diff --git a/crates/miden-testing/tests/scripts/rbac.rs b/crates/miden-testing/tests/scripts/rbac.rs index 7ede479c3b..bbcbd54a24 100644 --- a/crates/miden-testing/tests/scripts/rbac.rs +++ b/crates/miden-testing/tests/scripts/rbac.rs @@ -1,6 +1,6 @@ extern crate alloc; -use alloc::collections::BTreeMap; +use alloc::collections::{BTreeMap, BTreeSet}; use alloc::string::String; use core::slice; @@ -17,12 +17,7 @@ use miden_protocol::account::{ use miden_protocol::errors::AccountIdError; use miden_protocol::note::{Note, NoteType}; use miden_protocol::{Felt, Word}; -use miden_standards::account::access::{ - AccessControl, - Ownable2Step, - RoleAssignment, - RoleBasedAccessControl, -}; +use miden_standards::account::access::{AccessControl, Ownable2Step, RoleBasedAccessControl}; use miden_standards::errors::standards::{ ERR_ACCOUNT_NOT_IN_ROLE, ERR_ROLE_SYMBOL_ZERO, @@ -42,7 +37,7 @@ fn create_rbac_account_with_owner(owner: AccountId) -> anyhow::Result { .with_components(AccessControl::Rbac { owner, roles: BTreeMap::new(), - members: Vec::new(), + members: BTreeMap::new(), }) .build_existing()?; @@ -51,7 +46,7 @@ fn create_rbac_account_with_owner(owner: AccountId) -> anyhow::Result { fn create_rbac_account_with_members( owner: AccountId, - members: Vec, + members: BTreeMap>, ) -> anyhow::Result { let account = AccountBuilder::new([9; 32]) .account_type(AccountType::Public) @@ -371,7 +366,7 @@ async fn test_rbac_with_roles_matches_runtime_grants() -> anyhow::Result<()> { // Account seeded with two MINTER members at construction. let seeded = create_rbac_account_with_members( owner, - vec![RoleAssignment::new(minter.clone(), vec![alice, bob])], + BTreeMap::from([(minter.clone(), BTreeSet::from([alice, bob]))]), )?; // Account built empty, then granted the same two members at runtime. From 3ba3992f758b8ed2079d13f3458dfcedf1b733fa Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Wed, 1 Jul 2026 22:00:41 +0300 Subject: [PATCH 05/16] fix: address PR review feedback - bridge: resolve procedure roots via the procedure_root! macro + a component code() getter, matching the PausableManager pattern (removes the ad-hoc bridge_procedure_root helper). - bridge: BridgeRoleMember variants now carry a Vec so all holders of a role are grouped in one entry; rbac_role_members collects via entry()+extend. - docs: link role names to their BridgeRoleMember variants; tighten the bridge component and builder doc comments; bullet-list the with_roles Errors section. - masm: drop the redundant storage-slots comment. - SPEC: switch stored-ID wording to role-membership wording and keep a single authority::assert_authorized explanation (in Administration) instead of repeating it in every note/procedure section. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/bench-transaction/src/context_setups.rs | 6 +- crates/miden-agglayer/SPEC.md | 29 +++-- .../asm/agglayer/bridge/bridge_config.masm | 7 +- crates/miden-agglayer/src/bridge.rs | 101 ++++++++++-------- crates/miden-agglayer/src/lib.rs | 5 +- .../src/account/access/rbac.rs | 5 +- .../tests/agglayer/test_utils.rs | 6 +- 7 files changed, 84 insertions(+), 75 deletions(-) diff --git a/bin/bench-transaction/src/context_setups.rs b/bin/bench-transaction/src/context_setups.rs index 1becf82e09..0cec4a8dde 100644 --- a/bin/bench-transaction/src/context_setups.rs +++ b/bin/bench-transaction/src/context_setups.rs @@ -44,9 +44,9 @@ fn bench_bridge_account( seed, owner, vec![ - BridgeRoleMember::FaucetAdmin(faucet_admin), - BridgeRoleMember::GerInjector(ger_injector), - BridgeRoleMember::GerRemover(ger_remover), + BridgeRoleMember::FaucetAdmin(vec![faucet_admin]), + BridgeRoleMember::GerInjector(vec![ger_injector]), + BridgeRoleMember::GerRemover(vec![ger_remover]), ], ) } diff --git a/crates/miden-agglayer/SPEC.md b/crates/miden-agglayer/SPEC.md index fed5f62d38..6ee600d9cd 100644 --- a/crates/miden-agglayer/SPEC.md +++ b/crates/miden-agglayer/SPEC.md @@ -110,7 +110,7 @@ Global Exit Roots represent a snapshot of exit tree roots across all AggLayer-co chains. A `GER_INJECTOR` role holder observes L1 GER updates and creates [`UPDATE_GER`](#44-update_ger) notes on Miden. The bridge consumes these notes: -1. Asserts (via `authority::assert_authorized`) that the note sender holds the `GER_INJECTOR` role. +1. Asserts that the note sender holds the `GER_INJECTOR` role. 2. Computes `KEY = poseidon2::merge(GER_LOWER, GER_UPPER)`. 3. Stores `KEY -> [1, 0, 0, 0]` in the `ger_map`, marking the GER as known. 4. Reverts if the GER was already present in the map (duplicate insertions are rejected). @@ -128,7 +128,7 @@ to be valid. A separate `GER_REMOVER` role can revoke a previously-registered GER by sending a [`REMOVE_GER`](#45-remove_ger) note. The bridge consumes such a note and: -1. Asserts (via `authority::assert_authorized`) that the note sender holds the `GER_REMOVER` +1. Asserts that the note sender holds the `GER_REMOVER` role (a role distinct from the `GER_INJECTOR` role so that insertion and revocation authority can be split). 2. Computes `KEY = poseidon2::merge(GER_LOWER, GER_UPPER)`. @@ -174,8 +174,7 @@ TODO: No hash chain tracks GER insertions for proof generation Each bridged token (wrapped or Miden-native) requires registration in the bridge's registries. The Bridge Operator creates [`CONFIG_AGG_BRIDGE`](#43-config_agg_bridge) notes carrying the faucet's account ID, the origin token address, the origin network, the scale -factor, the metadata hash, and an `is_native` flag. The bridge consumes the note (asserting, -via `authority::assert_authorized`, that the sender holds the `FAUCET_ADMIN` role) and runs +factor, the metadata hash, and an `is_native` flag. The bridge consumes the note (asserting that the sender holds the `FAUCET_ADMIN` role) and runs two calls back-to-back: - `bridge_config::register_faucet` writes the registration flag plus `is_native` into @@ -272,7 +271,7 @@ Bridges an asset out of Miden into the AggLayer: | **Context** | Consuming a `CONFIG_AGG_BRIDGE` note on the bridge account | | **Panics** | Note sender does not hold the `FAUCET_ADMIN` role | -Asserts (via `authority::assert_authorized`) that the note sender holds the `FAUCET_ADMIN` +Asserts that the note sender holds the `FAUCET_ADMIN` role, then performs a two-step registration: 1. Writes `[0, 0, faucet_id_suffix, faucet_id_prefix] -> [1, 0, 0, 0]` into the @@ -295,7 +294,7 @@ role, then performs a two-step registration: | **Context** | Consuming an `UPDATE_GER` note on the bridge account | | **Panics** | Note sender does not hold the `GER_INJECTOR` role; GER has already been registered in storage | -Asserts (via `authority::assert_authorized`) that the note sender holds the `GER_INJECTOR` +Asserts that the note sender holds the `GER_INJECTOR` role, then computes `KEY = poseidon2::merge(GER_LOWER, GER_UPPER)` and stores `KEY -> [1, 0, 0, 0]` in the `ger_map` map slot. This marks the GER as "known". @@ -618,7 +617,7 @@ The storage is divided into three logical regions: proof data (felts 0-535), lea | Field | Value | |-------|-------| -| `sender` | Holder of the `FAUCET_ADMIN` role (sender authorization enforced by the bridge's `register_faucet` procedure via `authority::assert_authorized`) | +| `sender` | Holder of the `FAUCET_ADMIN` role (sender authorization enforced by the bridge's `register_faucet` procedure) | | `note_type` | `NoteType::Public` | | `tag` | `NoteTag::default()` | | `attachment` | `NetworkAccountTarget` -- target is the bridge account; execution hint: Always | @@ -645,7 +644,7 @@ The storage is divided into three logical regions: proof data (felts 0-535), lea | 7 | `faucet_id_prefix` | Felt (AccountId prefix) | **Consumption:** Script validates attachment target, loads storage, and calls -`bridge_config::register_faucet` (which asserts, via `authority::assert_authorized`, that the +`bridge_config::register_faucet` (which asserts that the sender holds the `FAUCET_ADMIN` role and performs two-step registration into `faucet_registry_map` and `token_registry_map`). @@ -653,7 +652,7 @@ sender holds the `FAUCET_ADMIN` role and performs two-step registration into | Role | Enforcement | |------|------------| -| **Issuer** | Holders of the `FAUCET_ADMIN` role only -- **enforced** by `bridge_config::register_faucet` via `authority::assert_authorized` | +| **Issuer** | Holders of the `FAUCET_ADMIN` role only -- **enforced** by `bridge_config::register_faucet` | | **Consumer** | Bridge account -- **enforced** via `NetworkAccountTarget` attachment | ### 4.4 UPDATE_GER @@ -667,7 +666,7 @@ CLAIM notes can be verified against it. | Field | Value | |-------|-------| -| `sender` | Holder of the `GER_INJECTOR` role (sender authorization enforced by the bridge's `update_ger` procedure via `authority::assert_authorized`) | +| `sender` | Holder of the `GER_INJECTOR` role (sender authorization enforced by the bridge's `update_ger` procedure) | | `note_type` | `NoteType::Public` | | `tag` | `NoteTag::default()` | | `attachment` | `NetworkAccountTarget` -- target is the bridge account; execution hint: Always | @@ -692,7 +691,7 @@ CLAIM notes can be verified against it. | 4-7 | `GER_UPPER` | Last 16 bytes as 4 x u32 felts | **Consumption:** Script validates attachment target, loads storage, and calls -`bridge_config::update_ger` (which asserts, via `authority::assert_authorized`, that the +`bridge_config::update_ger` (which asserts that the sender holds the `GER_INJECTOR` role), which computes `poseidon2::merge(GER_LOWER, GER_UPPER)` and stores the result in the GER map. @@ -700,7 +699,7 @@ sender holds the `GER_INJECTOR` role), which computes | Role | Enforcement | |------|------------| -| **Issuer** | Holders of the `GER_INJECTOR` role only -- **enforced** by `bridge_config::update_ger` via `authority::assert_authorized` | +| **Issuer** | Holders of the `GER_INJECTOR` role only -- **enforced** by `bridge_config::update_ger` | | **Consumer** | Bridge account -- **enforced** via `NetworkAccountTarget` attachment | ### 4.5 REMOVE_GER @@ -715,7 +714,7 @@ removed-GER keccak256 hash chain. | Field | Value | |-------|-------| -| `sender` | Holder of the `GER_REMOVER` role (sender authorization enforced by the bridge's `remove_ger` procedure via `authority::assert_authorized`) | +| `sender` | Holder of the `GER_REMOVER` role (sender authorization enforced by the bridge's `remove_ger` procedure) | | `note_type` | `NoteType::Public` | | `tag` | `NoteTag::default()` | | `attachment` | `NetworkAccountTarget` -- target is the bridge account; execution hint: Always | @@ -740,7 +739,7 @@ removed-GER keccak256 hash chain. | 4-7 | `GER_UPPER` | Last 16 bytes as 4 x u32 felts | **Consumption:** Script validates attachment target, loads storage, and calls -`bridge_config::remove_ger` (which asserts, via `authority::assert_authorized`, that the +`bridge_config::remove_ger` (which asserts that the sender holds the `GER_REMOVER` role), which computes `poseidon2::merge(GER_LOWER, GER_UPPER)`, asserts the GER map entry equals `[1, 0, 0, 0]` while overwriting it with `[0, 0, 0, 0]`, and updates the removed-GER hash chain as @@ -750,7 +749,7 @@ while overwriting it with `[0, 0, 0, 0]`, and updates the removed-GER hash chain | Role | Enforcement | |------|------------| -| **Issuer** | Holders of the `GER_REMOVER` role only -- **enforced** by `bridge_config::remove_ger` via `authority::assert_authorized` | +| **Issuer** | Holders of the `GER_REMOVER` role only -- **enforced** by `bridge_config::remove_ger` | | **Consumer** | Bridge account -- **enforced** via `NetworkAccountTarget` attachment | ### 4.6 BURN (generated) diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm index 748cef8094..36f72ff889 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm @@ -18,12 +18,7 @@ const ERR_TOKEN_NOT_REGISTERED = "(origin token address, origin network) pair is # CONSTANTS # ================================================================================================= -# Storage slots. -# -# The privileged roles (faucet admin, GER injector, GER remover) are no longer stored as plain -# account IDs here. Authorization for the role-gated procedures below is delegated to the account's -# `Authority` component (RBAC-controlled), which maps each procedure root to the role required to -# invoke it. See `authority::assert_authorized`. +# Storage slots const GER_MAP_STORAGE_SLOT = word("agglayer::bridge::ger_map") const FAUCET_REGISTRY_MAP_SLOT = word("agglayer::bridge::faucet_registry_map") const TOKEN_REGISTRY_MAP_SLOT = word("agglayer::bridge::token_registry_map") diff --git a/crates/miden-agglayer/src/bridge.rs b/crates/miden-agglayer/src/bridge.rs index fb31e6c9f9..283b63ebc4 100644 --- a/crates/miden-agglayer/src/bridge.rs +++ b/crates/miden-agglayer/src/bridge.rs @@ -18,6 +18,7 @@ use miden_protocol::account::{ }; use miden_protocol::crypto::hash::poseidon2::Poseidon2; use miden_protocol::note::NoteScriptRoot; +use miden_standards::procedure_root; use miden_utils_sync::LazyLock; use thiserror::Error; @@ -136,33 +137,58 @@ static GER_REMOVER_ROLE: LazyLock = LazyLock::new(|| { RoleSymbol::new("GER_REMOVER").expect("GER_REMOVER role symbol should be valid") }); -static REGISTER_FAUCET_ROOT: LazyLock = - LazyLock::new(|| bridge_procedure_root("register_faucet")); -static STORE_FAUCET_METADATA_HASH_ROOT: LazyLock = - LazyLock::new(|| bridge_procedure_root("store_faucet_metadata_hash")); -static UPDATE_GER_ROOT: LazyLock = - LazyLock::new(|| bridge_procedure_root("update_ger")); -static REMOVE_GER_ROOT: LazyLock = - LazyLock::new(|| bridge_procedure_root("remove_ger")); +/// The assembled bridge account component code, used to resolve the roots of the bridge's +/// role-gated procedures. +static BRIDGE_COMPONENT_CODE: LazyLock = + LazyLock::new(|| AccountComponentCode::from(agglayer_bridge_component_library())); -/// A privileged AggLayer bridge role together with the account that initially holds it. +fn bridge_component_code() -> &'static AccountComponentCode { + &BRIDGE_COMPONENT_CODE +} + +procedure_root!( + REGISTER_FAUCET_ROOT, + BRIDGE_COMPONENT_NAMESPACE, + "register_faucet", + bridge_component_code() +); +procedure_root!( + STORE_FAUCET_METADATA_HASH_ROOT, + BRIDGE_COMPONENT_NAMESPACE, + "store_faucet_metadata_hash", + bridge_component_code() +); +procedure_root!( + UPDATE_GER_ROOT, + BRIDGE_COMPONENT_NAMESPACE, + "update_ger", + bridge_component_code() +); +procedure_root!( + REMOVE_GER_ROOT, + BRIDGE_COMPONENT_NAMESPACE, + "remove_ger", + bridge_component_code() +); + +/// A privileged AggLayer bridge role together with the accounts that initially hold it. /// /// Used to seed the bridge account's RBAC role membership at creation. Each variant maps to a /// distinct role symbol and gates a specific set of bridge procedures: -/// - [`BridgeRoleMember::FaucetAdmin`] (`FAUCET_ADMIN`) gates `register_faucet` and +/// - [`FAUCET_ADMIN`][BridgeRoleMember::FaucetAdmin] gates `register_faucet` and /// `store_faucet_metadata_hash`. -/// - [`BridgeRoleMember::GerInjector`] (`GER_INJECTOR`) gates `update_ger`. -/// - [`BridgeRoleMember::GerRemover`] (`GER_REMOVER`) gates `remove_ger`. +/// - [`GER_INJECTOR`][BridgeRoleMember::GerInjector] gates `update_ger`. +/// - [`GER_REMOVER`][BridgeRoleMember::GerRemover] gates `remove_ger`. /// -/// Pass several entries with the same variant to seed multiple holders of a role. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Each variant carries all accounts that initially hold the role. +#[derive(Debug, Clone, PartialEq, Eq)] pub enum BridgeRoleMember { - /// Holder of the `FAUCET_ADMIN` role. - FaucetAdmin(AccountId), - /// Holder of the `GER_INJECTOR` role. - GerInjector(AccountId), - /// Holder of the `GER_REMOVER` role. - GerRemover(AccountId), + /// Holders of the `FAUCET_ADMIN` role. + FaucetAdmin(Vec), + /// Holders of the `GER_INJECTOR` role. + GerInjector(Vec), + /// Holders of the `GER_REMOVER` role. + GerRemover(Vec), } impl BridgeRoleMember { @@ -175,12 +201,12 @@ impl BridgeRoleMember { } } - /// Returns the account ID that holds the role. - pub fn account_id(&self) -> AccountId { + /// Returns the accounts that hold the role. + pub fn account_ids(&self) -> &[AccountId] { match self { - BridgeRoleMember::FaucetAdmin(id) - | BridgeRoleMember::GerInjector(id) - | BridgeRoleMember::GerRemover(id) => *id, + BridgeRoleMember::FaucetAdmin(ids) + | BridgeRoleMember::GerInjector(ids) + | BridgeRoleMember::GerRemover(ids) => ids, } } } @@ -202,10 +228,9 @@ impl BridgeRoleMember { /// /// The bridge's privileged roles are managed by the account's RBAC stack (`Ownable2Step` + /// `RoleBasedAccessControl` + `Authority`), installed alongside this component at account -/// creation, rather than stored as plain account IDs in the bridge component. The role-gated -/// procedures (`register_faucet`, `store_faucet_metadata_hash`, `update_ger`, `remove_ger`) call -/// `authority::assert_authorized`, which requires the note sender to hold the role mapped to the -/// procedure. See [`BridgeRoleMember`] and [`AggLayerBridge::procedure_roles`]. +/// creation. The role-gated procedures call `authority::assert_authorized`, which requires the note +/// sender to hold the role mapped to the procedure. See [`BridgeRoleMember`] and +/// [`AggLayerBridge::procedure_roles`]. /// /// ## Storage Layout /// @@ -306,7 +331,10 @@ impl AggLayerBridge { ) -> BTreeMap> { let mut roles: BTreeMap> = BTreeMap::new(); for member in members { - roles.entry(member.role_symbol()).or_default().insert(member.account_id()); + roles + .entry(member.role_symbol()) + .or_default() + .extend(member.account_ids().iter().copied()); } roles } @@ -672,19 +700,6 @@ pub enum AgglayerBridgeError { // HELPER FUNCTIONS // ================================================================================================ -/// Resolves the [`AccountProcedureRoot`] of a procedure exported by the bridge account component -/// library, by its name (e.g. `update_ger`). -/// -/// # Panics -/// -/// Panics if the bridge component library does not export a procedure with the given name. -fn bridge_procedure_root(proc_name: &str) -> AccountProcedureRoot { - let code = AccountComponentCode::from(agglayer_bridge_component_library()); - let path = alloc::format!("{BRIDGE_COMPONENT_NAMESPACE}::{proc_name}"); - code.get_procedure_root_by_path(path.as_str()) - .unwrap_or_else(|| panic!("bridge component library should export `{path}`")) -} - /// Creates an AggLayer Bridge component with the specified storage slots. fn bridge_component(storage_slots: Vec) -> AccountComponent { let library = agglayer_bridge_component_library(); diff --git a/crates/miden-agglayer/src/lib.rs b/crates/miden-agglayer/src/lib.rs index 536ba91804..eda3d7c85f 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -133,9 +133,8 @@ fn create_agglayer_faucet_component( /// via CONFIG_AGG_BRIDGE notes that call `bridge_config::register_faucet`. /// /// Access control is provided by the RBAC stack: `owner` becomes the account's `Ownable2Step` -/// governance owner (able to grant/revoke roles and transfer ownership), and `role_members` seeds -/// the initial holders of the `FAUCET_ADMIN`, `GER_INJECTOR`, and `GER_REMOVER` roles that gate -/// the bridge's privileged procedures. +/// governance owner, and `role_members` seeds the initial holders of the `FAUCET_ADMIN`, +/// `GER_INJECTOR`, and `GER_REMOVER` roles that gate the bridge's privileged procedures. /// /// The builder is pre-wired with the [`AuthNetworkAccount`] auth component, initialized with /// [`AggLayerBridge::allowed_notes()`] so the bridge only accepts its sanctioned input notes. diff --git a/crates/miden-standards/src/account/access/rbac.rs b/crates/miden-standards/src/account/access/rbac.rs index eb2408efd4..801edbe05a 100644 --- a/crates/miden-standards/src/account/access/rbac.rs +++ b/crates/miden-standards/src/account/access/rbac.rs @@ -154,8 +154,9 @@ impl RoleBasedAccessControl { /// /// # Errors /// - /// Returns an error if a role maps to an empty member set, or a role has more members than - /// `u32::MAX`. + /// Returns an error if: + /// - a role maps to an empty member set. + /// - a role has more members than `u32::MAX`. pub fn with_roles(roles: BTreeMap>) -> Result { for (role, members) in &roles { if members.is_empty() { diff --git a/crates/miden-testing/tests/agglayer/test_utils.rs b/crates/miden-testing/tests/agglayer/test_utils.rs index 046d73eeab..1176be881b 100644 --- a/crates/miden-testing/tests/agglayer/test_utils.rs +++ b/crates/miden-testing/tests/agglayer/test_utils.rs @@ -70,9 +70,9 @@ pub fn create_existing_bridge_account_with_roles( seed, bridge_test_owner(), vec![ - BridgeRoleMember::FaucetAdmin(faucet_admin), - BridgeRoleMember::GerInjector(ger_injector), - BridgeRoleMember::GerRemover(ger_remover), + BridgeRoleMember::FaucetAdmin(vec![faucet_admin]), + BridgeRoleMember::GerInjector(vec![ger_injector]), + BridgeRoleMember::GerRemover(vec![ger_remover]), ], ) } From f69a5e1ec98e743e5ebe188cb11f5e5006a617f7 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Wed, 1 Jul 2026 22:35:54 +0300 Subject: [PATCH 06/16] fix: address PR review feedback (round 2) - bridge: move the component namespace and the component-code getter into AggLayerBridge (AggLayerBridge::COMPONENT_NAMESPACE and AggLayerBridge::code()), matching the PausableManager shape. - rbac: trim the seeded roles field doc to the essential summary. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/miden-agglayer/src/bridge.rs | 34 ++++++++++--------- .../src/account/access/rbac.rs | 6 ---- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/crates/miden-agglayer/src/bridge.rs b/crates/miden-agglayer/src/bridge.rs index 283b63ebc4..c55ff3c177 100644 --- a/crates/miden-agglayer/src/bridge.rs +++ b/crates/miden-agglayer/src/bridge.rs @@ -123,10 +123,6 @@ static LET_NUM_LEAVES_SLOT_NAME: LazyLock = LazyLock::new(|| { // BRIDGE RBAC ROLES // ================================================================================================ -// Namespace of the assembled bridge account component library (the `asm/components/bridge.masm` -// wrapper). Procedure roots are resolved as `::`. -const BRIDGE_COMPONENT_NAMESPACE: &str = "bridge"; - static FAUCET_ADMIN_ROLE: LazyLock = LazyLock::new(|| { RoleSymbol::new("FAUCET_ADMIN").expect("FAUCET_ADMIN role symbol should be valid") }); @@ -142,33 +138,29 @@ static GER_REMOVER_ROLE: LazyLock = LazyLock::new(|| { static BRIDGE_COMPONENT_CODE: LazyLock = LazyLock::new(|| AccountComponentCode::from(agglayer_bridge_component_library())); -fn bridge_component_code() -> &'static AccountComponentCode { - &BRIDGE_COMPONENT_CODE -} - procedure_root!( REGISTER_FAUCET_ROOT, - BRIDGE_COMPONENT_NAMESPACE, + AggLayerBridge::COMPONENT_NAMESPACE, "register_faucet", - bridge_component_code() + AggLayerBridge::code() ); procedure_root!( STORE_FAUCET_METADATA_HASH_ROOT, - BRIDGE_COMPONENT_NAMESPACE, + AggLayerBridge::COMPONENT_NAMESPACE, "store_faucet_metadata_hash", - bridge_component_code() + AggLayerBridge::code() ); procedure_root!( UPDATE_GER_ROOT, - BRIDGE_COMPONENT_NAMESPACE, + AggLayerBridge::COMPONENT_NAMESPACE, "update_ger", - bridge_component_code() + AggLayerBridge::code() ); procedure_root!( REMOVE_GER_ROOT, - BRIDGE_COMPONENT_NAMESPACE, + AggLayerBridge::COMPONENT_NAMESPACE, "remove_ger", - bridge_component_code() + AggLayerBridge::code() ); /// A privileged AggLayer bridge role together with the accounts that initially hold it. @@ -273,9 +265,19 @@ impl AggLayerBridge { const REGISTERED_GER_MAP_VALUE: Word = Word::new([ONE, ZERO, ZERO, ZERO]); + /// Namespace of the assembled bridge account component library (the + /// `asm/components/bridge.masm` wrapper). Procedure roots are resolved as + /// `::`. + const COMPONENT_NAMESPACE: &'static str = "bridge"; + // RBAC ROLES // -------------------------------------------------------------------------------------------- + /// Returns the assembled bridge account component code. + pub fn code() -> &'static AccountComponentCode { + &BRIDGE_COMPONENT_CODE + } + /// Returns the `FAUCET_ADMIN` role symbol. Holders may register faucets and store faucet /// metadata (`register_faucet`, `store_faucet_metadata_hash`). pub fn faucet_admin_role() -> RoleSymbol { diff --git a/crates/miden-standards/src/account/access/rbac.rs b/crates/miden-standards/src/account/access/rbac.rs index 801edbe05a..cf5f2c276a 100644 --- a/crates/miden-standards/src/account/access/rbac.rs +++ b/crates/miden-standards/src/account/access/rbac.rs @@ -116,12 +116,6 @@ static ROLE_MEMBERSHIP_SLOT_NAME: LazyLock = LazyLock::new(|| { pub struct RoleBasedAccessControl { /// Initial role members seeded at account creation, keyed by role symbol. Empty for a /// runtime-populated component (see [`RoleBasedAccessControl::empty`]). - /// - /// Seeding a role with a set of members is equivalent to the owner calling `grant_role` for - /// each member at runtime: it sets each member's membership flag and the role's member count. - /// Modeling this as a map of sets makes duplicate roles and duplicate members impossible by - /// construction. Seeded roles are owner-managed (no delegated admin); delegated admins can be - /// configured later via the `set_role_admin` procedure. roles: BTreeMap>, } From d015264f19b1af7d8742e3b81e31584e7759df34 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Mon, 6 Jul 2026 21:03:34 +0300 Subject: [PATCH 07/16] fix: address PR review feedback (round 3) - bridge: replace the BridgeRoleMember enum with a BridgeRoles struct (BTreeSet per role) and a fallible BridgeRoles::new that rejects an empty holder set for any role (new AgglayerBridgeError::EmptyBridgeRole), so a bridge can't be deployed with an unfillable role. create_bridge_account takes BridgeRoles. - standards: make RoleBasedAccessControl::with_roles infallible (drop empty member sets instead of erroring/panicking; remove RbacError and the IntoIterator expect). Add reusable role_config_key / role_membership_key helpers and use them in the From impl instead of building raw words. - testing: move the shared create_existing_bridge_account_with_roles / bridge_test_owner helper into miden_agglayer::testing so the test crate and bench share one copy. - tests: add ERR_SENDER_LACKS_ROLE tests for update_ger (GER_INJECTOR) and register_faucet via CONFIG (FAUCET_ADMIN), and a BridgeRoles::new empty-role test. - SPEC: point the role-management follow-up TODO at #3046; fix a doc link. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/bench-transaction/src/context_setups.rs | 51 ++-------- crates/miden-agglayer/SPEC.md | 6 +- crates/miden-agglayer/src/bridge.rs | 95 +++++++++--------- crates/miden-agglayer/src/lib.rs | 30 ++---- crates/miden-agglayer/src/testing/mod.rs | 52 +++++++++- .../miden-standards/src/account/access/mod.rs | 6 +- .../src/account/access/rbac.rs | 72 ++++++-------- .../tests/agglayer/config_bridge.rs | 97 ++++++++++++++++++- .../tests/agglayer/test_utils.rs | 49 +--------- .../tests/agglayer/update_ger.rs | 46 +++++++++ 10 files changed, 298 insertions(+), 206 deletions(-) diff --git a/bin/bench-transaction/src/context_setups.rs b/bin/bench-transaction/src/context_setups.rs index 65ba1826ba..fc8460ffd4 100644 --- a/bin/bench-transaction/src/context_setups.rs +++ b/bin/bench-transaction/src/context_setups.rs @@ -1,9 +1,9 @@ use anyhow::Result; pub use miden_agglayer::testing::ClaimDataSource; +use miden_agglayer::testing::create_existing_bridge_account_with_roles; use miden_agglayer::{ AggLayerBridge, B2AggNote, - BridgeRoleMember, ClaimNote, ClaimNoteStorage, ConfigAggBridgeNote, @@ -12,17 +12,9 @@ use miden_agglayer::{ MetadataHash, UpdateGerNote, create_existing_agglayer_faucet, - create_existing_bridge_account, }; use miden_protocol::account::auth::AuthScheme; -use miden_protocol::account::{ - Account, - AccountId, - AccountIdVersion, - AccountType, - AssetCallbackFlag, - StorageMapKey, -}; +use miden_protocol::account::{Account, StorageMapKey}; use miden_protocol::asset::{Asset, FungibleAsset}; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::note::{NoteAssets, NoteType}; @@ -34,35 +26,6 @@ use miden_standards::note::StandardNote; use miden_testing::{Auth, MockChain, TransactionContext}; use rand::RngExt; -// BRIDGE ACCOUNT HELPER -// ================================================================================================ - -/// Builds an existing bridge account seeded with the three operational roles (`FAUCET_ADMIN`, -/// `GER_INJECTOR`, `GER_REMOVER`) and a fixed dummy governance owner. Benchmark accounts do not -/// exercise the owner's role-management powers. -fn bench_bridge_account( - seed: Word, - faucet_admin: AccountId, - ger_injector: AccountId, - ger_remover: AccountId, -) -> Account { - let owner = AccountId::dummy( - [0xee; 15], - AccountIdVersion::Version1, - AccountType::Public, - AssetCallbackFlag::Disabled, - ); - create_existing_bridge_account( - seed, - owner, - vec![ - BridgeRoleMember::FaucetAdmin(vec![faucet_admin]), - BridgeRoleMember::GerInjector(vec![ger_injector]), - BridgeRoleMember::GerRemover(vec![ger_remover]), - ], - ) -} - // P2ID NOTE SETUPS // ================================================================================================ @@ -237,8 +200,12 @@ pub async fn tx_consume_claim_note(data_source: ClaimDataSource) -> Result) -> Result), - /// Holders of the `GER_INJECTOR` role. - GerInjector(Vec), - /// Holders of the `GER_REMOVER` role. - GerRemover(Vec), +pub struct BridgeRoles { + faucet_admins: BTreeSet, + ger_injectors: BTreeSet, + ger_removers: BTreeSet, } -impl BridgeRoleMember { - /// Returns the role symbol of this role. - pub fn role_symbol(&self) -> RoleSymbol { - match self { - BridgeRoleMember::FaucetAdmin(_) => AggLayerBridge::faucet_admin_role(), - BridgeRoleMember::GerInjector(_) => AggLayerBridge::ger_injector_role(), - BridgeRoleMember::GerRemover(_) => AggLayerBridge::ger_remover_role(), +impl BridgeRoles { + /// Creates the initial bridge role membership from the holders of each role. + /// + /// # Errors + /// + /// Returns [`AgglayerBridgeError::EmptyBridgeRole`] if any of the three roles is given an empty + /// set of holders. + pub fn new( + faucet_admins: BTreeSet, + ger_injectors: BTreeSet, + ger_removers: BTreeSet, + ) -> Result { + for (role, members) in [ + (AggLayerBridge::faucet_admin_role(), &faucet_admins), + (AggLayerBridge::ger_injector_role(), &ger_injectors), + (AggLayerBridge::ger_remover_role(), &ger_removers), + ] { + if members.is_empty() { + return Err(AgglayerBridgeError::EmptyBridgeRole(role)); + } } + + Ok(Self { + faucet_admins, + ger_injectors, + ger_removers, + }) } - /// Returns the accounts that hold the role. - pub fn account_ids(&self) -> &[AccountId] { - match self { - BridgeRoleMember::FaucetAdmin(ids) - | BridgeRoleMember::GerInjector(ids) - | BridgeRoleMember::GerRemover(ids) => ids, - } + /// Returns the RBAC role-membership map used to seed the account's RBAC component. + pub(crate) fn role_members(&self) -> BTreeMap> { + BTreeMap::from([ + (AggLayerBridge::faucet_admin_role(), self.faucet_admins.clone()), + (AggLayerBridge::ger_injector_role(), self.ger_injectors.clone()), + (AggLayerBridge::ger_remover_role(), self.ger_removers.clone()), + ]) } } @@ -221,7 +239,7 @@ impl BridgeRoleMember { /// The bridge's privileged roles are managed by the account's RBAC stack (`Ownable2Step` + /// `RoleBasedAccessControl` + `Authority`), installed alongside this component at account /// creation. The role-gated procedures call `authority::assert_authorized`, which requires the note -/// sender to hold the role mapped to the procedure. See [`BridgeRoleMember`] and +/// sender to hold the role mapped to the procedure. See [`BridgeRoles`] and /// [`AggLayerBridge::procedure_roles`]. /// /// ## Storage Layout @@ -326,21 +344,6 @@ impl AggLayerBridge { ]) } - /// Groups the given role members by role symbol for seeding the account's RBAC component. - /// Roles with no provided members are omitted. - pub fn rbac_role_members( - members: &[BridgeRoleMember], - ) -> BTreeMap> { - let mut roles: BTreeMap> = BTreeMap::new(); - for member in members { - roles - .entry(member.role_symbol()) - .or_default() - .extend(member.account_ids().iter().copied()); - } - roles - } - // PUBLIC ACCESSORS // -------------------------------------------------------------------------------------------- @@ -697,6 +700,8 @@ pub enum AgglayerBridgeError { "the code commitment of the provided account does not match the code commitment of the AggLayer Bridge account" )] CodeCommitmentMismatch, + #[error("bridge role {0} must have at least one initial holder")] + EmptyBridgeRole(RoleSymbol), } // HELPER FUNCTIONS diff --git a/crates/miden-agglayer/src/lib.rs b/crates/miden-agglayer/src/lib.rs index 4ba2d32ed2..5d04804b41 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -2,8 +2,6 @@ extern crate alloc; -use alloc::vec::Vec; - use miden_core::{Felt, Word}; use miden_protocol::account::{Account, AccountBuilder, AccountComponent, AccountId, AccountType}; use miden_protocol::assembly::Library; @@ -35,7 +33,7 @@ pub mod update_ger_note; pub mod utils; pub use b2agg_note::B2AggNote; -pub use bridge::{AggLayerBridge, AgglayerBridgeError, BridgeRoleMember, RemovedGerHashChain}; +pub use bridge::{AggLayerBridge, AgglayerBridgeError, BridgeRoles, RemovedGerHashChain}; pub use claim_note::{ CgiChainHash, ClaimNote, @@ -133,15 +131,15 @@ fn create_agglayer_faucet_component( /// via CONFIG_AGG_BRIDGE notes that call `bridge_config::register_faucet`. /// /// Access control is provided by the RBAC stack: `owner` becomes the account's `Ownable2Step` -/// governance owner, and `role_members` seeds the initial holders of the `FAUCET_ADMIN`, -/// `GER_INJECTOR`, and `GER_REMOVER` roles that gate the bridge's privileged procedures. +/// governance owner, and `roles` seeds the initial holders of the `FAUCET_ADMIN`, `GER_INJECTOR`, +/// and `GER_REMOVER` roles that gate the bridge's privileged procedures. /// /// The builder is pre-wired with the [`AuthNetworkAccount`] auth component, initialized with /// [`AggLayerBridge::allowed_notes()`] so the bridge only accepts its sanctioned input notes. fn create_bridge_account_builder( seed: Word, owner: AccountId, - role_members: Vec, + roles: BridgeRoles, ) -> AccountBuilder { Account::builder(seed.into()) .account_type(AccountType::Public) @@ -149,7 +147,7 @@ fn create_bridge_account_builder( .with_components(AccessControl::Rbac { owner, roles: AggLayerBridge::procedure_roles(), - members: AggLayerBridge::rbac_role_members(&role_members), + members: roles.role_members(), }) .with_auth_component( AuthNetworkAccount::with_allowed_notes(AggLayerBridge::allowed_notes()) @@ -160,13 +158,9 @@ fn create_bridge_account_builder( /// Creates a new bridge account with the standard configuration. /// /// This creates a new account suitable for production use. `owner` is the governance owner; the -/// initial role holders are seeded from `role_members` (see [`BridgeRoleMember`]). -pub fn create_bridge_account( - seed: Word, - owner: AccountId, - role_members: Vec, -) -> Account { - create_bridge_account_builder(seed, owner, role_members) +/// initial role holders are seeded from `roles` (see [`BridgeRoles`]). +pub fn create_bridge_account(seed: Word, owner: AccountId, roles: BridgeRoles) -> Account { + create_bridge_account_builder(seed, owner, roles) .build() .expect("bridge account should be valid") } @@ -175,12 +169,8 @@ pub fn create_bridge_account( /// /// This creates an existing account suitable for testing scenarios. #[cfg(any(feature = "testing", test))] -pub fn create_existing_bridge_account( - seed: Word, - owner: AccountId, - role_members: Vec, -) -> Account { - create_bridge_account_builder(seed, owner, role_members) +pub fn create_existing_bridge_account(seed: Word, owner: AccountId, roles: BridgeRoles) -> Account { + create_bridge_account_builder(seed, owner, roles) .build_existing() .expect("bridge account should be valid") } diff --git a/crates/miden-agglayer/src/testing/mod.rs b/crates/miden-agglayer/src/testing/mod.rs index fb120ccc76..dd5763d948 100644 --- a/crates/miden-agglayer/src/testing/mod.rs +++ b/crates/miden-agglayer/src/testing/mod.rs @@ -9,15 +9,65 @@ extern crate alloc; +use alloc::collections::BTreeSet; use alloc::string::{String, ToString}; use alloc::vec::Vec; +use miden_protocol::Word; +use miden_protocol::account::{ + Account, + AccountId, + AccountIdVersion, + AccountType, + AssetCallbackFlag, +}; use miden_protocol::utils::hex_to_bytes; use miden_protocol::utils::sync::LazyLock; use serde::Deserialize; use crate::claim_note::{ProofData, SmtNode}; -use crate::{CgiChainHash, EthAddress, EthAmount, ExitRoot, GlobalIndex, LeafData, MetadataHash}; +use crate::{ + BridgeRoles, + CgiChainHash, + EthAddress, + EthAmount, + ExitRoot, + GlobalIndex, + LeafData, + MetadataHash, + create_existing_bridge_account, +}; + +// BRIDGE ACCOUNT HELPERS +// ================================================================================================ + +/// A fixed dummy governance owner used for bridge accounts in tests that don't exercise the +/// owner's role-management powers. +pub fn bridge_test_owner() -> AccountId { + AccountId::dummy( + [0xee; 15], + AccountIdVersion::Version1, + AccountType::Public, + AssetCallbackFlag::Disabled, + ) +} + +/// Creates an existing bridge account seeded with a single holder per role and the fixed +/// [`bridge_test_owner`] as the governance owner. +pub fn create_existing_bridge_account_with_roles( + seed: Word, + faucet_admin: AccountId, + ger_injector: AccountId, + ger_remover: AccountId, +) -> Account { + let roles = BridgeRoles::new( + BTreeSet::from([faucet_admin]), + BTreeSet::from([ger_injector]), + BTreeSet::from([ger_remover]), + ) + .expect("single-holder role sets are non-empty"); + create_existing_bridge_account(seed, bridge_test_owner(), roles) +} // EMBEDDED TEST VECTOR JSON FILES // ================================================================================================ diff --git a/crates/miden-standards/src/account/access/mod.rs b/crates/miden-standards/src/account/access/mod.rs index 1ce86f20ee..1d5176598b 100644 --- a/crates/miden-standards/src/account/access/mod.rs +++ b/crates/miden-standards/src/account/access/mod.rs @@ -79,9 +79,7 @@ impl IntoIterator for AccessControl { }, AccessControl::Rbac { owner, roles, members } => vec![ Ownable2Step::new(owner).into(), - RoleBasedAccessControl::with_roles(members) - .expect("initial RBAC role assignments should be valid") - .into(), + RoleBasedAccessControl::with_roles(members).into(), Authority::RbacControlled { roles }.into(), ] .into_iter(), @@ -92,4 +90,4 @@ impl IntoIterator for AccessControl { pub use authority::{Authority, AuthorityError}; pub use ownable2step::{Ownable2Step, Ownable2StepError}; pub use pausable::{Pausable, PausableManager, PausableStorage}; -pub use rbac::{RbacError, RoleBasedAccessControl}; +pub use rbac::RoleBasedAccessControl; diff --git a/crates/miden-standards/src/account/access/rbac.rs b/crates/miden-standards/src/account/access/rbac.rs index f8a7376c2a..4685253180 100644 --- a/crates/miden-standards/src/account/access/rbac.rs +++ b/crates/miden-standards/src/account/access/rbac.rs @@ -151,24 +151,13 @@ impl RoleBasedAccessControl { /// /// Each entry grants a role to a set of accounts at account creation, exactly as if the owner /// had called `grant_role` for each member at runtime. Because roles and members are modeled as - /// a map of sets, duplicate roles and duplicate members are impossible by construction. All - /// seeded roles are owner-managed; delegated admins can be configured later via - /// `set_role_admin`. - /// - /// # Errors - /// - /// Returns an error if: - /// - a role maps to an empty member set. - /// - a role has more members than `u32::MAX`. - pub fn with_roles(roles: BTreeMap>) -> Result { - for (role, members) in &roles { - if members.is_empty() { - return Err(RbacError::EmptyRole(role.clone())); - } - u32::try_from(members.len()).map_err(|_| RbacError::TooManyMembers(role.clone()))?; - } - - Ok(Self { roles }) + /// a map of sets, duplicate roles and duplicate members are impossible by construction. A role + /// mapped to an empty set is dropped — a role with no members is a no-op, matching the runtime + /// semantics where a role exists only while it has at least one member. All seeded roles are + /// owner-managed; delegated admins can be configured later via `set_role_admin`. + pub fn with_roles(mut roles: BTreeMap>) -> Self { + roles.retain(|_, members| !members.is_empty()); + Self { roles } } /// Returns the storage slot name for the per-role config map. @@ -181,6 +170,22 @@ impl RoleBasedAccessControl { &ROLE_MEMBERSHIP_SLOT_NAME } + /// Returns the `role_config` map key for a role: the word `[0, 0, 0, role]`. + pub fn role_config_key(role: &RoleSymbol) -> StorageMapKey { + StorageMapKey::from_raw(Word::from([Felt::ZERO, Felt::ZERO, Felt::ZERO, Felt::from(role)])) + } + + /// Returns the `role_membership` map key for a `(role, account)` pair: the word + /// `[0, role, account_suffix, account_prefix]`. + pub fn role_membership_key(role: &RoleSymbol, account: &AccountId) -> StorageMapKey { + StorageMapKey::from_raw(Word::from([ + Felt::ZERO, + Felt::from(role), + account.suffix(), + account.prefix().as_felt(), + ])) + } + /// Returns the schema entry for the per-role config map. pub fn role_config_slot_schema() -> (StorageSlotName, StorageSlotSchema) { ( @@ -229,28 +234,17 @@ impl From for AccountComponent { let mut membership_entries = Vec::new(); for (role, members) in &rbac.roles { - let role_felt = Felt::from(role); - let member_count = u32::try_from(members.len()) - .expect("member count fits into u32 (validated in with_roles)"); + let member_count = + u32::try_from(members.len()).expect("role member count fits into u32"); config_entries.push(( - StorageMapKey::from_raw(Word::from([ - Felt::ZERO, - Felt::ZERO, - Felt::ZERO, - role_felt, - ])), + RoleBasedAccessControl::role_config_key(role), Word::from([Felt::from(member_count), Felt::ZERO, Felt::ZERO, Felt::ZERO]), )); for member in members { membership_entries.push(( - StorageMapKey::from_raw(Word::from([ - Felt::ZERO, - role_felt, - member.suffix(), - member.prefix().as_felt(), - ])), + RoleBasedAccessControl::role_membership_key(role, member), Word::from([Felt::ONE, Felt::ZERO, Felt::ZERO, Felt::ZERO]), )); } @@ -274,15 +268,3 @@ impl From for AccountComponent { .expect("RBAC component should satisfy the requirements of a valid account component") } } - -// RBAC ERROR -// ================================================================================================ - -/// Errors that can occur when seeding a [`RoleBasedAccessControl`] component with initial roles. -#[derive(Debug, thiserror::Error)] -pub enum RbacError { - #[error("role {0} has no initial members")] - EmptyRole(RoleSymbol), - #[error("role {0} has more members than the u32 maximum")] - TooManyMembers(RoleSymbol), -} diff --git a/crates/miden-testing/tests/agglayer/config_bridge.rs b/crates/miden-testing/tests/agglayer/config_bridge.rs index a2e94e2563..7c38b7d7ed 100644 --- a/crates/miden-testing/tests/agglayer/config_bridge.rs +++ b/crates/miden-testing/tests/agglayer/config_bridge.rs @@ -1,9 +1,12 @@ extern crate alloc; +use alloc::collections::BTreeSet; use alloc::vec::Vec; use miden_agglayer::{ AggLayerBridge, + AgglayerBridgeError, + BridgeRoles, ConfigAggBridgeNote, ConversionMetadata, EthAddress, @@ -15,7 +18,8 @@ use miden_protocol::block::account_tree::AccountIdKey; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::transaction::RawOutputNote; use miden_protocol::{Felt, Hasher, Word}; -use miden_testing::{Auth, MockChain}; +use miden_standards::errors::standards::ERR_SENDER_LACKS_ROLE; +use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; use super::test_utils::create_existing_bridge_account_with_roles; @@ -276,3 +280,94 @@ async fn test_config_agg_bridge_distinguishes_origin_network() -> anyhow::Result Ok(()) } + +/// A note sender that does not hold the `FAUCET_ADMIN` role cannot register a faucet: +/// `register_faucet` reverts via the account's `Authority` role check with `ERR_SENDER_LACKS_ROLE`. +#[tokio::test] +async fn config_agg_bridge_non_admin_sender_reverts() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let bridge_account = create_existing_bridge_account_with_roles( + builder.rng_mut().draw_word(), + bridge_admin.id(), + ger_injector.id(), + ger_remover.id(), + ); + builder.add_account(bridge_account.clone())?; + + let faucet_to_register = + AccountId::builder().account_type(AccountType::Public).build_with_seed([7; 32]); + + // The GER injector (who does not hold the FAUCET_ADMIN role) attempts to send the + // CONFIG_AGG_BRIDGE note. + let config_note = ConfigAggBridgeNote::create( + ConversionMetadata { + faucet_account_id: faucet_to_register, + origin_token_address: EthAddress::from_hex( + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + ) + .unwrap(), + scale: 0, + origin_network: 1, + is_native: false, + metadata_hash: MetadataHash::from_token_info("USD Coin", "USDC", 6), + }, + ger_injector.id(), + bridge_account.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(config_note.clone())); + + let mock_chain = builder.build()?; + + let result = mock_chain + .build_tx_context(bridge_account.id(), &[], &[config_note])? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_SENDER_LACKS_ROLE); + + Ok(()) +} + +/// `BridgeRoles::new` rejects an empty holder set for any role so a bridge cannot be deployed with +/// an unfillable role. +#[test] +fn bridge_roles_new_rejects_empty_role() { + let account = |seed: u8| { + AccountId::builder() + .account_type(AccountType::Public) + .build_with_seed([seed; 32]) + }; + let (a, b, c) = (account(1), account(2), account(3)); + + // A non-empty set for every role succeeds. + assert!( + BridgeRoles::new(BTreeSet::from([a]), BTreeSet::from([b]), BTreeSet::from([c]),).is_ok() + ); + + // An empty set for any single role is rejected. + for empty in 0..3 { + let sets: [BTreeSet; 3] = core::array::from_fn(|i| { + if i == empty { + BTreeSet::new() + } else { + BTreeSet::from([a]) + } + }); + let [faucet_admins, ger_injectors, ger_removers] = sets; + let err = BridgeRoles::new(faucet_admins, ger_injectors, ger_removers).unwrap_err(); + assert!(matches!(err, AgglayerBridgeError::EmptyBridgeRole(_))); + } +} diff --git a/crates/miden-testing/tests/agglayer/test_utils.rs b/crates/miden-testing/tests/agglayer/test_utils.rs index b8d5256257..10bd8e9db5 100644 --- a/crates/miden-testing/tests/agglayer/test_utils.rs +++ b/crates/miden-testing/tests/agglayer/test_utils.rs @@ -1,8 +1,8 @@ extern crate alloc; use alloc::sync::Arc; -use alloc::vec; +use miden_agglayer::agglayer_library; pub use miden_agglayer::testing::{ ClaimDataSource, LEAF_VALUE_VECTORS_JSON, @@ -11,8 +11,8 @@ pub use miden_agglayer::testing::{ MtfVectorsFile, SOLIDITY_CANONICAL_ZEROS, SOLIDITY_MERKLE_PROOF_VECTORS, + create_existing_bridge_account_with_roles, }; -use miden_agglayer::{BridgeRoleMember, agglayer_library, create_existing_bridge_account}; use miden_assembly::{Assembler, DefaultSourceManager, Linkage}; use miden_core_lib::CoreLibrary; use miden_processor::advice::AdviceInputs; @@ -24,14 +24,6 @@ use miden_processor::{ Program, StackInputs, }; -use miden_protocol::Word; -use miden_protocol::account::{ - Account, - AccountId, - AccountIdVersion, - AccountType, - AssetCallbackFlag, -}; use miden_protocol::errors::MasmError; use miden_protocol::transaction::TransactionKernel; use miden_protocol::utils::sync::LazyLock; @@ -52,43 +44,6 @@ pub static SOLIDITY_MTF_VECTORS: LazyLock = LazyLock::new(|| { serde_json::from_str(MTF_VECTORS_JSON).expect("failed to parse MTF vectors JSON") }); -// BRIDGE ACCOUNT HELPERS -// ================================================================================================ - -/// A fixed dummy governance owner used for bridge accounts in tests that don't exercise the -/// owner's role-management powers (granting/revoking roles, transferring ownership). -pub fn bridge_test_owner() -> AccountId { - AccountId::dummy( - [0xee; 15], - AccountIdVersion::Version1, - AccountType::Public, - AssetCallbackFlag::Disabled, - ) -} - -/// Creates an existing bridge account seeded with the three operational roles held by the given -/// accounts (`FAUCET_ADMIN`, `GER_INJECTOR`, `GER_REMOVER`) and the fixed [`bridge_test_owner`] -/// as the governance owner. -/// -/// Drop-in replacement for `create_existing_bridge_account` in tests: same `(seed, faucet_admin, -/// ger_injector, ger_remover)` arguments, but wires up the RBAC role seeding. -pub fn create_existing_bridge_account_with_roles( - seed: Word, - faucet_admin: AccountId, - ger_injector: AccountId, - ger_remover: AccountId, -) -> Account { - create_existing_bridge_account( - seed, - bridge_test_owner(), - vec![ - BridgeRoleMember::FaucetAdmin(vec![faucet_admin]), - BridgeRoleMember::GerInjector(vec![ger_injector]), - BridgeRoleMember::GerRemover(vec![ger_remover]), - ], - ) -} - // HELPER FUNCTIONS // ================================================================================================ diff --git a/crates/miden-testing/tests/agglayer/update_ger.rs b/crates/miden-testing/tests/agglayer/update_ger.rs index fc083a3aff..2cf281f42f 100644 --- a/crates/miden-testing/tests/agglayer/update_ger.rs +++ b/crates/miden-testing/tests/agglayer/update_ger.rs @@ -15,6 +15,7 @@ use miden_protocol::account::auth::AuthScheme; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::transaction::RawOutputNote; use miden_protocol::utils::sync::LazyLock; +use miden_standards::errors::standards::ERR_SENDER_LACKS_ROLE; use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; use miden_tx::utils::hex_to_bytes; use serde::Deserialize; @@ -345,3 +346,48 @@ async fn update_ger_rejects_duplicate() -> anyhow::Result<()> { Ok(()) } + +/// A note sender that does not hold the `GER_INJECTOR` role cannot inject a GER: `update_ger` +/// reverts via the account's `Authority` role check with `ERR_SENDER_LACKS_ROLE`. +#[tokio::test] +async fn update_ger_non_injector_sender_reverts() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let bridge_seed = builder.rng_mut().draw_word(); + let bridge_account = create_existing_bridge_account_with_roles( + bridge_seed, + bridge_admin.id(), + ger_injector.id(), + ger_remover.id(), + ); + builder.add_account(bridge_account.clone())?; + + // The GER remover (who does not hold the GER_INJECTOR role) attempts to send the UPDATE_GER + // note. + let ger = ExitRoot::from([0x33; 32]); + let update_ger_note = + UpdateGerNote::create(ger, ger_remover.id(), bridge_account.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); + + let mock_chain = builder.build()?; + + let result = mock_chain + .build_tx_context(bridge_account.id(), &[update_ger_note.id()], &[])? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_SENDER_LACKS_ROLE); + + Ok(()) +} From 5408fe4e2da65c06cf850f1d4c2a67f75154556b Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Mon, 6 Jul 2026 21:09:28 +0300 Subject: [PATCH 08/16] fix: address post-commit review - changelog: correct the [BREAKING] entry to reflect the BridgeRoles signature (create_bridge_account takes (seed, owner, BridgeRoles)) instead of the removed BridgeRoleMember type. - rbac: note that the u32 member-count conversion in the From impl is purely defensive / infallible. - test: add test_rbac_with_roles_drops_empty_role locking in the empty-role drop semantics. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- .../src/account/access/rbac.rs | 2 ++ crates/miden-testing/tests/scripts/rbac.rs | 29 +++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f21d3bc097..e4470bae0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Changes -- [BREAKING] Replaced the AggLayer bridge's hard-coded admin/injector/remover account-ID authorization with an RBAC access-control stack (`Ownable2Step` + `RoleBasedAccessControl` + `Authority`); `create_bridge_account` now takes `(seed, owner, Vec)`, `AggLayerBridge::new()` is now stateless, and `AccessControl::Rbac` gains a `members` field for seeding initial role holders ([#3130](https://github.com/0xMiden/protocol/pull/3130)). +- [BREAKING] Replaced the AggLayer bridge's hard-coded admin/injector/remover account-ID authorization with an RBAC access-control stack (`Ownable2Step` + `RoleBasedAccessControl` + `Authority`); `create_bridge_account` now takes `(seed, owner, BridgeRoles)` (a validated struct of the initial `FAUCET_ADMIN` / `GER_INJECTOR` / `GER_REMOVER` holders), `AggLayerBridge` is now a stateless component, and `AccessControl::Rbac` gains a `members` field for seeding initial role holders ([#3130](https://github.com/0xMiden/protocol/pull/3130)). - Changed the default `LocalTransactionProver` hash function from `BLAKE3` to `Poseidon2`, added ECDSA variants for every signature-authenticated transaction benchmark, and restructured the time counting benchmark IDs to encode the signing scheme and proving hash function (e.g. `poseidon2/falcon/single-p2id-note`) ([#3152](https://github.com/0xMiden/protocol/pull/3152)). - Added a non-fungible (NFT) faucet (`NonFungibleFaucet`): the asset value is an off-chain salted commitment `hash(user_data, salt)`, and an on-chain asset-status registry keyed by `[hash0, hash1, 0, 0]` enforces per-commitment uniqueness and permanent burn. Added `create_user_non_fungible_faucet` / `create_network_non_fungible_faucet` APIs, the `mint_nft` / `burn_nft` note scripts (`NonFungibleMintNote` / `NonFungibleBurnNote`), a `compute_commitment` helper, and reuses `TokenMetadata` (with `external_link` surfaced as `contract_uri`) and `TokenPolicyManager` for mint/burn/transfer policies ([#3106](https://github.com/0xMiden/protocol/pull/3106)). - [BREAKING] Refactored the mint policy interface so it is shared across fungible and non-fungible faucets: `policy_manager::execute_mint_policy` (and the mint policy `check_policy` predicates) now operate on the full `ASSET_VALUE` word instead of an `amount` ([#3106](https://github.com/0xMiden/protocol/pull/3106)). diff --git a/crates/miden-standards/src/account/access/rbac.rs b/crates/miden-standards/src/account/access/rbac.rs index 4685253180..8529b34940 100644 --- a/crates/miden-standards/src/account/access/rbac.rs +++ b/crates/miden-standards/src/account/access/rbac.rs @@ -234,6 +234,8 @@ impl From for AccountComponent { let mut membership_entries = Vec::new(); for (role, members) in &rbac.roles { + // A `BTreeSet` cannot hold more than `u32::MAX` distinct account IDs in + // practice, so this conversion is infallible; the bound is purely defensive. let member_count = u32::try_from(members.len()).expect("role member count fits into u32"); diff --git a/crates/miden-testing/tests/scripts/rbac.rs b/crates/miden-testing/tests/scripts/rbac.rs index fc644ef351..075959a807 100644 --- a/crates/miden-testing/tests/scripts/rbac.rs +++ b/crates/miden-testing/tests/scripts/rbac.rs @@ -818,3 +818,32 @@ async fn test_rbac_granting_admin_role_does_not_change_target_role_admin_config( Ok(()) } + +/// `with_roles` drops a role mapped to an empty member set (a role with no members is a no-op), +/// while non-empty roles are still seeded. +#[test] +fn test_rbac_with_roles_drops_empty_role() -> anyhow::Result<()> { + let owner = test_account_id(111); + let member = test_account_id(112); + + let minter = role("MINTER"); + let burner = role("BURNER"); + + let account = create_rbac_account_with_members( + owner, + BTreeMap::from([ + // Mapped to an empty set: dropped, not seeded. + (minter.clone(), BTreeSet::new()), + // Mapped to a non-empty set: seeded. + (burner.clone(), BTreeSet::from([member])), + ]), + )?; + + assert_eq!(get_role_config(&account, &minter)?.0, Felt::ZERO); + assert!(!is_role_member(&account, &minter, member)?); + + assert_eq!(get_role_config(&account, &burner)?.0, Felt::ONE); + assert!(is_role_member(&account, &burner, member)?); + + Ok(()) +} From 8c004b498402602e662e6cd5e27c31938c7ba3fe Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Tue, 7 Jul 2026 14:40:28 +0300 Subject: [PATCH 09/16] test: drop redundant create_rbac_account_with_owner helper create_rbac_account_with_members(owner, BTreeMap::new()) covers the empty-members case, so the separate owner-only helper is unnecessary. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/miden-testing/tests/scripts/rbac.rs | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/crates/miden-testing/tests/scripts/rbac.rs b/crates/miden-testing/tests/scripts/rbac.rs index 075959a807..686b1b0273 100644 --- a/crates/miden-testing/tests/scripts/rbac.rs +++ b/crates/miden-testing/tests/scripts/rbac.rs @@ -29,20 +29,6 @@ use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; // HELPERS // ================================================================================================ -fn create_rbac_account_with_owner(owner: AccountId) -> anyhow::Result { - let account = AccountBuilder::new([9; 32]) - .account_type(AccountType::Public) - .with_auth_component(Auth::IncrNonce) - .with_components(AccessControl::Rbac { - owner, - roles: BTreeMap::new(), - members: BTreeMap::new(), - }) - .build_existing()?; - - Ok(account) -} - fn create_rbac_account_with_members( owner: AccountId, members: BTreeMap>, @@ -57,7 +43,7 @@ fn create_rbac_account_with_members( } fn create_rbac_chain(owner: AccountId) -> anyhow::Result<(Account, MockChain)> { - let account = create_rbac_account_with_owner(owner)?; + let account = create_rbac_account_with_members(owner, BTreeMap::new())?; let mut builder = MockChain::builder(); builder.add_account(account.clone())?; @@ -371,7 +357,7 @@ async fn test_rbac_with_roles_matches_runtime_grants() -> anyhow::Result<()> { )?; // Account built empty, then granted the same two members at runtime. - let empty = create_rbac_account_with_owner(owner)?; + let empty = create_rbac_account_with_members(owner, BTreeMap::new())?; let mut builder = MockChain::builder(); builder.add_account(empty.clone())?; let mock_chain = builder.build()?; From 8f6abc7d469a63e267bb596c526e0fc20d051135 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Tue, 7 Jul 2026 14:57:01 +0300 Subject: [PATCH 10/16] refactor(agglayer): inline create_existing_bridge_account It was only used by the shared create_existing_bridge_account_with_roles test helper, so fold the build_existing() call into that helper (via the crate-private create_bridge_account_builder) and drop the redundant public wrapper. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/miden-agglayer/src/lib.rs | 10 ---------- crates/miden-agglayer/src/testing/mod.rs | 6 ++++-- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/crates/miden-agglayer/src/lib.rs b/crates/miden-agglayer/src/lib.rs index 5d04804b41..10f1ad4a00 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -165,16 +165,6 @@ pub fn create_bridge_account(seed: Word, owner: AccountId, roles: BridgeRoles) - .expect("bridge account should be valid") } -/// Creates an existing bridge account with the standard configuration. -/// -/// This creates an existing account suitable for testing scenarios. -#[cfg(any(feature = "testing", test))] -pub fn create_existing_bridge_account(seed: Word, owner: AccountId, roles: BridgeRoles) -> Account { - create_bridge_account_builder(seed, owner, roles) - .build_existing() - .expect("bridge account should be valid") -} - /// Creates a complete agglayer faucet account builder with the specified configuration. /// /// The builder includes: diff --git a/crates/miden-agglayer/src/testing/mod.rs b/crates/miden-agglayer/src/testing/mod.rs index dd5763d948..91d8188125 100644 --- a/crates/miden-agglayer/src/testing/mod.rs +++ b/crates/miden-agglayer/src/testing/mod.rs @@ -35,7 +35,7 @@ use crate::{ GlobalIndex, LeafData, MetadataHash, - create_existing_bridge_account, + create_bridge_account_builder, }; // BRIDGE ACCOUNT HELPERS @@ -66,7 +66,9 @@ pub fn create_existing_bridge_account_with_roles( BTreeSet::from([ger_remover]), ) .expect("single-holder role sets are non-empty"); - create_existing_bridge_account(seed, bridge_test_owner(), roles) + create_bridge_account_builder(seed, bridge_test_owner(), roles) + .build_existing() + .expect("bridge account should be valid") } // EMBEDDED TEST VECTOR JSON FILES From 4a7bebb97bd10f072fb575a491ed6150620bc7a8 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Tue, 7 Jul 2026 15:08:50 +0300 Subject: [PATCH 11/16] docs: address review comments - bridge: drop the verbose BridgeRoles struct doc paragraph (the empty-role rejection is already documented on BridgeRoles::new). - changelog: trim the breaking-change entry per review suggestion. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- crates/miden-agglayer/src/bridge.rs | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4470bae0d..a8b5d3a8d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Changes -- [BREAKING] Replaced the AggLayer bridge's hard-coded admin/injector/remover account-ID authorization with an RBAC access-control stack (`Ownable2Step` + `RoleBasedAccessControl` + `Authority`); `create_bridge_account` now takes `(seed, owner, BridgeRoles)` (a validated struct of the initial `FAUCET_ADMIN` / `GER_INJECTOR` / `GER_REMOVER` holders), `AggLayerBridge` is now a stateless component, and `AccessControl::Rbac` gains a `members` field for seeding initial role holders ([#3130](https://github.com/0xMiden/protocol/pull/3130)). +- [BREAKING] Replaced the AggLayer bridge's hard-coded admin/injector/remover account-ID authorization with an RBAC access-control stack (`Ownable2Step` + `RoleBasedAccessControl` + `Authority`); `create_bridge_account` now takes `(seed, owner, BridgeRoles)`, `AggLayerBridge` is now a stateless component, and `AccessControl::Rbac` gains a `members` field for seeding initial role holders ([#3130](https://github.com/0xMiden/protocol/pull/3130)). - Changed the default `LocalTransactionProver` hash function from `BLAKE3` to `Poseidon2`, added ECDSA variants for every signature-authenticated transaction benchmark, and restructured the time counting benchmark IDs to encode the signing scheme and proving hash function (e.g. `poseidon2/falcon/single-p2id-note`) ([#3152](https://github.com/0xMiden/protocol/pull/3152)). - Added a non-fungible (NFT) faucet (`NonFungibleFaucet`): the asset value is an off-chain salted commitment `hash(user_data, salt)`, and an on-chain asset-status registry keyed by `[hash0, hash1, 0, 0]` enforces per-commitment uniqueness and permanent burn. Added `create_user_non_fungible_faucet` / `create_network_non_fungible_faucet` APIs, the `mint_nft` / `burn_nft` note scripts (`NonFungibleMintNote` / `NonFungibleBurnNote`), a `compute_commitment` helper, and reuses `TokenMetadata` (with `external_link` surfaced as `contract_uri`) and `TokenPolicyManager` for mint/burn/transfer policies ([#3106](https://github.com/0xMiden/protocol/pull/3106)). - [BREAKING] Refactored the mint policy interface so it is shared across fungible and non-fungible faucets: `policy_manager::execute_mint_policy` (and the mint policy `check_policy` predicates) now operate on the full `ASSET_VALUE` word instead of an `amount` ([#3106](https://github.com/0xMiden/protocol/pull/3106)). diff --git a/crates/miden-agglayer/src/bridge.rs b/crates/miden-agglayer/src/bridge.rs index 89e4fdd5cb..621754b139 100644 --- a/crates/miden-agglayer/src/bridge.rs +++ b/crates/miden-agglayer/src/bridge.rs @@ -170,11 +170,6 @@ procedure_root!( /// - `FAUCET_ADMIN` gates `register_faucet` and `store_faucet_metadata_hash`. /// - `GER_INJECTOR` gates `update_ger`. /// - `GER_REMOVER` gates `remove_ger`. -/// -/// Construct via [`BridgeRoles::new`], which rejects an empty holder set for any role: naming all -/// three roles is mandatory, and a bridge cannot be deployed with a role that has no holders (which -/// could not be repaired until on-chain role management lands, see -/// [#2706](https://github.com/0xMiden/protocol/issues/2706)). #[derive(Debug, Clone, PartialEq, Eq)] pub struct BridgeRoles { faucet_admins: BTreeSet, From 596d2b356ede93c02e6e24f86a904c7fcb7cd5fd Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Tue, 7 Jul 2026 15:54:57 +0300 Subject: [PATCH 12/16] docs: align remaining bridge-admin wording to FAUCET_ADMIN role Post-merge review nits: the config/deregister note-script headers, the register_faucet / store_faucet_metadata_hash panic docs, and the deregister note builder still referred to the 'bridge admin'; update them to the FAUCET_ADMIN role that authority::assert_authorized now enforces. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm | 4 ++-- crates/miden-agglayer/asm/note_scripts/config_agg_bridge.masm | 2 +- .../asm/note_scripts/deregister_agg_faucet.masm | 2 +- crates/miden-agglayer/src/deregister_note.rs | 3 ++- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm index 16913985c3..b005c5e47d 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm @@ -206,7 +206,7 @@ end #! Outputs: [pad(16)] #! #! Panics if: -#! - the note sender is not the bridge admin. +#! - the note sender does not hold the faucet admin role. #! #! Invocation: call @locals(14) @@ -354,7 +354,7 @@ end #! Outputs: [pad(16)] #! #! Panics if: -#! - the note sender is not the bridge admin. +#! - the note sender does not hold the faucet admin role. #! #! Invocation: call @account_procedure diff --git a/crates/miden-agglayer/asm/note_scripts/config_agg_bridge.masm b/crates/miden-agglayer/asm/note_scripts/config_agg_bridge.masm index 018f114844..02817dc308 100644 --- a/crates/miden-agglayer/asm/note_scripts/config_agg_bridge.masm +++ b/crates/miden-agglayer/asm/note_scripts/config_agg_bridge.masm @@ -39,7 +39,7 @@ const ERR_CONFIG_AGG_BRIDGE_TARGET_ACCOUNT_MISMATCH = "CONFIG_AGG_BRIDGE note at #! token registry, and faucet metadata map. #! #! This note can only be consumed by the Agglayer Bridge account that is targeted by the note -#! attachment, and only if the note was sent by the bridge admin. +#! attachment, and only if the note was sent by a holder of the faucet admin role. #! Upon consumption, it registers the faucet ID, origin token address mapping, scale factor, #! origin network, is_native flag, and metadata hash in the bridge. #! diff --git a/crates/miden-agglayer/asm/note_scripts/deregister_agg_faucet.masm b/crates/miden-agglayer/asm/note_scripts/deregister_agg_faucet.masm index affd14a5c1..24060e7291 100644 --- a/crates/miden-agglayer/asm/note_scripts/deregister_agg_faucet.masm +++ b/crates/miden-agglayer/asm/note_scripts/deregister_agg_faucet.masm @@ -21,7 +21,7 @@ const ERR_DEREGISTER_AGG_FAUCET_TARGET_ACCOUNT_MISMATCH = "DEREGISTER_AGG_FAUCET #! clearing every registry/metadata entry the bridge holds for it. #! #! This note can only be consumed by the Agglayer Bridge account targeted by the note attachment, -#! and only if it was sent by the bridge admin. +#! and only if it was sent by a holder of the faucet admin role. #! #! Requires that the account exposes: #! - agglayer::bridge_config::deregister_faucet procedure. diff --git a/crates/miden-agglayer/src/deregister_note.rs b/crates/miden-agglayer/src/deregister_note.rs index 419fb44a17..6227c440bb 100644 --- a/crates/miden-agglayer/src/deregister_note.rs +++ b/crates/miden-agglayer/src/deregister_note.rs @@ -87,7 +87,8 @@ impl DeregisterAggFaucetNote { /// /// # Parameters /// - `faucet_account_id`: The account ID of the faucet to deregister - /// - `sender_account_id`: The account ID of the note creator (must be the bridge admin) + /// - `sender_account_id`: The account ID of the note creator (must hold the `FAUCET_ADMIN` + /// role) /// - `target_account_id`: The bridge account ID that will consume this note /// - `rng`: Random number generator for creating the note serial number /// From 3a3ba8ff868c8701496517ec2242fd84c63315a8 Mon Sep 17 00:00:00 2001 From: Andrey Khmuro Date: Thu, 9 Jul 2026 15:53:30 +0300 Subject: [PATCH 13/16] chore: update docs, reorganize test folder (no code changes) --- crates/miden-agglayer/src/bridge.rs | 4 +- crates/miden-agglayer/src/lib.rs | 8 +- crates/miden-agglayer/src/testing/mod.rs | 28 +- .../src/account/access/rbac.rs | 11 +- crates/miden-testing/tests/scripts/rbac.rs | 742 +++++++++--------- 5 files changed, 390 insertions(+), 403 deletions(-) diff --git a/crates/miden-agglayer/src/bridge.rs b/crates/miden-agglayer/src/bridge.rs index 68e3121131..21d5b332ef 100644 --- a/crates/miden-agglayer/src/bridge.rs +++ b/crates/miden-agglayer/src/bridge.rs @@ -243,8 +243,8 @@ impl BridgeRoles { /// The bridge's privileged roles are managed by the account's RBAC stack /// (`RoleBasedAccessControl` + `Authority`), installed alongside this component at account /// creation. The role-gated procedures call `authority::assert_authorized`, which requires the note -/// sender to hold the role mapped to the procedure. The built-in `ADMIN` role (seeded at creation) -/// administers the operational roles. See [`BridgeRoles`] and [`AggLayerBridge::procedure_roles`]. +/// sender to hold the role mapped to the procedure. See [`BridgeRoles`] and +/// [`AggLayerBridge::procedure_roles`]. /// /// ## Storage Layout /// diff --git a/crates/miden-agglayer/src/lib.rs b/crates/miden-agglayer/src/lib.rs index 10493e4283..bb16f965ff 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -134,10 +134,10 @@ fn create_agglayer_faucet_component( /// The bridge starts with an empty faucet registry. Faucets are registered at runtime /// via CONFIG_AGG_BRIDGE notes that call `bridge_config::register_faucet`. /// -/// Access control is provided by the RBAC stack (`RoleBasedAccessControl` + `Authority`): `admin` -/// is seeded as the initial member of the built-in `ADMIN` role, which administers (grants/revokes) -/// the operational roles, and `roles` seeds the initial holders of the `FAUCET_ADMIN`, -/// `GER_INJECTOR`, and `GER_REMOVER` roles that gate the bridge's privileged procedures. +/// Here `admin` is seeded as the initial member of the built-in `ADMIN` role, which administers the +/// operational roles in case they don't have their own administrators, and `roles` seeds the +/// initial holders of the `FAUCET_ADMIN`, `GER_INJECTOR`, and `GER_REMOVER` roles that gate the +/// bridge's privileged procedures. /// /// The builder is pre-wired with the [`AuthNetworkAccount`] auth component, initialized with /// [`AggLayerBridge::allowed_notes()`] so the bridge only accepts its sanctioned input notes. diff --git a/crates/miden-agglayer/src/testing/mod.rs b/crates/miden-agglayer/src/testing/mod.rs index 2d1e52f4fe..58bfd7a98a 100644 --- a/crates/miden-agglayer/src/testing/mod.rs +++ b/crates/miden-agglayer/src/testing/mod.rs @@ -14,13 +14,8 @@ use alloc::string::{String, ToString}; use alloc::vec::Vec; use miden_protocol::Word; -use miden_protocol::account::{ - Account, - AccountId, - AccountIdVersion, - AccountType, - AssetCallbackFlag, -}; +use miden_protocol::account::{Account, AccountId}; +use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE; use miden_protocol::utils::hex_to_bytes; use miden_protocol::utils::sync::LazyLock; use serde::Deserialize; @@ -41,19 +36,8 @@ use crate::{ // BRIDGE ACCOUNT HELPERS // ================================================================================================ -/// A fixed dummy `ADMIN`-role account used for bridge accounts in tests that don't exercise the -/// admin's role-management powers (granting/revoking the operational roles). -pub fn bridge_test_admin() -> AccountId { - AccountId::dummy( - [0xee; 15], - AccountIdVersion::Version1, - AccountType::Public, - AssetCallbackFlag::Disabled, - ) -} - /// Creates an existing bridge account seeded with a single holder per operational role and the -/// fixed [`bridge_test_admin`] as the built-in `ADMIN` role member. +/// fixed dummy acconunt as the built-in `ADMIN` role member. pub fn create_existing_bridge_account_with_roles( seed: Word, faucet_admin: AccountId, @@ -66,7 +50,11 @@ pub fn create_existing_bridge_account_with_roles( BTreeSet::from([ger_remover]), ) .expect("single-holder role sets are non-empty"); - create_bridge_account_builder(seed, bridge_test_admin(), roles) + + let bridge_admin_account = + AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap(); + + create_bridge_account_builder(seed, bridge_admin_account, roles) .build_existing() .expect("bridge account should be valid") } diff --git a/crates/miden-standards/src/account/access/rbac.rs b/crates/miden-standards/src/account/access/rbac.rs index 6d14217f85..0e5a00b30d 100644 --- a/crates/miden-standards/src/account/access/rbac.rs +++ b/crates/miden-standards/src/account/access/rbac.rs @@ -188,10 +188,8 @@ impl RoleBasedAccessControl { /// /// Each seeded role's delegated admin is left unset, so — like any role — it is administered /// by the `ADMIN` role until an admin is delegated via `set_role_admin`. This lets an account - /// be created already populated with role holders (e.g. domain-specific operator roles) - /// alongside the bootstrap administrator. A role mapped to an empty member set is dropped (a - /// role with no members is a no-op). Members provided for the `ADMIN` role are merged with - /// `initial_admins`. + /// be created already populated with role holders alongside the bootstrap administrator. A role + /// mapped to an empty member set is dropped: a role with no members is a no-op. pub fn with_roles( initial_admins: BTreeSet, mut roles: BTreeMap>, @@ -252,7 +250,8 @@ impl From for AccountComponent { fn from(rbac: RoleBasedAccessControl) -> Self { // Combine the seeded ADMIN members with any additional seeded roles into a single // role -> members map. Each seeded role (including ADMIN) leaves its delegated admin unset - // (0), so ADMIN administers it. + // (0), so ADMIN administers it. Members provided for the `ADMIN` role are merged with + // `initial_admins`. let mut roles = rbac.initial_roles; if !rbac.initial_admins.is_empty() { roles @@ -262,7 +261,7 @@ impl From for AccountComponent { } // Seed, for every non-empty role: - // - role_config: [0, 0, 0, role] -> [member_count, 0, 0, 0] + // - role_config: [0, 0, 0, role] -> [member_count, 0, 0, 0] // - role_membership: [0, role, acct_suffix, acct_prefix] -> [1, 0, 0, 0] let mut config_entries = Vec::new(); let mut membership_entries = Vec::new(); diff --git a/crates/miden-testing/tests/scripts/rbac.rs b/crates/miden-testing/tests/scripts/rbac.rs index 6649853240..d4b3b96815 100644 --- a/crates/miden-testing/tests/scripts/rbac.rs +++ b/crates/miden-testing/tests/scripts/rbac.rs @@ -25,400 +25,122 @@ use miden_standards::testing::note::NoteBuilder; use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; use rstest::rstest; -// HELPERS +// TESTS // ================================================================================================ -fn create_rbac_account_with_admin(admin: AccountId) -> anyhow::Result { - let account = AccountBuilder::new([9; 32]) - .account_type(AccountType::Public) - .with_auth_component(Auth::IncrNonce) - .with_components(AccessControl::Rbac { admin, roles: BTreeMap::new() }) - .build_existing()?; +/// End-to-end role management through the delegated-admin path: the `ADMIN` bootstrap member +/// delegates `MINTER` to `MINTER_ADMIN`, seeds a `MINTER_ADMIN` member, and that +/// delegate — not ADMIN — grants and revokes `MINTER`. +#[tokio::test] +async fn test_rbac_role_management_and_lookup() -> anyhow::Result<()> { + let admin = test_account_id(11); + let admin_member = test_account_id(13); + let member = test_account_id(12); - Ok(account) -} + let minter = role("MINTER"); + let minter_admin = role("MINTER_ADMIN"); -fn create_rbac_chain(admin: AccountId) -> anyhow::Result<(Account, MockChain)> { - let account = create_rbac_account_with_admin(admin)?; - let mut builder = MockChain::builder(); - builder.add_account(account.clone())?; + let (account, mock_chain) = create_rbac_chain(admin)?; - Ok((account, builder.build()?)) -} + // Admin (a member of ADMIN, the default admin of every role) delegates MINTER's admin. + let set_role_admin_note = + build_note(admin, set_role_admin_script(&minter, Some(&minter_admin)))?; + let updated = execute_note_and_apply(&mock_chain, &account, &set_role_admin_note).await?; -fn test_account_id(seed: u8) -> AccountId { - AccountId::builder() - .account_type(AccountType::Private) - .build_with_seed([seed; 32]) -} + let (member_count, admin_role) = get_role_config(&updated, &minter)?; + assert_eq!(member_count, Felt::from(0u32)); + assert_eq!(admin_role, Felt::from(&minter_admin)); -fn role(name: &str) -> RoleSymbol { - RoleSymbol::new(name).expect("role symbol should be valid") -} + // MINTER_ADMIN is itself undelegated, so ADMIN seeds its first member. + let grant_admin_note = build_note(admin, grant_role_script(&minter_admin, admin_member))?; + let updated = execute_note_and_apply(&mock_chain, &updated, &grant_admin_note).await?; -fn role_config_key(role: &RoleSymbol) -> StorageMapKey { - StorageMapKey::from_raw(Word::from([Felt::ZERO, Felt::ZERO, Felt::ZERO, Felt::from(role)])) -} + // The MINTER_ADMIN member — not ADMIN — grants MINTER. + let grant_role_note = build_note(admin_member, grant_role_script(&minter, member))?; + let granted = execute_note_and_apply(&mock_chain, &updated, &grant_role_note).await?; -fn role_membership_key(role: &RoleSymbol, account_id: AccountId) -> StorageMapKey { - StorageMapKey::from_raw(Word::from([ - Felt::ZERO, - Felt::from(role), - account_id.suffix(), - account_id.prefix().as_felt(), - ])) -} + let (member_count, admin_role) = get_role_config(&granted, &minter)?; + assert_eq!(member_count, Felt::from(1u32)); + assert_eq!(admin_role, Felt::from(&minter_admin)); + assert!(is_role_member(&granted, &minter, member)?); -/// Returns the role's `(member_count, admin_role_symbol)` from on-chain storage. -fn get_role_config(account: &Account, role: &RoleSymbol) -> anyhow::Result<(Felt, Felt)> { - let word = account - .storage() - .get_map_item(RoleBasedAccessControl::role_config_slot(), role_config_key(role))?; - Ok((word[0], word[1])) -} + let revoke_role_note = build_note(admin_member, revoke_role_script(&minter, member))?; + let revoked = execute_note_and_apply(&mock_chain, &granted, &revoke_role_note).await?; -fn is_role_member( - account: &Account, - role: &RoleSymbol, - account_id: AccountId, -) -> anyhow::Result { - let word = account.storage().get_map_item( - RoleBasedAccessControl::role_membership_slot(), - role_membership_key(role, account_id), - )?; - Ok(word[0].as_canonical_u64() != 0) -} + let (member_count, admin_role) = get_role_config(&revoked, &minter)?; + assert_eq!(member_count, Felt::from(0u32)); + assert_eq!(admin_role, Felt::from(&minter_admin)); + assert!(!is_role_member(&revoked, &minter, member)?); -fn build_note(sender: AccountId, code: impl Into) -> anyhow::Result { - let seed: [u64; 4] = rand::random(); - let mut rng = RandomCoin::new(Word::from(seed.map(Felt::new_unchecked))); - Ok(NoteBuilder::new(sender, &mut rng) - .note_type(NoteType::Private) - .code(code.into()) - .build()?) + Ok(()) } -async fn execute_note_and_apply( - mock_chain: &MockChain, - account: &Account, - note: &Note, -) -> anyhow::Result { +#[tokio::test] +async fn test_rbac_renounce_role_and_permission_checks() -> anyhow::Result<()> { + let admin = test_account_id(31); + let member = test_account_id(32); + let outsider = test_account_id(33); + + let pauser = role("PAUSER"); + + let (account, mock_chain) = create_rbac_chain(admin)?; + + let grant_pauser_to_member = grant_role_script(&pauser, member); + + let non_admin_grant_note = build_note(outsider, grant_pauser_to_member.clone())?; let tx = mock_chain - .build_tx_context(account.clone(), &[], slice::from_ref(note))? + .build_tx_context(account.clone(), &[], slice::from_ref(&non_admin_grant_note))? .build()?; - let executed = tx.execute().await?; - - let mut updated = account.clone(); - updated.apply_patch(executed.account_patch())?; + let result = tx.execute().await; + assert_transaction_executor_error!(result, ERR_SENDER_NOT_ROLE_ADMIN); - Ok(updated) -} + let admin_grant_note = build_note(admin, grant_pauser_to_member)?; + let updated = execute_note_and_apply(&mock_chain, &account, &admin_grant_note).await?; + assert!(is_role_member(&updated, &pauser, member)?); -// SCRIPTS -// ================================================================================================ + let renounce_note = build_note(member, renounce_role_script(&pauser))?; + let renounced = execute_note_and_apply(&mock_chain, &updated, &renounce_note).await?; + assert!(!is_role_member(&renounced, &pauser, member)?); -fn set_role_admin_script(role: &RoleSymbol, admin_role: Option<&RoleSymbol>) -> String { - let admin_role = admin_role.map(Felt::from).unwrap_or(Felt::ZERO); - format!( - r#" - use miden::standards::access::rbac + let bad_revoke_note = build_note(admin, revoke_role_script(&pauser, member))?; + let tx = mock_chain + .build_tx_context(renounced, &[], slice::from_ref(&bad_revoke_note))? + .build()?; + let result = tx.execute().await; + assert_transaction_executor_error!(result, ERR_ACCOUNT_NOT_IN_ROLE); - @note_script - pub proc main - repeat.14 push.0 end - push.{admin_role} - push.{role} - call.rbac::set_role_admin - dropw dropw dropw dropw - end - "#, - role = Felt::from(role), - ) + Ok(()) } -fn grant_role_script(role: &RoleSymbol, account_id: AccountId) -> String { - format!( - r#" - use miden::standards::access::rbac +#[tokio::test] +async fn test_rbac_grant_role_sets_membership() -> anyhow::Result<()> { + let admin = test_account_id(41); + let member = test_account_id(42); - @note_script - pub proc main - repeat.13 push.0 end - push.{account_prefix} - push.{account_suffix} - push.{role} - call.rbac::grant_role - dropw dropw dropw dropw - end - "#, - account_prefix = account_id.prefix().as_felt(), - account_suffix = account_id.suffix(), - role = Felt::from(role), - ) -} + let minter = role("MINTER"); -fn revoke_role_script(role: &RoleSymbol, account_id: AccountId) -> String { - format!( - r#" - use miden::standards::access::rbac + let (account, mock_chain) = create_rbac_chain(admin)?; - @note_script - pub proc main - repeat.13 push.0 end - push.{account_prefix} - push.{account_suffix} - push.{role} - call.rbac::revoke_role - dropw dropw dropw dropw - end - "#, - account_prefix = account_id.prefix().as_felt(), - account_suffix = account_id.suffix(), - role = Felt::from(role), - ) -} + let grant_note = build_note(admin, grant_role_script(&minter, member))?; + let granted = execute_note_and_apply(&mock_chain, &account, &grant_note).await?; -fn renounce_role_script(role: &RoleSymbol) -> String { - format!( - r#" - use miden::standards::access::rbac + assert!(is_role_member(&granted, &minter, member)?); + let (member_count, _) = get_role_config(&granted, &minter)?; + assert_eq!(member_count, Felt::ONE); - @note_script - pub proc main - repeat.15 push.0 end - push.{role} - call.rbac::renounce_role - dropw dropw dropw dropw - end - "#, - role = Felt::from(role), - ) + Ok(()) } -fn assert_role_member_count_script(role: &RoleSymbol, expected_count: u64) -> String { - format!( - r#" - use miden::standards::access::rbac +#[tokio::test] +async fn test_rbac_grant_existing_member_is_noop() -> anyhow::Result<()> { + let admin = test_account_id(43); + let member = test_account_id(44); - @note_script - pub proc main - repeat.15 push.0 end - push.{role} - call.rbac::get_role_member_count - eq.{expected_count} assert.err="role member count mismatch" - dropw dropw dropw - drop drop drop - end - "#, - role = Felt::from(role), - ) -} + let minter = role("MINTER"); -fn assert_role_has_members_script(role: &RoleSymbol, expected_has_members: bool) -> String { - let expected_has_members = u8::from(expected_has_members); + let (account, mock_chain) = create_rbac_chain(admin)?; - format!( - r#" - use miden::standards::access::rbac - - @note_script - pub proc main - repeat.15 push.0 end - push.{role} - call.rbac::get_role_member_count - neq.0 - eq.{expected_has_members} assert.err="role population mismatch" - dropw dropw dropw - drop drop drop - end - "#, - role = Felt::from(role), - ) -} - -fn assert_role_admin_script(role: &RoleSymbol, expected_admin_role: Option<&RoleSymbol>) -> String { - let expected_admin_role = expected_admin_role.map(Felt::from).unwrap_or(Felt::ZERO); - - format!( - r#" - use miden::standards::access::rbac - - @note_script - pub proc main - repeat.15 push.0 end - push.{role} - call.rbac::get_role_admin - eq.{expected_admin_role} assert.err="role admin mismatch" - dropw dropw dropw - drop drop drop - end - "#, - role = Felt::from(role), - ) -} - -fn assert_has_role_script( - role: &RoleSymbol, - account_id: AccountId, - expected_has_role: bool, -) -> String { - let expected_has_role = u8::from(expected_has_role); - - format!( - r#" - use miden::standards::access::rbac - - @note_script - pub proc main - repeat.13 push.0 end - push.{account_prefix} - push.{account_suffix} - push.{role} - call.rbac::has_role - eq.{expected_has_role} assert.err="account role membership mismatch" - dropw dropw dropw - drop drop drop - end - "#, - account_prefix = account_id.prefix().as_felt(), - account_suffix = account_id.suffix(), - role = Felt::from(role), - ) -} - -fn set_role_admin_raw_script(role: Felt, admin_role: Felt) -> String { - format!( - r#" - use miden::standards::access::rbac - - @note_script - pub proc main - repeat.14 push.0 end - push.{admin_role} - push.{role} - call.rbac::set_role_admin - dropw dropw dropw dropw - end - "#, - ) -} - -// TESTS -// ================================================================================================ - -/// End-to-end role management through the delegated-admin path: the `ADMIN` bootstrap member -/// delegates `MINTER` to `MINTER_ADMIN`, seeds a `MINTER_ADMIN` member, and that -/// delegate — not ADMIN — grants and revokes `MINTER`. -#[tokio::test] -async fn test_rbac_role_management_and_lookup() -> anyhow::Result<()> { - let admin = test_account_id(11); - let admin_member = test_account_id(13); - let member = test_account_id(12); - - let minter = role("MINTER"); - let minter_admin = role("MINTER_ADMIN"); - - let (account, mock_chain) = create_rbac_chain(admin)?; - - // Admin (a member of ADMIN, the default admin of every role) delegates MINTER's admin. - let set_role_admin_note = - build_note(admin, set_role_admin_script(&minter, Some(&minter_admin)))?; - let updated = execute_note_and_apply(&mock_chain, &account, &set_role_admin_note).await?; - - let (member_count, admin_role) = get_role_config(&updated, &minter)?; - assert_eq!(member_count, Felt::from(0u32)); - assert_eq!(admin_role, Felt::from(&minter_admin)); - - // MINTER_ADMIN is itself undelegated, so ADMIN seeds its first member. - let grant_admin_note = build_note(admin, grant_role_script(&minter_admin, admin_member))?; - let updated = execute_note_and_apply(&mock_chain, &updated, &grant_admin_note).await?; - - // The MINTER_ADMIN member — not ADMIN — grants MINTER. - let grant_role_note = build_note(admin_member, grant_role_script(&minter, member))?; - let granted = execute_note_and_apply(&mock_chain, &updated, &grant_role_note).await?; - - let (member_count, admin_role) = get_role_config(&granted, &minter)?; - assert_eq!(member_count, Felt::from(1u32)); - assert_eq!(admin_role, Felt::from(&minter_admin)); - assert!(is_role_member(&granted, &minter, member)?); - - let revoke_role_note = build_note(admin_member, revoke_role_script(&minter, member))?; - let revoked = execute_note_and_apply(&mock_chain, &granted, &revoke_role_note).await?; - - let (member_count, admin_role) = get_role_config(&revoked, &minter)?; - assert_eq!(member_count, Felt::from(0u32)); - assert_eq!(admin_role, Felt::from(&minter_admin)); - assert!(!is_role_member(&revoked, &minter, member)?); - - Ok(()) -} - -#[tokio::test] -async fn test_rbac_renounce_role_and_permission_checks() -> anyhow::Result<()> { - let admin = test_account_id(31); - let member = test_account_id(32); - let outsider = test_account_id(33); - - let pauser = role("PAUSER"); - - let (account, mock_chain) = create_rbac_chain(admin)?; - - let grant_pauser_to_member = grant_role_script(&pauser, member); - - let non_admin_grant_note = build_note(outsider, grant_pauser_to_member.clone())?; - let tx = mock_chain - .build_tx_context(account.clone(), &[], slice::from_ref(&non_admin_grant_note))? - .build()?; - let result = tx.execute().await; - assert_transaction_executor_error!(result, ERR_SENDER_NOT_ROLE_ADMIN); - - let admin_grant_note = build_note(admin, grant_pauser_to_member)?; - let updated = execute_note_and_apply(&mock_chain, &account, &admin_grant_note).await?; - assert!(is_role_member(&updated, &pauser, member)?); - - let renounce_note = build_note(member, renounce_role_script(&pauser))?; - let renounced = execute_note_and_apply(&mock_chain, &updated, &renounce_note).await?; - assert!(!is_role_member(&renounced, &pauser, member)?); - - let bad_revoke_note = build_note(admin, revoke_role_script(&pauser, member))?; - let tx = mock_chain - .build_tx_context(renounced, &[], slice::from_ref(&bad_revoke_note))? - .build()?; - let result = tx.execute().await; - assert_transaction_executor_error!(result, ERR_ACCOUNT_NOT_IN_ROLE); - - Ok(()) -} - -#[tokio::test] -async fn test_rbac_grant_role_sets_membership() -> anyhow::Result<()> { - let admin = test_account_id(41); - let member = test_account_id(42); - - let minter = role("MINTER"); - - let (account, mock_chain) = create_rbac_chain(admin)?; - - let grant_note = build_note(admin, grant_role_script(&minter, member))?; - let granted = execute_note_and_apply(&mock_chain, &account, &grant_note).await?; - - assert!(is_role_member(&granted, &minter, member)?); - let (member_count, _) = get_role_config(&granted, &minter)?; - assert_eq!(member_count, Felt::ONE); - - Ok(()) -} - -#[tokio::test] -async fn test_rbac_grant_existing_member_is_noop() -> anyhow::Result<()> { - let admin = test_account_id(43); - let member = test_account_id(44); - - let minter = role("MINTER"); - - let (account, mock_chain) = create_rbac_chain(admin)?; - - let grant_minter_to_member = grant_role_script(&minter, member); + let grant_minter_to_member = grant_role_script(&minter, member); let grant_note = build_note(admin, grant_minter_to_member.clone())?; let granted = execute_note_and_apply(&mock_chain, &account, &grant_note).await?; @@ -727,15 +449,6 @@ async fn test_rbac_granting_admin_role_does_not_change_target_role_admin_config( Ok(()) } -/// The action that a role's delegated admin performs exclusively, out of the general `ADMIN` -/// role's reach. -enum DelegatedAdminAction { - /// Grant the delegated role to a member (`grant_role`). - GrantRole, - /// Clear the delegation, reverting the role to `ADMIN` management (`set_role_admin(_, None)`). - ClearDelegation, -} - /// Regression test: once a role is delegated to a dedicated admin role, the general `ADMIN` role /// is locked out of it entirely — it can neither manage membership (`grant_role`) nor re-point the /// delegation (`set_role_admin`). The role becomes exclusively controlled by its delegated admin, @@ -939,3 +652,290 @@ fn test_rbac_with_roles_seeds_admin_and_operator_roles() -> anyhow::Result<()> { Ok(()) } + +// HELPERS +// ================================================================================================ + +/// The action that a role's delegated admin performs exclusively, out of the general `ADMIN` +/// role's reach. +enum DelegatedAdminAction { + /// Grant the delegated role to a member (`grant_role`). + GrantRole, + /// Clear the delegation, reverting the role to `ADMIN` management (`set_role_admin(_, None)`). + ClearDelegation, +} + +fn create_rbac_account_with_admin(admin: AccountId) -> anyhow::Result { + let account = AccountBuilder::new([9; 32]) + .account_type(AccountType::Public) + .with_auth_component(Auth::IncrNonce) + .with_components(AccessControl::Rbac { admin, roles: BTreeMap::new() }) + .build_existing()?; + + Ok(account) +} + +fn create_rbac_chain(admin: AccountId) -> anyhow::Result<(Account, MockChain)> { + let account = create_rbac_account_with_admin(admin)?; + let mut builder = MockChain::builder(); + builder.add_account(account.clone())?; + + Ok((account, builder.build()?)) +} + +fn test_account_id(seed: u8) -> AccountId { + AccountId::builder() + .account_type(AccountType::Private) + .build_with_seed([seed; 32]) +} + +fn role(name: &str) -> RoleSymbol { + RoleSymbol::new(name).expect("role symbol should be valid") +} + +fn role_config_key(role: &RoleSymbol) -> StorageMapKey { + StorageMapKey::from_raw(Word::from([Felt::ZERO, Felt::ZERO, Felt::ZERO, Felt::from(role)])) +} + +fn role_membership_key(role: &RoleSymbol, account_id: AccountId) -> StorageMapKey { + StorageMapKey::from_raw(Word::from([ + Felt::ZERO, + Felt::from(role), + account_id.suffix(), + account_id.prefix().as_felt(), + ])) +} + +/// Returns the role's `(member_count, admin_role_symbol)` from on-chain storage. +fn get_role_config(account: &Account, role: &RoleSymbol) -> anyhow::Result<(Felt, Felt)> { + let word = account + .storage() + .get_map_item(RoleBasedAccessControl::role_config_slot(), role_config_key(role))?; + Ok((word[0], word[1])) +} + +fn is_role_member( + account: &Account, + role: &RoleSymbol, + account_id: AccountId, +) -> anyhow::Result { + let word = account.storage().get_map_item( + RoleBasedAccessControl::role_membership_slot(), + role_membership_key(role, account_id), + )?; + Ok(word[0].as_canonical_u64() != 0) +} + +fn build_note(sender: AccountId, code: impl Into) -> anyhow::Result { + let seed: [u64; 4] = rand::random(); + let mut rng = RandomCoin::new(Word::from(seed.map(Felt::new_unchecked))); + Ok(NoteBuilder::new(sender, &mut rng) + .note_type(NoteType::Private) + .code(code.into()) + .build()?) +} + +async fn execute_note_and_apply( + mock_chain: &MockChain, + account: &Account, + note: &Note, +) -> anyhow::Result { + let tx = mock_chain + .build_tx_context(account.clone(), &[], slice::from_ref(note))? + .build()?; + let executed = tx.execute().await?; + + let mut updated = account.clone(); + updated.apply_patch(executed.account_patch())?; + + Ok(updated) +} + +// SCRIPTS +// ================================================================================================ + +fn set_role_admin_script(role: &RoleSymbol, admin_role: Option<&RoleSymbol>) -> String { + let admin_role = admin_role.map(Felt::from).unwrap_or(Felt::ZERO); + format!( + r#" + use miden::standards::access::rbac + + @note_script + pub proc main + repeat.14 push.0 end + push.{admin_role} + push.{role} + call.rbac::set_role_admin + dropw dropw dropw dropw + end + "#, + role = Felt::from(role), + ) +} + +fn grant_role_script(role: &RoleSymbol, account_id: AccountId) -> String { + format!( + r#" + use miden::standards::access::rbac + + @note_script + pub proc main + repeat.13 push.0 end + push.{account_prefix} + push.{account_suffix} + push.{role} + call.rbac::grant_role + dropw dropw dropw dropw + end + "#, + account_prefix = account_id.prefix().as_felt(), + account_suffix = account_id.suffix(), + role = Felt::from(role), + ) +} + +fn revoke_role_script(role: &RoleSymbol, account_id: AccountId) -> String { + format!( + r#" + use miden::standards::access::rbac + + @note_script + pub proc main + repeat.13 push.0 end + push.{account_prefix} + push.{account_suffix} + push.{role} + call.rbac::revoke_role + dropw dropw dropw dropw + end + "#, + account_prefix = account_id.prefix().as_felt(), + account_suffix = account_id.suffix(), + role = Felt::from(role), + ) +} + +fn renounce_role_script(role: &RoleSymbol) -> String { + format!( + r#" + use miden::standards::access::rbac + + @note_script + pub proc main + repeat.15 push.0 end + push.{role} + call.rbac::renounce_role + dropw dropw dropw dropw + end + "#, + role = Felt::from(role), + ) +} + +fn assert_role_member_count_script(role: &RoleSymbol, expected_count: u64) -> String { + format!( + r#" + use miden::standards::access::rbac + + @note_script + pub proc main + repeat.15 push.0 end + push.{role} + call.rbac::get_role_member_count + eq.{expected_count} assert.err="role member count mismatch" + dropw dropw dropw + drop drop drop + end + "#, + role = Felt::from(role), + ) +} + +fn assert_role_has_members_script(role: &RoleSymbol, expected_has_members: bool) -> String { + let expected_has_members = u8::from(expected_has_members); + + format!( + r#" + use miden::standards::access::rbac + + @note_script + pub proc main + repeat.15 push.0 end + push.{role} + call.rbac::get_role_member_count + neq.0 + eq.{expected_has_members} assert.err="role population mismatch" + dropw dropw dropw + drop drop drop + end + "#, + role = Felt::from(role), + ) +} + +fn assert_role_admin_script(role: &RoleSymbol, expected_admin_role: Option<&RoleSymbol>) -> String { + let expected_admin_role = expected_admin_role.map(Felt::from).unwrap_or(Felt::ZERO); + + format!( + r#" + use miden::standards::access::rbac + + @note_script + pub proc main + repeat.15 push.0 end + push.{role} + call.rbac::get_role_admin + eq.{expected_admin_role} assert.err="role admin mismatch" + dropw dropw dropw + drop drop drop + end + "#, + role = Felt::from(role), + ) +} + +fn assert_has_role_script( + role: &RoleSymbol, + account_id: AccountId, + expected_has_role: bool, +) -> String { + let expected_has_role = u8::from(expected_has_role); + + format!( + r#" + use miden::standards::access::rbac + + @note_script + pub proc main + repeat.13 push.0 end + push.{account_prefix} + push.{account_suffix} + push.{role} + call.rbac::has_role + eq.{expected_has_role} assert.err="account role membership mismatch" + dropw dropw dropw + drop drop drop + end + "#, + account_prefix = account_id.prefix().as_felt(), + account_suffix = account_id.suffix(), + role = Felt::from(role), + ) +} + +fn set_role_admin_raw_script(role: Felt, admin_role: Felt) -> String { + format!( + r#" + use miden::standards::access::rbac + + @note_script + pub proc main + repeat.14 push.0 end + push.{admin_role} + push.{role} + call.rbac::set_role_admin + dropw dropw dropw dropw + end + "#, + ) +} From 164a4653b3b846011f2f9eb534ea63b62d2cd076 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 9 Jul 2026 16:33:56 +0300 Subject: [PATCH 14/16] refactor: rename FAUCET_ADMIN to FAUCET_MNGR and disambiguate role maps Addresses two review comments: 1. Rename the non-admin faucet role FAUCET_ADMIN -> FAUCET_MNGR (symbol) / faucet_manager (Rust identifiers). 'Admin' was misleading for an operational role administered by ADMIN; an admin variation would have read as FAUCET_ADMIN_ADMIN. Also renames the outdated bridge_admin test wallets/comments to faucet_manager (they hold the faucet role, not ADMIN). 2. Disambiguate the two RBAC maps that were both spelled 'roles': - role-members map: RoleBasedAccessControl::with_roles -> with_role_members (field initial_roles -> initial_role_members). - procedure-roles map: the 'roles' field of Authority::RbacControlled and AccessControl::Rbac -> procedure_roles. --- CHANGELOG.md | 2 +- bin/bench-transaction/src/context_setups.rs | 16 ++-- crates/miden-agglayer/SPEC.md | 42 +++++----- crates/miden-agglayer/build.rs | 2 +- crates/miden-agglayer/src/bridge.rs | 28 +++---- crates/miden-agglayer/src/deregister_note.rs | 3 +- crates/miden-agglayer/src/lib.rs | 8 +- crates/miden-agglayer/src/testing/mod.rs | 12 +-- .../src/account/access/authority.rs | 23 +++--- .../miden-standards/src/account/access/mod.rs | 23 +++--- .../src/account/access/rbac.rs | 23 +++--- .../miden-testing/tests/agglayer/bridge_in.rs | 82 ++++++++++--------- .../tests/agglayer/bridge_out.rs | 52 ++++++------ .../tests/agglayer/config_bridge.rs | 62 +++++++------- .../tests/agglayer/faucet_helpers.rs | 4 +- .../agglayer/network_account_regression.rs | 20 ++--- .../tests/agglayer/remove_ger.rs | 6 +- .../tests/agglayer/update_ger.rs | 16 ++-- .../miden-testing/tests/scripts/authority.rs | 4 +- .../miden-testing/tests/scripts/pausable.rs | 6 +- crates/miden-testing/tests/scripts/rbac.rs | 10 +-- 21 files changed, 227 insertions(+), 217 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fafc2f297..016de29170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ### Changes -- [BREAKING] Replaced the AggLayer bridge's hard-coded admin/injector/remover account-ID authorization with an RBAC access-control stack (`RoleBasedAccessControl` + `Authority`); `create_bridge_account` now takes `(seed, admin, BridgeRoles)` where `admin` seeds the built-in `ADMIN` role that administers the operational `FAUCET_ADMIN` / `GER_INJECTOR` / `GER_REMOVER` roles, `AggLayerBridge` is now a stateless component, and `RoleBasedAccessControl::with_roles` seeds the initial `ADMIN` and operational-role holders at genesis ([#3130](https://github.com/0xMiden/protocol/pull/3130)). +- [BREAKING] Replaced the AggLayer bridge's hard-coded admin/injector/remover account-ID authorization with an RBAC access-control stack (`RoleBasedAccessControl` + `Authority`); `create_bridge_account` now takes `(seed, admin, BridgeRoles)` where `admin` seeds the built-in `ADMIN` role that administers the operational `FAUCET_MNGR` / `GER_INJECTOR` / `GER_REMOVER` roles, `AggLayerBridge` is now a stateless component, and `RoleBasedAccessControl::with_roles` seeds the initial `ADMIN` and operational-role holders at genesis ([#3130](https://github.com/0xMiden/protocol/pull/3130)). - [BREAKING] Unified the MINT and BURN note scripts to serve both fungible and non-fungible faucets: the single `mint` / `burn` note now detects the faucet kind by reflection (the `CodeInspection` component's `has_procedure`, which the fungible and non-fungible faucet components now expose) and calls the matching `mint_and_send` / `receive_and_burn`. Removed the `mint_nft` / `burn_nft` note scripts and the `NonFungibleMintNote` / `NonFungibleBurnNote` / `NonFungibleMintNoteStorage` types; `MintNote` / `BurnNote` and `MintNoteStorage` (with fungible and non-fungible variants) now cover both faucet kinds ([#3222](https://github.com/0xMiden/protocol/pull/3222)). - [BREAKING] Renamed the `miden::standards::metadata` module to `miden::standards::inspection` (in MASM, the `miden-standards` account components, and the `miden_standards::account::inspection` Rust module), scoping it as the home of `CodeInspection`, the storage schema, and future inspection components ([#3222](https://github.com/0xMiden/protocol/pull/3222)). - Added `NonFungibleFaucet::asset_status` API and `AssetStatus` enum (`NotIssued` / `Issued` / `Burned`) for querying a commitment's issuance status from account storage, mirroring the on-chain `get_asset_status` procedure ([#3222](https://github.com/0xMiden/protocol/pull/3222)). diff --git a/bin/bench-transaction/src/context_setups.rs b/bin/bench-transaction/src/context_setups.rs index d68ca280ab..85651abc69 100644 --- a/bin/bench-transaction/src/context_setups.rs +++ b/bin/bench-transaction/src/context_setups.rs @@ -183,8 +183,8 @@ fn tx_consume_two_p2id_notes_with_auth(auth_scheme: AuthScheme) -> Result Result { let mut builder = MockChain::builder(); - // CREATE BRIDGE ADMIN ACCOUNT (sends CONFIG_AGG_BRIDGE notes) - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + // CREATE FAUCET MANAGER ACCOUNT (sends CONFIG_AGG_BRIDGE notes) + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -202,7 +202,7 @@ pub async fn tx_consume_claim_note(data_source: ClaimDataSource) -> Result Result) -> Result) -> Result) -> Result [1, 0, 0, 0]` into the @@ -304,9 +304,9 @@ written, so a `token_registry` key never outlives the registration that created | **Inputs** | `[faucet_id_suffix, faucet_id_prefix, pad(14)]` | | **Outputs** | `[pad(16)]` | | **Context** | Consuming a `DEREGISTER_AGG_FAUCET` note on the bridge account | -| **Panics** | Note sender does not hold the `FAUCET_ADMIN` role; faucet is not currently registered | +| **Panics** | Note sender does not hold the `FAUCET_MNGR` role; faucet is not currently registered | -Asserts the note sender holds the `FAUCET_ADMIN` role and the faucet is currently registered (via +Asserts the note sender holds the `FAUCET_MNGR` role and the faucet is currently registered (via `assert_faucet_registered`), then clears all of the faucet's entries: 1. `faucet_registry_map`: `[0, 0, faucet_id_suffix, faucet_id_prefix] -> [0, 0, 0, 0]`. @@ -316,7 +316,7 @@ Asserts the note sender holds the `FAUCET_ADMIN` role and the faucet is currentl matches the faucet's current registration. 3. `faucet_metadata_map`: clears all four sub-keys (origin address, network, scale, metadata hash). -After deregistration, in-flight B2AGG / CLAIM notes referencing the faucet fail, so a `FAUCET_ADMIN` +After deregistration, in-flight B2AGG / CLAIM notes referencing the faucet fail, so a `FAUCET_MNGR` role holder should warn users with notes in flight. As defense-in-depth, `claim` and `bridge_out` also re-check `assert_faucet_registered` after the token lookup, so a faucet can never mint or unlock through a `token_registry` entry once it is deregistered. @@ -399,7 +399,7 @@ documented in `miden-standards`, rather than in dedicated bridge slots. See [Administration](#25-administration). Initial state: all map slots empty, all value slots `[0, 0, 0, 0]`. The initial `ADMIN` member and -the initial `FAUCET_ADMIN` / `GER_INJECTOR` / `GER_REMOVER` role holders are seeded into the +the initial `FAUCET_MNGR` / `GER_INJECTOR` / `GER_REMOVER` role holders are seeded into the access-control components at account creation time. ### 3.2 Faucet Account Component @@ -654,7 +654,7 @@ The storage is divided into three logical regions: proof data (felts 0-535), lea | Field | Value | |-------|-------| -| `sender` | Holder of the `FAUCET_ADMIN` role (sender authorization enforced by the bridge's `register_faucet` procedure) | +| `sender` | Holder of the `FAUCET_MNGR` role (sender authorization enforced by the bridge's `register_faucet` procedure) | | `note_type` | `NoteType::Public` | | `tag` | `NoteTag::default()` | | `attachment` | `NetworkAccountTarget` -- target is the bridge account; execution hint: Always | @@ -682,14 +682,14 @@ The storage is divided into three logical regions: proof data (felts 0-535), lea **Consumption:** Script validates attachment target, loads storage, and calls `bridge_config::register_faucet` (which asserts that the -sender holds the `FAUCET_ADMIN` role and performs two-step registration into +sender holds the `FAUCET_MNGR` role and performs two-step registration into `faucet_registry_map` and `token_registry_map`). #### Permissions | Role | Enforcement | |------|------------| -| **Issuer** | Holders of the `FAUCET_ADMIN` role only -- **enforced** by `bridge_config::register_faucet` | +| **Issuer** | Holders of the `FAUCET_MNGR` role only -- **enforced** by `bridge_config::register_faucet` | | **Consumer** | Bridge account -- **enforced** via `NetworkAccountTarget` attachment | ### 4.4 DEREGISTER_AGG_FAUCET @@ -702,7 +702,7 @@ sender holds the `FAUCET_ADMIN` role and performs two-step registration into | Field | Value | |-------|-------| -| `sender` | Holder of the `FAUCET_ADMIN` role (sender authorization enforced by the bridge's `deregister_faucet` procedure) | +| `sender` | Holder of the `FAUCET_MNGR` role (sender authorization enforced by the bridge's `deregister_faucet` procedure) | | `note_type` | `NoteType::Public` | | `tag` | `NoteTag::default()` | | `attachment` | `NetworkAccountTarget` -- target is the bridge account; execution hint: Always | @@ -730,20 +730,20 @@ The origin token address and origin network are not carried by the note; the bri back from its own `faucet_metadata_map` when clearing the token registry. **Consumption:** Script validates attachment target, loads storage, and calls -`bridge_config::deregister_faucet` (which asserts the sender holds the `FAUCET_ADMIN` role, asserts +`bridge_config::deregister_faucet` (which asserts the sender holds the `FAUCET_MNGR` role, asserts the faucet is currently registered, and clears the `faucet_registry_map`, `token_registry_map`, and `faucet_metadata_map` entries). After consumption, in-flight B2AGG / CLAIM notes referencing the deregistered faucet will fail their `assert_faucet_registered` / `lookup_faucet_by_token_address` -checks. A `FAUCET_ADMIN` role holder should drain or otherwise warn users about pending +checks. A `FAUCET_MNGR` role holder should drain or otherwise warn users about pending notes before sending a `DEREGISTER_AGG_FAUCET`. #### Permissions | Role | Enforcement | |------|------------| -| **Issuer** | Holders of the `FAUCET_ADMIN` role only -- **enforced** by `bridge_config::deregister_faucet` procedure | +| **Issuer** | Holders of the `FAUCET_MNGR` role only -- **enforced** by `bridge_config::deregister_faucet` procedure | | **Consumer** | Bridge account -- **enforced** via `NetworkAccountTarget` attachment | ### 4.5 UPDATE_GER @@ -1280,7 +1280,7 @@ token metadata — symbol, decimals, max supply, and token supply Conversion metadata (origin address, origin network, scale, and metadata hash) is *not* stored on the faucet; it is carried by the `CONFIG_AGG_BRIDGE` note at registration time and written directly into the bridge's `faucet_metadata_map`. The metadata hash is -precomputed by the `FAUCET_ADMIN` role holder and is currently not verified onchain +precomputed by the `FAUCET_MNGR` role holder and is currently not verified onchain (TODO Verify metadata hash onchain ([#2586](https://github.com/0xMiden/protocol/issues/2586))). Registration is performed via [`CONFIG_AGG_BRIDGE`](#43-config_agg_bridge) notes. The bridge @@ -1306,11 +1306,11 @@ data and calls `bridge_config::lookup_faucet_by_token_address` to find the regis faucet. If the `(origin_token_address, origin_network)` pair is not registered, the `CLAIM` note consumption will fail. -The `FAUCET_ADMIN` role holder is trusted, and is the sole entity that can register faucets on +The `FAUCET_MNGR` role holder is trusted, and is the sole entity that can register faucets on the Miden side (enforced by the caller restriction on [`bridge_config::register_faucet`](#bridge_configregister_faucet)). -A `FAUCET_ADMIN` role holder can also revoke a faucet's authorization via a +A `FAUCET_MNGR` role holder can also revoke a faucet's authorization via a [`DEREGISTER_AGG_FAUCET`](#44-deregister_agg_faucet) note (see [Section 4.4](#44-deregister_agg_faucet)), which retires compromised, broken, or deprecated faucets without redeploying the bridge. @@ -1333,8 +1333,8 @@ operations against them — *not* how they are registered. `origin_token_address` is the faucet's own `AccountId` in the [Embedded Format](#62-embedded-format), and `origin_network` is Miden's own network ID. -In both cases the `FAUCET_ADMIN` role holder drives registration via the same `CONFIG_AGG_BRIDGE` note; -the `FAUCET_ADMIN` role holder is responsible for setting `is_native` correctly for the faucet at hand. +In both cases the `FAUCET_MNGR` role holder drives registration via the same `CONFIG_AGG_BRIDGE` note; +the `FAUCET_MNGR` role holder is responsible for setting `is_native` correctly for the faucet at hand. ### 7.2 Bridging-out: How tokens are registered on other chains diff --git a/crates/miden-agglayer/build.rs b/crates/miden-agglayer/build.rs index fad0e65bd4..3a8667d4e4 100644 --- a/crates/miden-agglayer/build.rs +++ b/crates/miden-agglayer/build.rs @@ -542,7 +542,7 @@ fn generate_agglayer_constants( // commitment. components.extend(AccessControl::Rbac { admin: dummy_owner, - roles: BTreeMap::new(), + procedure_roles: BTreeMap::new(), }); } else if lib_name == "faucet" { components.push(AccountComponent::from( diff --git a/crates/miden-agglayer/src/bridge.rs b/crates/miden-agglayer/src/bridge.rs index 21d5b332ef..93f9219c8a 100644 --- a/crates/miden-agglayer/src/bridge.rs +++ b/crates/miden-agglayer/src/bridge.rs @@ -124,8 +124,8 @@ static LET_NUM_LEAVES_SLOT_NAME: LazyLock = LazyLock::new(|| { // BRIDGE RBAC ROLES // ================================================================================================ -static FAUCET_ADMIN_ROLE: LazyLock = LazyLock::new(|| { - RoleSymbol::new("FAUCET_ADMIN").expect("FAUCET_ADMIN role symbol should be valid") +static FAUCET_MANAGER_ROLE: LazyLock = LazyLock::new(|| { + RoleSymbol::new("FAUCET_MNGR").expect("FAUCET_MNGR role symbol should be valid") }); static GER_INJECTOR_ROLE: LazyLock = LazyLock::new(|| { RoleSymbol::new("GER_INJECTOR").expect("GER_INJECTOR role symbol should be valid") @@ -174,12 +174,12 @@ procedure_root!( /// /// Used to seed the bridge account's RBAC role membership at creation. Each role gates a distinct /// set of bridge procedures: -/// - `FAUCET_ADMIN` gates `register_faucet` and `store_faucet_metadata_hash`. +/// - `FAUCET_MNGR` gates `register_faucet` and `store_faucet_metadata_hash`. /// - `GER_INJECTOR` gates `update_ger`. /// - `GER_REMOVER` gates `remove_ger`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BridgeRoles { - faucet_admins: BTreeSet, + faucet_managers: BTreeSet, ger_injectors: BTreeSet, ger_removers: BTreeSet, } @@ -192,12 +192,12 @@ impl BridgeRoles { /// Returns [`AgglayerBridgeError::EmptyBridgeRole`] if any of the three roles is given an empty /// set of holders. pub fn new( - faucet_admins: BTreeSet, + faucet_managers: BTreeSet, ger_injectors: BTreeSet, ger_removers: BTreeSet, ) -> Result { for (role, members) in [ - (AggLayerBridge::faucet_admin_role(), &faucet_admins), + (AggLayerBridge::faucet_manager_role(), &faucet_managers), (AggLayerBridge::ger_injector_role(), &ger_injectors), (AggLayerBridge::ger_remover_role(), &ger_removers), ] { @@ -207,7 +207,7 @@ impl BridgeRoles { } Ok(Self { - faucet_admins, + faucet_managers, ger_injectors, ger_removers, }) @@ -216,7 +216,7 @@ impl BridgeRoles { /// Returns the RBAC role-membership map used to seed the account's RBAC component. pub(crate) fn role_members(&self) -> BTreeMap> { BTreeMap::from([ - (AggLayerBridge::faucet_admin_role(), self.faucet_admins.clone()), + (AggLayerBridge::faucet_manager_role(), self.faucet_managers.clone()), (AggLayerBridge::ger_injector_role(), self.ger_injectors.clone()), (AggLayerBridge::ger_remover_role(), self.ger_removers.clone()), ]) @@ -300,10 +300,10 @@ impl AggLayerBridge { &BRIDGE_COMPONENT_CODE } - /// Returns the `FAUCET_ADMIN` role symbol. Holders may register faucets and store faucet + /// Returns the `FAUCET_MNGR` role symbol. Holders may register faucets and store faucet /// metadata (`register_faucet`, `store_faucet_metadata_hash`). - pub fn faucet_admin_role() -> RoleSymbol { - FAUCET_ADMIN_ROLE.clone() + pub fn faucet_manager_role() -> RoleSymbol { + FAUCET_MANAGER_ROLE.clone() } /// Returns the `GER_INJECTOR` role symbol. Holders may inject GERs (`update_ger`). @@ -346,9 +346,9 @@ impl AggLayerBridge { /// required to invoke it. pub fn procedure_roles() -> BTreeMap { BTreeMap::from([ - (Self::register_faucet_root(), Self::faucet_admin_role()), - (Self::store_faucet_metadata_hash_root(), Self::faucet_admin_role()), - (Self::deregister_faucet_root(), Self::faucet_admin_role()), + (Self::register_faucet_root(), Self::faucet_manager_role()), + (Self::store_faucet_metadata_hash_root(), Self::faucet_manager_role()), + (Self::deregister_faucet_root(), Self::faucet_manager_role()), (Self::update_ger_root(), Self::ger_injector_role()), (Self::remove_ger_root(), Self::ger_remover_role()), ]) diff --git a/crates/miden-agglayer/src/deregister_note.rs b/crates/miden-agglayer/src/deregister_note.rs index 6227c440bb..8fe269a91e 100644 --- a/crates/miden-agglayer/src/deregister_note.rs +++ b/crates/miden-agglayer/src/deregister_note.rs @@ -87,8 +87,7 @@ impl DeregisterAggFaucetNote { /// /// # Parameters /// - `faucet_account_id`: The account ID of the faucet to deregister - /// - `sender_account_id`: The account ID of the note creator (must hold the `FAUCET_ADMIN` - /// role) + /// - `sender_account_id`: The account ID of the note creator (must hold the `FAUCET_MNGR` role) /// - `target_account_id`: The bridge account ID that will consume this note /// - `rng`: Random number generator for creating the note serial number /// diff --git a/crates/miden-agglayer/src/lib.rs b/crates/miden-agglayer/src/lib.rs index bb16f965ff..f2eb14a306 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -136,7 +136,7 @@ fn create_agglayer_faucet_component( /// /// Here `admin` is seeded as the initial member of the built-in `ADMIN` role, which administers the /// operational roles in case they don't have their own administrators, and `roles` seeds the -/// initial holders of the `FAUCET_ADMIN`, `GER_INJECTOR`, and `GER_REMOVER` roles that gate the +/// initial holders of the `FAUCET_MNGR`, `GER_INJECTOR`, and `GER_REMOVER` roles that gate the /// bridge's privileged procedures. /// /// The builder is pre-wired with the [`AuthNetworkAccount`] auth component, initialized with @@ -149,11 +149,13 @@ fn create_bridge_account_builder( Account::builder(seed.into()) .account_type(AccountType::Public) .with_component(AggLayerBridge) - .with_component(RoleBasedAccessControl::with_roles( + .with_component(RoleBasedAccessControl::with_role_members( BTreeSet::from([admin]), roles.role_members(), )) - .with_component(Authority::RbacControlled { roles: AggLayerBridge::procedure_roles() }) + .with_component(Authority::RbacControlled { + procedure_roles: AggLayerBridge::procedure_roles(), + }) .with_auth_component( AuthNetworkAccount::with_allowed_notes(AggLayerBridge::allowed_notes()) .expect("bridge note allowlist is non-empty"), diff --git a/crates/miden-agglayer/src/testing/mod.rs b/crates/miden-agglayer/src/testing/mod.rs index 58bfd7a98a..880b2c1676 100644 --- a/crates/miden-agglayer/src/testing/mod.rs +++ b/crates/miden-agglayer/src/testing/mod.rs @@ -36,25 +36,25 @@ use crate::{ // BRIDGE ACCOUNT HELPERS // ================================================================================================ -/// Creates an existing bridge account seeded with a single holder per operational role and the -/// fixed dummy acconunt as the built-in `ADMIN` role member. +/// Creates an existing bridge account seeded with a single holder per operational role and a +/// fixed dummy account as the built-in `ADMIN` role member. pub fn create_existing_bridge_account_with_roles( seed: Word, - faucet_admin: AccountId, + faucet_manager: AccountId, ger_injector: AccountId, ger_remover: AccountId, ) -> Account { let roles = BridgeRoles::new( - BTreeSet::from([faucet_admin]), + BTreeSet::from([faucet_manager]), BTreeSet::from([ger_injector]), BTreeSet::from([ger_remover]), ) .expect("single-holder role sets are non-empty"); - let bridge_admin_account = + let admin_account = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap(); - create_bridge_account_builder(seed, bridge_admin_account, roles) + create_bridge_account_builder(seed, admin_account, roles) .build_existing() .expect("bridge account should be valid") } diff --git a/crates/miden-standards/src/account/access/authority.rs b/crates/miden-standards/src/account/access/authority.rs index 38e3e4cfc6..a332b080eb 100644 --- a/crates/miden-standards/src/account/access/authority.rs +++ b/crates/miden-standards/src/account/access/authority.rs @@ -83,8 +83,8 @@ const RBAC_CONTROLLED: u8 = 2; /// /// # Per-procedure roles under [`Authority::RbacControlled`] /// -/// Under RBAC, each gated procedure can be assigned its own role via `roles`, keyed by the -/// procedure's [`AccountProcedureRoot`] (e.g. `pause` → `PAUSER`, `unpause` → `UNPAUSER`). At +/// Under RBAC, each gated procedure can be assigned its own role via `procedure_roles`, keyed by +/// the procedure's [`AccountProcedureRoot`] (e.g. `pause` → `PAUSER`, `unpause` → `UNPAUSER`). At /// runtime `assert_authorized` identifies the calling procedure via the `caller` instruction and /// looks up its role. A procedure without a mapping falls back to the `ADMIN` role check. /// @@ -116,12 +116,13 @@ pub enum Authority { OwnerControlled = OWNER_CONTROLLED, /// Authority is membership in an RBAC role, resolved per gated procedure. /// - /// `roles` maps a gated procedure's [`AccountProcedureRoot`] to the role required to invoke it. - /// Requires the [`RoleBasedAccessControl`][crate::account::access::RoleBasedAccessControl] - /// component to be installed on the account. the MASM helper calls into - /// `rbac::assert_sender_has_role` and will fail to link otherwise. + /// `procedure_roles` maps a gated procedure's [`AccountProcedureRoot`] to the role required to + /// invoke it. Requires the + /// [`RoleBasedAccessControl`][crate::account::access::RoleBasedAccessControl] component to be + /// installed on the account. the MASM helper calls into `rbac::assert_sender_has_role` and will + /// fail to link otherwise. RbacControlled { - roles: BTreeMap, + procedure_roles: BTreeMap, } = RBAC_CONTROLLED, } @@ -185,8 +186,8 @@ impl Authority { AUTH_CONTROLLED => Ok(Self::AuthControlled), OWNER_CONTROLLED => Ok(Self::OwnerControlled), RBAC_CONTROLLED => { - let roles = Self::read_roles_from_storage(storage)?; - Ok(Self::RbacControlled { roles }) + let procedure_roles = Self::read_roles_from_storage(storage)?; + Ok(Self::RbacControlled { procedure_roles }) }, other => Err(AuthorityError::InvalidAuthority(other.into())), } @@ -306,8 +307,8 @@ impl From for AccountComponent { let mut slots = vec![StorageSlot::with_value(AUTHORITY_SLOT_NAME.clone(), value.to_word())]; - if let Authority::RbacControlled { roles } = value { - let entries = roles.into_iter().map(|(proc_root, role)| { + if let Authority::RbacControlled { procedure_roles } = value { + let entries = procedure_roles.into_iter().map(|(proc_root, role)| { (StorageMapKey::new(proc_root.as_word()), role_value_word(&role)) }); slots.push(StorageSlot::with_map( diff --git a/crates/miden-standards/src/account/access/mod.rs b/crates/miden-standards/src/account/access/mod.rs index 70ff96e129..da0210a2c8 100644 --- a/crates/miden-standards/src/account/access/mod.rs +++ b/crates/miden-standards/src/account/access/mod.rs @@ -22,7 +22,7 @@ pub mod warden; /// - [`AccessControl::Ownable2Step`] → [`Ownable2Step`] + [`Authority::OwnerControlled`]. The /// setter gate enforces `sender == owner`. /// - [`AccessControl::Rbac`] → [`RoleBasedAccessControl`] + [`Authority::RbacControlled`]. The -/// `roles` map assigns a role to individual gated procedures (keyed by procedure root); +/// `procedure_roles` map assigns a role to individual gated procedures (keyed by procedure root); /// procedures without a mapping fall back to the `ADMIN` role check. /// /// Pass to @@ -37,7 +37,7 @@ pub mod warden; /// # let admin: miden_protocol::account::AccountId = unimplemented!(); /// # let init_seed = [0u8; 32]; /// AccountBuilder::new(init_seed) -/// .with_components(AccessControl::Rbac { admin, roles: BTreeMap::new() }); +/// .with_components(AccessControl::Rbac { admin, procedure_roles: BTreeMap::new() }); /// ``` /// /// For accounts that don't use the [`AccessControl`] convenience but want to install the @@ -55,15 +55,16 @@ pub enum AccessControl { /// admin role (its delegated admin, or `ADMIN` by default). See [`RoleBasedAccessControl`] /// for the administration model. /// - /// `roles` assigns a role to individual authority-gated procedures, keyed by procedure root - /// (e.g. `PausableManager::pause_root()` → `PAUSER`, `unpause_root()` → `UNPAUSER`, and - /// optionally `Authority::freeze_root()` → `FREEZER`). A gated procedure without an entry in - /// `roles` falls back to the `ADMIN` role. The emergency `freeze` / `unfreeze` switch resolves - /// its role the same way, defaulting to `ADMIN`. Role membership is managed through the - /// standard RBAC API on the [`RoleBasedAccessControl`] component. + /// `procedure_roles` assigns a role to individual authority-gated procedures, keyed by + /// procedure root (e.g. `PausableManager::pause_root()` → `PAUSER`, `unpause_root()` → + /// `UNPAUSER`, and optionally `Authority::freeze_root()` → `FREEZER`). A gated procedure + /// without an entry in `procedure_roles` falls back to the `ADMIN` role. The emergency + /// `freeze` / `unfreeze` switch resolves its role the same way, defaulting to `ADMIN`. Role + /// membership is managed through the standard RBAC API on the [`RoleBasedAccessControl`] + /// component. Rbac { admin: AccountId, - roles: BTreeMap, + procedure_roles: BTreeMap, }, } @@ -79,9 +80,9 @@ impl IntoIterator for AccessControl { AccessControl::Ownable2Step { owner } => { vec![Ownable2Step::new(owner).into(), Authority::OwnerControlled.into()].into_iter() }, - AccessControl::Rbac { admin, roles } => vec![ + AccessControl::Rbac { admin, procedure_roles } => vec![ RoleBasedAccessControl::new(admin).into(), - Authority::RbacControlled { roles }.into(), + Authority::RbacControlled { procedure_roles }.into(), ] .into_iter(), } diff --git a/crates/miden-standards/src/account/access/rbac.rs b/crates/miden-standards/src/account/access/rbac.rs index 0e5a00b30d..657c7b788b 100644 --- a/crates/miden-standards/src/account/access/rbac.rs +++ b/crates/miden-standards/src/account/access/rbac.rs @@ -130,8 +130,8 @@ pub struct RoleBasedAccessControl { initial_admins: BTreeSet, /// Additional roles seeded at construction, keyed by role symbol. Each seeded role's /// delegated admin is left unset, so it is administered by the `ADMIN` role (see - /// [`with_roles`][Self::with_roles]). - initial_roles: BTreeMap>, + /// [`with_role_members`][Self::with_role_members]). + initial_role_members: BTreeMap>, } impl RoleBasedAccessControl { @@ -167,7 +167,7 @@ impl RoleBasedAccessControl { pub fn new(initial_admin: AccountId) -> Self { Self { initial_admins: BTreeSet::from([initial_admin]), - initial_roles: BTreeMap::new(), + initial_role_members: BTreeMap::new(), } } @@ -179,23 +179,26 @@ impl RoleBasedAccessControl { pub fn with_admins(initial_admins: BTreeSet) -> Self { Self { initial_admins, - initial_roles: BTreeMap::new(), + initial_role_members: BTreeMap::new(), } } /// Returns an RBAC component whose `ADMIN` role is seeded with `initial_admins` and whose - /// additional `roles` are each seeded with their given member set at construction. + /// additional roles are each seeded with the given `role_members` set at construction. /// /// Each seeded role's delegated admin is left unset, so — like any role — it is administered /// by the `ADMIN` role until an admin is delegated via `set_role_admin`. This lets an account /// be created already populated with role holders alongside the bootstrap administrator. A role /// mapped to an empty member set is dropped: a role with no members is a no-op. - pub fn with_roles( + pub fn with_role_members( initial_admins: BTreeSet, - mut roles: BTreeMap>, + mut role_members: BTreeMap>, ) -> Self { - roles.retain(|_, members| !members.is_empty()); - Self { initial_admins, initial_roles: roles } + role_members.retain(|_, members| !members.is_empty()); + Self { + initial_admins, + initial_role_members: role_members, + } } /// Returns the storage slot name for the per-role config map. @@ -252,7 +255,7 @@ impl From for AccountComponent { // role -> members map. Each seeded role (including ADMIN) leaves its delegated admin unset // (0), so ADMIN administers it. Members provided for the `ADMIN` role are merged with // `initial_admins`. - let mut roles = rbac.initial_roles; + let mut roles = rbac.initial_role_members; if !rbac.initial_admins.is_empty() { roles .entry(RoleBasedAccessControl::admin_role()) diff --git a/crates/miden-testing/tests/agglayer/bridge_in.rs b/crates/miden-testing/tests/agglayer/bridge_in.rs index f0210ca310..0288108126 100644 --- a/crates/miden-testing/tests/agglayer/bridge_in.rs +++ b/crates/miden-testing/tests/agglayer/bridge_in.rs @@ -135,9 +135,9 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a let mut builder = MockChain::builder(); - // CREATE BRIDGE ADMIN ACCOUNT (sends CONFIG_AGG_BRIDGE notes) + // CREATE FAUCET MANAGER ACCOUNT (sends CONFIG_AGG_BRIDGE notes) // -------------------------------------------------------------------------------------------- - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -158,7 +158,7 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a let bridge_seed = builder.rng_mut().draw_word(); let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -251,7 +251,7 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a is_native: false, metadata_hash, }, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; @@ -440,7 +440,7 @@ async fn test_mint_cannot_be_consumed_by_unrelated_faucet() -> anyhow::Result<() let data_source = ClaimDataSource::L1ToMiden; let mut builder = MockChain::builder(); - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { @@ -453,7 +453,7 @@ async fn test_mint_cannot_be_consumed_by_unrelated_faucet() -> anyhow::Result<() let bridge_seed = builder.rng_mut().draw_word(); let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -542,7 +542,7 @@ async fn test_mint_cannot_be_consumed_by_unrelated_faucet() -> anyhow::Result<() is_native: false, metadata_hash: leaf_data.metadata_hash, }, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; @@ -559,7 +559,7 @@ async fn test_mint_cannot_be_consumed_by_unrelated_faucet() -> anyhow::Result<() is_native: false, metadata_hash: leaf_data.metadata_hash, }, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; @@ -632,9 +632,9 @@ async fn test_claim_rejects_wrong_destination_network() -> anyhow::Result<()> { let data_source = ClaimDataSource::L1ToMiden; let mut builder = MockChain::builder(); - // CREATE BRIDGE ADMIN ACCOUNT (sends CONFIG_AGG_BRIDGE notes) + // CREATE FAUCET MANAGER ACCOUNT (sends CONFIG_AGG_BRIDGE notes) // -------------------------------------------------------------------------------------------- - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -655,7 +655,7 @@ async fn test_claim_rejects_wrong_destination_network() -> anyhow::Result<()> { let bridge_seed = builder.rng_mut().draw_word(); let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -708,7 +708,7 @@ async fn test_claim_rejects_wrong_destination_network() -> anyhow::Result<()> { miden_claim_amount, }, bridge_account.id(), - bridge_admin.id(), + faucet_manager.id(), builder.rng_mut(), )?; builder.add_output_note(RawOutputNote::Full(claim_note.clone())); @@ -724,7 +724,7 @@ async fn test_claim_rejects_wrong_destination_network() -> anyhow::Result<()> { is_native: false, metadata_hash, }, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; @@ -782,8 +782,8 @@ async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { let data_source = ClaimDataSource::L1ToMiden; let mut builder = MockChain::builder(); - // CREATE BRIDGE ADMIN ACCOUNT - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + // CREATE FAUCET MANAGER ACCOUNT + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -801,7 +801,7 @@ async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { let bridge_seed = builder.rng_mut().draw_word(); let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -846,7 +846,7 @@ async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { let claim_note_1 = ClaimNote::create( claim_inputs_1, bridge_account.id(), - bridge_admin.id(), + faucet_manager.id(), builder.rng_mut(), )?; builder.add_output_note(RawOutputNote::Full(claim_note_1.clone())); @@ -861,7 +861,7 @@ async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { let claim_note_2 = ClaimNote::create( claim_inputs_2, bridge_account.id(), - bridge_admin.id(), + faucet_manager.id(), builder.rng_mut(), )?; builder.add_output_note(RawOutputNote::Full(claim_note_2.clone())); @@ -876,7 +876,7 @@ async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { is_native: false, metadata_hash: leaf_data.metadata_hash, }, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; @@ -949,8 +949,8 @@ async fn test_claim_rejects_removed_ger() -> anyhow::Result<()> { let data_source = ClaimDataSource::L1ToMiden; let mut builder = MockChain::builder(); - // CREATE BRIDGE ADMIN ACCOUNT - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + // CREATE FAUCET MANAGER ACCOUNT + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -968,7 +968,7 @@ async fn test_claim_rejects_removed_ger() -> anyhow::Result<()> { let bridge_seed = builder.rng_mut().draw_word(); let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -1010,8 +1010,12 @@ async fn test_claim_rejects_removed_ger() -> anyhow::Result<()> { miden_claim_amount, }; - let claim_note = - ClaimNote::create(claim_inputs, bridge_account.id(), bridge_admin.id(), builder.rng_mut())?; + let claim_note = ClaimNote::create( + claim_inputs, + bridge_account.id(), + faucet_manager.id(), + builder.rng_mut(), + )?; builder.add_output_note(RawOutputNote::Full(claim_note.clone())); // CREATE CONFIG_AGG_BRIDGE NOTE @@ -1024,7 +1028,7 @@ async fn test_claim_rejects_removed_ger() -> anyhow::Result<()> { is_native: false, metadata_hash: leaf_data.metadata_hash, }, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; @@ -1102,8 +1106,8 @@ async fn bridge_in_unlock_native_token() -> anyhow::Result<()> { let data_source = ClaimDataSource::L1ToMiden; let mut builder = MockChain::builder(); - // Bridge admin / GER injector / bridge account. - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + // Faucet manager / GER injector / bridge account. + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { @@ -1116,7 +1120,7 @@ async fn bridge_in_unlock_native_token() -> anyhow::Result<()> { })?; let mut bridge_account = create_existing_bridge_account_with_roles( bridge_seed, - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -1186,7 +1190,7 @@ async fn bridge_in_unlock_native_token() -> anyhow::Result<()> { is_native: true, metadata_hash, }, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; @@ -1385,7 +1389,7 @@ async fn bridge_in_unlock_native_duplicate_rejected() -> anyhow::Result<()> { let data_source = ClaimDataSource::L1ToMiden; let mut builder = MockChain::builder(); - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { @@ -1398,7 +1402,7 @@ async fn bridge_in_unlock_native_duplicate_rejected() -> anyhow::Result<()> { })?; let mut bridge_account = create_existing_bridge_account_with_roles( bridge_seed, - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -1458,7 +1462,7 @@ async fn bridge_in_unlock_native_duplicate_rejected() -> anyhow::Result<()> { is_native: true, metadata_hash, }, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; @@ -1637,7 +1641,7 @@ async fn test_claim_fails_when_origin_network_unregistered() -> anyhow::Result<( let data_source = ClaimDataSource::L1ToMiden; let mut builder = MockChain::builder(); - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -1651,7 +1655,7 @@ async fn test_claim_fails_when_origin_network_unregistered() -> anyhow::Result<( })?; let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -1724,7 +1728,7 @@ async fn test_claim_fails_when_origin_network_unregistered() -> anyhow::Result<( is_native: false, metadata_hash, }, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; @@ -1784,7 +1788,7 @@ async fn test_reregister_clears_prior_token_key() -> anyhow::Result<()> { let data_source = ClaimDataSource::L1ToMiden; let mut builder = MockChain::builder(); - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { @@ -1797,7 +1801,7 @@ async fn test_reregister_clears_prior_token_key() -> anyhow::Result<()> { let bridge_seed = builder.rng_mut().draw_word(); let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -1865,7 +1869,7 @@ async fn test_reregister_clears_prior_token_key() -> anyhow::Result<()> { is_native: false, metadata_hash, }, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; @@ -1882,7 +1886,7 @@ async fn test_reregister_clears_prior_token_key() -> anyhow::Result<()> { is_native: false, metadata_hash, }, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; diff --git a/crates/miden-testing/tests/agglayer/bridge_out.rs b/crates/miden-testing/tests/agglayer/bridge_out.rs index 13fe1a48bd..8741835288 100644 --- a/crates/miden-testing/tests/agglayer/bridge_out.rs +++ b/crates/miden-testing/tests/agglayer/bridge_out.rs @@ -78,8 +78,8 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - // CREATE BRIDGE ADMIN ACCOUNT (sends CONFIG_AGG_BRIDGE notes) - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + // CREATE FAUCET MANAGER ACCOUNT (sends CONFIG_AGG_BRIDGE notes) + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -95,7 +95,7 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { let mut bridge_account = create_existing_bridge_account_with_roles( builder.rng_mut().draw_word(), - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -129,7 +129,7 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { ); builder.add_account(faucet.clone())?; - // CONFIG_AGG_BRIDGE note to register the faucet in the bridge (sent by bridge admin) + // CONFIG_AGG_BRIDGE note to register the faucet in the bridge (sent by faucet manager) let config_note = ConfigAggBridgeNote::create( ConversionMetadata { faucet_account_id: faucet.id(), @@ -139,7 +139,7 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { is_native: false, metadata_hash, }, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; @@ -365,7 +365,7 @@ async fn bridge_out_at_high_num_leaves(#[case] initial_num_leaves: u32) -> anyho let mut builder = MockChain::builder(); - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { @@ -378,7 +378,7 @@ async fn bridge_out_at_high_num_leaves(#[case] initial_num_leaves: u32) -> anyho })?; let mut bridge_account = create_existing_bridge_account_with_roles( bridge_seed, - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -415,7 +415,7 @@ async fn bridge_out_at_high_num_leaves(#[case] initial_num_leaves: u32) -> anyho is_native: false, metadata_hash, }, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; @@ -491,8 +491,8 @@ async fn bridge_out_at_high_num_leaves(#[case] initial_num_leaves: u32) -> anyho async fn test_bridge_out_fails_with_unregistered_faucet() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - // CREATE BRIDGE ADMIN ACCOUNT - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + // CREATE FAUCET MANAGER ACCOUNT + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -510,7 +510,7 @@ async fn test_bridge_out_fails_with_unregistered_faucet() -> anyhow::Result<()> // -------------------------------------------------------------------------------------------- let bridge_account = create_existing_bridge_account_with_roles( builder.rng_mut().draw_word(), - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -588,9 +588,9 @@ async fn test_bridge_out_rejects_invalid_b2agg_note( ) -> anyhow::Result<()> { let mut builder = MockChain::builder(); - // CREATE BRIDGE ADMIN ACCOUNT (sends CONFIG_AGG_BRIDGE notes) + // CREATE FAUCET MANAGER ACCOUNT (sends CONFIG_AGG_BRIDGE notes) // -------------------------------------------------------------------------------------------- - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -608,7 +608,7 @@ async fn test_bridge_out_rejects_invalid_b2agg_note( })?; let mut bridge_account = create_existing_bridge_account_with_roles( bridge_seed, - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -647,7 +647,7 @@ async fn test_bridge_out_rejects_invalid_b2agg_note( is_native: false, metadata_hash, }, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; @@ -760,8 +760,8 @@ async fn b2agg_note_reclaim_scenario() -> anyhow::Result<()> { [], )?; - // Create a bridge admin account - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + // Create a faucet manager account + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -778,7 +778,7 @@ async fn b2agg_note_reclaim_scenario() -> anyhow::Result<()> { // Create a bridge account (includes a `bridge` component) let bridge_account = create_existing_bridge_account_with_roles( builder.rng_mut().draw_word(), - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -884,8 +884,8 @@ async fn b2agg_note_non_target_account_cannot_consume() -> anyhow::Result<()> { [], )?; - // Create a bridge admin account - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + // Create a faucet manager account + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -902,7 +902,7 @@ async fn b2agg_note_non_target_account_cannot_consume() -> anyhow::Result<()> { // Create a bridge account as the designated TARGET for the B2AGG note let bridge_account = create_existing_bridge_account_with_roles( builder.rng_mut().draw_word(), - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -916,7 +916,7 @@ async fn b2agg_note_non_target_account_cannot_consume() -> anyhow::Result<()> { // Create a "malicious" account with a bridge interface let malicious_account = create_existing_bridge_account_with_roles( builder.rng_mut().draw_word(), - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -971,8 +971,8 @@ async fn b2agg_note_non_target_account_cannot_consume() -> anyhow::Result<()> { async fn bridge_out_lock_native_token() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - // Bridge admin / GER injector / bridge account. - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + // Faucet manager / GER injector / bridge account. + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { @@ -985,7 +985,7 @@ async fn bridge_out_lock_native_token() -> anyhow::Result<()> { })?; let mut bridge_account = create_existing_bridge_account_with_roles( bridge_seed, - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -1024,7 +1024,7 @@ async fn bridge_out_lock_native_token() -> anyhow::Result<()> { is_native: true, metadata_hash, }, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; diff --git a/crates/miden-testing/tests/agglayer/config_bridge.rs b/crates/miden-testing/tests/agglayer/config_bridge.rs index c4058e9b46..c2fb70acd9 100644 --- a/crates/miden-testing/tests/agglayer/config_bridge.rs +++ b/crates/miden-testing/tests/agglayer/config_bridge.rs @@ -53,15 +53,15 @@ fn test_bridge_procedure_roles_mapping() { assert_eq!(roles.len(), 5, "exactly the five role-gated procedures must be mapped"); assert_eq!( roles.get(&AggLayerBridge::register_faucet_root()), - Some(&AggLayerBridge::faucet_admin_role()), + Some(&AggLayerBridge::faucet_manager_role()), ); assert_eq!( roles.get(&AggLayerBridge::store_faucet_metadata_hash_root()), - Some(&AggLayerBridge::faucet_admin_role()), + Some(&AggLayerBridge::faucet_manager_role()), ); assert_eq!( roles.get(&AggLayerBridge::deregister_faucet_root()), - Some(&AggLayerBridge::faucet_admin_role()), + Some(&AggLayerBridge::faucet_manager_role()), ); assert_eq!( roles.get(&AggLayerBridge::update_ger_root()), @@ -85,8 +85,8 @@ fn test_bridge_procedure_roles_mapping() { async fn test_config_agg_bridge_registers_faucet() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - // CREATE BRIDGE ADMIN ACCOUNT (note sender) - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + // CREATE FAUCET MANAGER ACCOUNT (note sender) + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -103,7 +103,7 @@ async fn test_config_agg_bridge_registers_faucet() -> anyhow::Result<()> { // CREATE BRIDGE ACCOUNT (starts with empty faucet registry) let bridge_account = create_existing_bridge_account_with_roles( builder.rng_mut().draw_word(), - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -138,7 +138,7 @@ async fn test_config_agg_bridge_registers_faucet() -> anyhow::Result<()> { is_native: false, metadata_hash, }, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; @@ -179,8 +179,8 @@ async fn test_config_agg_bridge_registers_faucet() -> anyhow::Result<()> { async fn test_config_agg_bridge_distinguishes_origin_network() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - // CREATE BRIDGE ADMIN ACCOUNT (note sender) - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + // CREATE FAUCET MANAGER ACCOUNT (note sender) + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -196,7 +196,7 @@ async fn test_config_agg_bridge_distinguishes_origin_network() -> anyhow::Result })?; let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -224,7 +224,7 @@ async fn test_config_agg_bridge_distinguishes_origin_network() -> anyhow::Result is_native: false, metadata_hash, }, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; @@ -237,7 +237,7 @@ async fn test_config_agg_bridge_distinguishes_origin_network() -> anyhow::Result is_native: false, metadata_hash, }, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; @@ -294,13 +294,13 @@ async fn test_config_agg_bridge_distinguishes_origin_network() -> anyhow::Result Ok(()) } -/// A note sender that does not hold the `FAUCET_ADMIN` role cannot register a faucet: +/// A note sender that does not hold the `FAUCET_MNGR` role cannot register a faucet: /// `register_faucet` reverts via the account's `Authority` role check with `ERR_SENDER_LACKS_ROLE`. #[tokio::test] async fn config_agg_bridge_non_admin_sender_reverts() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { @@ -312,7 +312,7 @@ async fn config_agg_bridge_non_admin_sender_reverts() -> anyhow::Result<()> { let bridge_account = create_existing_bridge_account_with_roles( builder.rng_mut().draw_word(), - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -321,7 +321,7 @@ async fn config_agg_bridge_non_admin_sender_reverts() -> anyhow::Result<()> { let faucet_to_register = AccountId::builder().account_type(AccountType::Public).build_with_seed([7; 32]); - // The GER injector (who does not hold the FAUCET_ADMIN role) attempts to send the + // The GER injector (who does not hold the FAUCET_MNGR role) attempts to send the // CONFIG_AGG_BRIDGE note. let config_note = ConfigAggBridgeNote::create( ConversionMetadata { @@ -379,8 +379,8 @@ fn bridge_roles_new_rejects_empty_role() { BTreeSet::from([a]) } }); - let [faucet_admins, ger_injectors, ger_removers] = sets; - let err = BridgeRoles::new(faucet_admins, ger_injectors, ger_removers).unwrap_err(); + let [faucet_managers, ger_injectors, ger_removers] = sets; + let err = BridgeRoles::new(faucet_managers, ger_injectors, ger_removers).unwrap_err(); assert!(matches!(err, AgglayerBridgeError::EmptyBridgeRole(_))); } } @@ -407,7 +407,7 @@ fn faucet_metadata_key(faucet: AccountId, sub_key: u8) -> StorageMapKey { async fn test_deregister_agg_faucet_clears_both_registries() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -420,7 +420,7 @@ async fn test_deregister_agg_faucet_clears_both_registries() -> anyhow::Result<( let bridge_account = create_existing_bridge_account_with_roles( builder.rng_mut().draw_word(), - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -447,13 +447,13 @@ async fn test_deregister_agg_faucet_clears_both_registries() -> anyhow::Result<( is_native: false, metadata_hash, }, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; let deregister_note = DeregisterAggFaucetNote::create( faucet_to_register, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; @@ -549,7 +549,7 @@ async fn test_deregister_agg_faucet_clears_both_registries() -> anyhow::Result<( async fn test_deregister_agg_faucet_clears_native_faucet() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { @@ -561,7 +561,7 @@ async fn test_deregister_agg_faucet_clears_native_faucet() -> anyhow::Result<()> let bridge_account = create_existing_bridge_account_with_roles( builder.rng_mut().draw_word(), - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -587,13 +587,13 @@ async fn test_deregister_agg_faucet_clears_native_faucet() -> anyhow::Result<()> is_native: true, metadata_hash, }, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; let deregister_note = DeregisterAggFaucetNote::create( faucet_to_register, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?; @@ -675,7 +675,7 @@ async fn test_deregister_agg_faucet_rejects_invalid( ) -> anyhow::Result<()> { let mut builder = MockChain::builder(); - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { @@ -690,7 +690,7 @@ async fn test_deregister_agg_faucet_rejects_invalid( let bridge_account = create_existing_bridge_account_with_roles( builder.rng_mut().draw_word(), - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -718,7 +718,7 @@ async fn test_deregister_agg_faucet_rejects_invalid( is_native: false, metadata_hash: MetadataHash::from_token_info("USD Coin", "USDC", 6), }, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?) @@ -730,7 +730,7 @@ async fn test_deregister_agg_faucet_rejects_invalid( let prior_deregister_note = if deregister_first { Some(DeregisterAggFaucetNote::create( faucet_id, - bridge_admin.id(), + faucet_manager.id(), bridge_account.id(), builder.rng_mut(), )?) @@ -741,7 +741,7 @@ async fn test_deregister_agg_faucet_rejects_invalid( let sender = if sender_is_attacker { attacker.id() } else { - bridge_admin.id() + faucet_manager.id() }; let deregister_note = DeregisterAggFaucetNote::create(faucet_id, sender, bridge_account.id(), builder.rng_mut())?; diff --git a/crates/miden-testing/tests/agglayer/faucet_helpers.rs b/crates/miden-testing/tests/agglayer/faucet_helpers.rs index d344089d02..407184421e 100644 --- a/crates/miden-testing/tests/agglayer/faucet_helpers.rs +++ b/crates/miden-testing/tests/agglayer/faucet_helpers.rs @@ -13,7 +13,7 @@ use super::test_utils::create_existing_bridge_account_with_roles; fn test_faucet_helper_methods() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { @@ -25,7 +25,7 @@ fn test_faucet_helper_methods() -> anyhow::Result<()> { let bridge_account = create_existing_bridge_account_with_roles( builder.rng_mut().draw_word(), - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); diff --git a/crates/miden-testing/tests/agglayer/network_account_regression.rs b/crates/miden-testing/tests/agglayer/network_account_regression.rs index 7801dcdacf..d2c9b174f1 100644 --- a/crates/miden-testing/tests/agglayer/network_account_regression.rs +++ b/crates/miden-testing/tests/agglayer/network_account_regression.rs @@ -42,7 +42,7 @@ end async fn bridge_rejects_tx_script() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { @@ -55,7 +55,7 @@ async fn bridge_rejects_tx_script() -> anyhow::Result<()> { })?; let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -94,7 +94,7 @@ async fn bridge_rejects_tx_script() -> anyhow::Result<()> { async fn bridge_rejects_non_allowlisted_input_note() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { @@ -107,13 +107,13 @@ async fn bridge_rejects_non_allowlisted_input_note() -> anyhow::Result<()> { })?; let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); builder.add_account(bridge_account.clone())?; - let attack_note = NoteBuilder::new(bridge_admin.id(), &mut rand::rng()) + let attack_note = NoteBuilder::new(faucet_manager.id(), &mut rand::rng()) .code(ATTACK_NOTE_CODE) .build() .expect("failed to build attack note"); @@ -145,7 +145,7 @@ async fn faucet_rejects_tx_script() -> anyhow::Result<()> { // The bridge_account_id is wired into the faucet at creation time as the registered owner; // we never execute against the bridge in this test, so a placeholder admin wallet is enough. - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -155,7 +155,7 @@ async fn faucet_rejects_tx_script() -> anyhow::Result<()> { 8, Felt::new(1_000_000).unwrap(), Felt::ZERO, - bridge_admin.id(), + faucet_manager.id(), ); builder.add_account(faucet.clone())?; @@ -186,7 +186,7 @@ async fn faucet_rejects_tx_script() -> anyhow::Result<()> { async fn faucet_rejects_non_allowlisted_input_note() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -196,11 +196,11 @@ async fn faucet_rejects_non_allowlisted_input_note() -> anyhow::Result<()> { 8, Felt::new(1_000_000).unwrap(), Felt::ZERO, - bridge_admin.id(), + faucet_manager.id(), ); builder.add_account(faucet.clone())?; - let attack_note = NoteBuilder::new(bridge_admin.id(), &mut rand::rng()) + let attack_note = NoteBuilder::new(faucet_manager.id(), &mut rand::rng()) .code(ATTACK_NOTE_CODE) .build() .expect("failed to build attack note"); diff --git a/crates/miden-testing/tests/agglayer/remove_ger.rs b/crates/miden-testing/tests/agglayer/remove_ger.rs index e44450e99a..c4523df4bd 100644 --- a/crates/miden-testing/tests/agglayer/remove_ger.rs +++ b/crates/miden-testing/tests/agglayer/remove_ger.rs @@ -17,12 +17,12 @@ const GER_BYTES: [u8; 32] = [ 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, ]; -/// Creates the bridge admin, GER injector, and GER remover wallets, builds the bridge account +/// Creates the faucet manager, GER injector, and GER remover wallets, builds the bridge account /// wired to those roles, and registers the bridge account with the builder. /// /// Returns the bridge account together with the GER injector and GER remover wallets. fn setup_bridge(builder: &mut MockChainBuilder) -> anyhow::Result<(Account, Account, Account)> { - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { @@ -35,7 +35,7 @@ fn setup_bridge(builder: &mut MockChainBuilder) -> anyhow::Result<(Account, Acco let bridge_seed = builder.rng_mut().draw_word(); let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); diff --git a/crates/miden-testing/tests/agglayer/update_ger.rs b/crates/miden-testing/tests/agglayer/update_ger.rs index 2cf281f42f..e47838a904 100644 --- a/crates/miden-testing/tests/agglayer/update_ger.rs +++ b/crates/miden-testing/tests/agglayer/update_ger.rs @@ -51,9 +51,9 @@ static EXIT_ROOTS_VECTORS: LazyLock = LazyLock::new(|| { async fn update_ger_note_updates_storage() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - // CREATE BRIDGE ADMIN ACCOUNT (not used in this test, but distinct from GER injector) + // CREATE FAUCET MANAGER ACCOUNT (not used in this test, but distinct from GER injector) // -------------------------------------------------------------------------------------------- - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -74,7 +74,7 @@ async fn update_ger_note_updates_storage() -> anyhow::Result<()> { let bridge_seed = builder.rng_mut().draw_word(); let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -284,8 +284,8 @@ async fn test_compute_ger_basic() -> anyhow::Result<()> { async fn update_ger_rejects_duplicate() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - // CREATE BRIDGE ADMIN ACCOUNT - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + // CREATE FAUCET MANAGER ACCOUNT + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; @@ -303,7 +303,7 @@ async fn update_ger_rejects_duplicate() -> anyhow::Result<()> { let bridge_seed = builder.rng_mut().draw_word(); let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -353,7 +353,7 @@ async fn update_ger_rejects_duplicate() -> anyhow::Result<()> { async fn update_ger_non_injector_sender_reverts() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; let ger_injector = builder.add_existing_wallet(Auth::BasicAuth { @@ -366,7 +366,7 @@ async fn update_ger_non_injector_sender_reverts() -> anyhow::Result<()> { let bridge_seed = builder.rng_mut().draw_word(); let bridge_account = create_existing_bridge_account_with_roles( bridge_seed, - bridge_admin.id(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); diff --git a/crates/miden-testing/tests/scripts/authority.rs b/crates/miden-testing/tests/scripts/authority.rs index e1d8458412..bdd2905ff7 100644 --- a/crates/miden-testing/tests/scripts/authority.rs +++ b/crates/miden-testing/tests/scripts/authority.rs @@ -74,7 +74,7 @@ fn add_owner_faucet( fn add_rbac_faucet( builder: &mut MockChainBuilder, admin: AccountId, - roles: BTreeMap, + procedure_roles: BTreeMap, seed: u8, ) -> anyhow::Result { let faucet = FungibleFaucet::builder() @@ -87,7 +87,7 @@ fn add_rbac_faucet( let account_builder = AccountBuilder::new([seed; 32]) .account_type(AccountType::Public) .with_component(faucet) - .with_components(AccessControl::Rbac { admin, roles }) + .with_components(AccessControl::Rbac { admin, procedure_roles }) .with_component(Pausable::unpaused()) .with_component(PausableManager); diff --git a/crates/miden-testing/tests/scripts/pausable.rs b/crates/miden-testing/tests/scripts/pausable.rs index faedf8c7f3..3a038919f9 100644 --- a/crates/miden-testing/tests/scripts/pausable.rs +++ b/crates/miden-testing/tests/scripts/pausable.rs @@ -290,11 +290,11 @@ fn pause_unpause_roles() -> BTreeMap { } /// Builds an RBAC faucet whose pause / unpause are gated per-procedure. Any authority-gated -/// procedure not present in `roles` falls back to the ADMIN role check. +/// procedure not present in `procedure_roles` falls back to the ADMIN role check. fn add_rbac_faucet_with_pause( builder: &mut MockChainBuilder, admin: AccountId, - roles: BTreeMap, + procedure_roles: BTreeMap, seed: u8, mutable_max_supply: bool, ) -> anyhow::Result { @@ -309,7 +309,7 @@ fn add_rbac_faucet_with_pause( let account_builder = AccountBuilder::new([seed; 32]) .account_type(AccountType::Public) .with_component(faucet) - .with_components(AccessControl::Rbac { admin, roles }) + .with_components(AccessControl::Rbac { admin, procedure_roles }) .with_component(Pausable::unpaused()) .with_component(PausableManager); diff --git a/crates/miden-testing/tests/scripts/rbac.rs b/crates/miden-testing/tests/scripts/rbac.rs index d4b3b96815..6021946f93 100644 --- a/crates/miden-testing/tests/scripts/rbac.rs +++ b/crates/miden-testing/tests/scripts/rbac.rs @@ -611,9 +611,9 @@ async fn test_rbac_admin_can_renounce_admin_role() -> anyhow::Result<()> { Ok(()) } -/// `RoleBasedAccessControl::with_roles` seeds the `ADMIN` role plus arbitrary operator roles at -/// construction. Each seeded operator role's delegated admin is left unset (0), and a role mapped -/// to an empty member set is dropped. +/// `RoleBasedAccessControl::with_role_members` seeds the `ADMIN` role plus arbitrary operator +/// roles at construction. Each seeded operator role's delegated admin is left unset (0), and a role +/// mapped to an empty member set is dropped. #[test] fn test_rbac_with_roles_seeds_admin_and_operator_roles() -> anyhow::Result<()> { let admin = test_account_id(201); @@ -628,7 +628,7 @@ fn test_rbac_with_roles_seeds_admin_and_operator_roles() -> anyhow::Result<()> { let account = AccountBuilder::new([9; 32]) .account_type(AccountType::Public) .with_auth_component(Auth::IncrNonce) - .with_component(RoleBasedAccessControl::with_roles( + .with_component(RoleBasedAccessControl::with_role_members( BTreeSet::from([admin]), BTreeMap::from([ (minter_role.clone(), BTreeSet::from([minter])), @@ -669,7 +669,7 @@ fn create_rbac_account_with_admin(admin: AccountId) -> anyhow::Result { let account = AccountBuilder::new([9; 32]) .account_type(AccountType::Public) .with_auth_component(Auth::IncrNonce) - .with_components(AccessControl::Rbac { admin, roles: BTreeMap::new() }) + .with_components(AccessControl::Rbac { admin, procedure_roles: BTreeMap::new() }) .build_existing()?; Ok(account) From 95c4e04c075560ab433cf3e14fc7ba6e329a7dd8 Mon Sep 17 00:00:00 2001 From: Andrey Khmuro Date: Wed, 15 Jul 2026 20:53:02 +0300 Subject: [PATCH 15/16] refactor: update doc comments, optimize RBAC constructors --- .../asm/agglayer/bridge/bridge_config.masm | 16 ++--- .../asm/note_scripts/config_agg_bridge.masm | 2 +- .../note_scripts/deregister_agg_faucet.masm | 2 +- crates/miden-agglayer/src/lib.rs | 5 +- .../miden-standards/src/account/access/mod.rs | 4 +- .../src/account/access/rbac.rs | 70 +++++++------------ crates/miden-testing/tests/scripts/rbac.rs | 2 +- 7 files changed, 40 insertions(+), 61 deletions(-) diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm index 9aa7c69185..3ad6d460b2 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm @@ -72,7 +72,7 @@ const FAUCET_METADATA_SUBKEY_HASH_HI = 3 # METADATA_HASH_HI[4] #! Outputs: [pad(16)] #! #! Panics if: -#! - the note sender is not the global exit root injector. +#! - the note sender does not hold the global exit root injector role. #! - the GER has already been registered in storage. #! #! Invocation: call @@ -116,7 +116,7 @@ end #! Outputs: [pad(16)] #! #! Panics if: -#! - the note sender is not the global exit root remover. +#! - the note sender does not hold the global exit root remover role. #! - the GER is not currently registered in the bridge's GER map. #! #! Invocation: call @@ -206,13 +206,13 @@ end #! Outputs: [pad(16)] #! #! Panics if: -#! - the note sender does not hold the faucet admin role. +#! - the note sender does not hold the faucet manager role. #! #! Invocation: call @locals(14) @account_procedure pub proc register_faucet - # assert the note sender is authorized for this procedure (holds the faucet admin role). + # assert the note sender is authorized for this procedure (holds the faucet manager role). exec.authority::assert_authorized # => [addr0, addr1, addr2, addr3, addr4, faucet_id_suffix, faucet_id_prefix, scale, origin_network, is_native, pad(6)] @@ -354,12 +354,12 @@ end #! Outputs: [pad(16)] #! #! Panics if: -#! - the note sender does not hold the faucet admin role. +#! - the note sender does not hold the faucet manager role. #! #! Invocation: call @account_procedure pub proc store_faucet_metadata_hash - # assert the note sender is authorized for this procedure (holds the faucet admin role). + # assert the note sender is authorized for this procedure (holds the faucet manager role). exec.authority::assert_authorized # => [faucet_id_suffix, faucet_id_prefix, MH_LO, MH_HI, pad(6)] @@ -395,7 +395,7 @@ end #! Outputs: [pad(16)] #! #! Panics if: -#! - the note sender does not hold the faucet admin role. +#! - the note sender does not hold the faucet manager role. #! - the faucet is not currently registered in the faucet registry. #! #! Locals: @@ -406,7 +406,7 @@ end @locals(2) @account_procedure pub proc deregister_faucet - # assert the note sender is authorized for this procedure (holds the faucet admin role). + # assert the note sender is authorized for this procedure (holds the faucet manager role). exec.authority::assert_authorized # => [faucet_id_suffix, faucet_id_prefix, pad(14)] diff --git a/crates/miden-agglayer/asm/note_scripts/config_agg_bridge.masm b/crates/miden-agglayer/asm/note_scripts/config_agg_bridge.masm index 02817dc308..675c4c7d66 100644 --- a/crates/miden-agglayer/asm/note_scripts/config_agg_bridge.masm +++ b/crates/miden-agglayer/asm/note_scripts/config_agg_bridge.masm @@ -39,7 +39,7 @@ const ERR_CONFIG_AGG_BRIDGE_TARGET_ACCOUNT_MISMATCH = "CONFIG_AGG_BRIDGE note at #! token registry, and faucet metadata map. #! #! This note can only be consumed by the Agglayer Bridge account that is targeted by the note -#! attachment, and only if the note was sent by a holder of the faucet admin role. +#! attachment, and only if the note was sent by a holder of the faucet manager role. #! Upon consumption, it registers the faucet ID, origin token address mapping, scale factor, #! origin network, is_native flag, and metadata hash in the bridge. #! diff --git a/crates/miden-agglayer/asm/note_scripts/deregister_agg_faucet.masm b/crates/miden-agglayer/asm/note_scripts/deregister_agg_faucet.masm index 24060e7291..212e6e0e66 100644 --- a/crates/miden-agglayer/asm/note_scripts/deregister_agg_faucet.masm +++ b/crates/miden-agglayer/asm/note_scripts/deregister_agg_faucet.masm @@ -21,7 +21,7 @@ const ERR_DEREGISTER_AGG_FAUCET_TARGET_ACCOUNT_MISMATCH = "DEREGISTER_AGG_FAUCET #! clearing every registry/metadata entry the bridge holds for it. #! #! This note can only be consumed by the Agglayer Bridge account targeted by the note attachment, -#! and only if it was sent by a holder of the faucet admin role. +#! and only if it was sent by a holder of the faucet manager role. #! #! Requires that the account exposes: #! - agglayer::bridge_config::deregister_faucet procedure. diff --git a/crates/miden-agglayer/src/lib.rs b/crates/miden-agglayer/src/lib.rs index f2eb14a306..77a3494100 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -149,10 +149,7 @@ fn create_bridge_account_builder( Account::builder(seed.into()) .account_type(AccountType::Public) .with_component(AggLayerBridge) - .with_component(RoleBasedAccessControl::with_role_members( - BTreeSet::from([admin]), - roles.role_members(), - )) + .with_component(RoleBasedAccessControl::new(BTreeSet::from([admin]), roles.role_members())) .with_component(Authority::RbacControlled { procedure_roles: AggLayerBridge::procedure_roles(), }) diff --git a/crates/miden-standards/src/account/access/mod.rs b/crates/miden-standards/src/account/access/mod.rs index da0210a2c8..08eacedced 100644 --- a/crates/miden-standards/src/account/access/mod.rs +++ b/crates/miden-standards/src/account/access/mod.rs @@ -1,4 +1,4 @@ -use alloc::collections::BTreeMap; +use alloc::collections::{BTreeMap, BTreeSet}; use alloc::vec; use miden_protocol::Felt; @@ -81,7 +81,7 @@ impl IntoIterator for AccessControl { vec![Ownable2Step::new(owner).into(), Authority::OwnerControlled.into()].into_iter() }, AccessControl::Rbac { admin, procedure_roles } => vec![ - RoleBasedAccessControl::new(admin).into(), + RoleBasedAccessControl::new(BTreeSet::from([admin]), BTreeMap::default()).into(), Authority::RbacControlled { procedure_roles }.into(), ] .into_iter(), diff --git a/crates/miden-standards/src/account/access/rbac.rs b/crates/miden-standards/src/account/access/rbac.rs index 657c7b788b..f4f86a3ba9 100644 --- a/crates/miden-standards/src/account/access/rbac.rs +++ b/crates/miden-standards/src/account/access/rbac.rs @@ -135,6 +135,7 @@ pub struct RoleBasedAccessControl { } impl RoleBasedAccessControl { + /// The name of the component. pub const NAME: &'static str = "miden::standards::components::access::rbac"; /// The built-in default admin role symbol. A role whose delegated admin is unset is @@ -143,45 +144,8 @@ impl RoleBasedAccessControl { /// Keep in sync with the `ADMIN_ROLE` constant in `asm/standards/access/rbac.masm`. pub const ADMIN_ROLE: &'static str = "ADMIN"; - /// Returns the built-in default admin [`RoleSymbol`]. - pub fn admin_role() -> RoleSymbol { - RoleSymbol::new(Self::ADMIN_ROLE).expect("ADMIN is a valid role symbol") - } - - /// Returns the canonical [`AccountComponentName`] of this component. - pub const fn name() -> AccountComponentName { - AccountComponentName::from_static_str(Self::NAME) - } - - /// Returns the [`AccountComponentCode`] of this component. - pub fn code() -> &'static AccountComponentCode { - &RBAC_CODE - } - - /// Returns an RBAC component whose `ADMIN` role is seeded with the given `initial_admin`. - /// - /// The initial admin bootstraps role administration: it can grant every role (including - /// `ADMIN`), configure delegated admins, and later hand off or renounce its own `ADMIN` - /// membership. Additional roles are populated at runtime via the `grant_role`, - /// `set_role_admin`, etc. procedures exposed by the component. - pub fn new(initial_admin: AccountId) -> Self { - Self { - initial_admins: BTreeSet::from([initial_admin]), - initial_role_members: BTreeMap::new(), - } - } - - /// Returns an RBAC component whose `ADMIN` role is seeded with the given `initial_admins`. - /// - /// Passing an empty set produces a component with no initial administrator, which cannot - /// manage any role until `ADMIN` membership is established by other means; prefer - /// [`new`][Self::new] unless that is intended. - pub fn with_admins(initial_admins: BTreeSet) -> Self { - Self { - initial_admins, - initial_role_members: BTreeMap::new(), - } - } + // CONSTRUCTORS + // -------------------------------------------------------------------------------------------- /// Returns an RBAC component whose `ADMIN` role is seeded with `initial_admins` and whose /// additional roles are each seeded with the given `role_members` set at construction. @@ -190,17 +154,34 @@ impl RoleBasedAccessControl { /// by the `ADMIN` role until an admin is delegated via `set_role_admin`. This lets an account /// be created already populated with role holders alongside the bootstrap administrator. A role /// mapped to an empty member set is dropped: a role with no members is a no-op. - pub fn with_role_members( + pub fn new( initial_admins: BTreeSet, - mut role_members: BTreeMap>, + role_members: BTreeMap>, ) -> Self { - role_members.retain(|_, members| !members.is_empty()); Self { initial_admins, initial_role_members: role_members, } } + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns the built-in default admin [`RoleSymbol`]. + pub fn admin_role() -> RoleSymbol { + RoleSymbol::new(Self::ADMIN_ROLE).expect("ADMIN is a valid role symbol") + } + + /// Returns the canonical [`AccountComponentName`] of this component. + pub const fn name() -> AccountComponentName { + AccountComponentName::from_static_str(Self::NAME) + } + + /// Returns the [`AccountComponentCode`] of this component. + pub fn code() -> &'static AccountComponentCode { + &RBAC_CODE + } + /// Returns the storage slot name for the per-role config map. pub fn role_config_slot() -> &'static StorageSlotName { &ROLE_CONFIG_SLOT_NAME @@ -359,7 +340,8 @@ mod tests { // and the member count always matches the number of membership entries. let admins = [test_admin(1), test_admin(2), test_admin(3)]; let component: AccountComponent = - RoleBasedAccessControl::with_admins(admins.iter().copied().collect()).into(); + RoleBasedAccessControl::new(admins.iter().copied().collect(), BTreeMap::default()) + .into(); let admin_symbol = RoleBasedAccessControl::admin_role().as_element(); @@ -391,7 +373,7 @@ mod tests { #[test] fn with_admins_empty_seeds_no_admin() { let component: AccountComponent = - RoleBasedAccessControl::with_admins(BTreeSet::new()).into(); + RoleBasedAccessControl::new(BTreeSet::default(), BTreeMap::default()).into(); // No membership entries and an empty config: the component starts with no administrator. let membership = find_map(&component, RoleBasedAccessControl::role_membership_slot()); diff --git a/crates/miden-testing/tests/scripts/rbac.rs b/crates/miden-testing/tests/scripts/rbac.rs index f7315a72aa..45161462dd 100644 --- a/crates/miden-testing/tests/scripts/rbac.rs +++ b/crates/miden-testing/tests/scripts/rbac.rs @@ -928,7 +928,7 @@ fn test_rbac_with_role_members_seeds_admin_and_operator_roles() -> anyhow::Resul let account = AccountBuilder::new([9; 32]) .account_type(AccountType::Public) .with_auth_component(Auth::IncrNonce) - .with_component(RoleBasedAccessControl::with_role_members( + .with_component(RoleBasedAccessControl::new( BTreeSet::from([admin]), BTreeMap::from([ (minter_role.clone(), BTreeSet::from([minter])), From a51a672e7fcf0cbc6274a99130cfb87428ecc975 Mon Sep 17 00:00:00 2001 From: Andrey Khmuro Date: Wed, 15 Jul 2026 21:07:26 +0300 Subject: [PATCH 16/16] chore: update doc comment --- crates/miden-standards/src/account/access/rbac.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/miden-standards/src/account/access/rbac.rs b/crates/miden-standards/src/account/access/rbac.rs index f4f86a3ba9..75b9510517 100644 --- a/crates/miden-standards/src/account/access/rbac.rs +++ b/crates/miden-standards/src/account/access/rbac.rs @@ -59,9 +59,8 @@ static ROLE_MEMBERSHIP_SLOT_NAME: LazyLock = LazyLock::new(|| { /// revoke, or re-point (`set_role_admin`) that role. /// /// The component is seeded at construction with one or more members of the `ADMIN` role (see -/// [`new`][Self::new] / [`with_admins`][Self::with_admins]); this bootstraps administration. -/// The `ADMIN` role administers itself, so `ADMIN` membership can be granted, revoked, and -/// renounced through the standard API. +/// [`new`][Self::new]); this bootstraps administration. The `ADMIN` role administers itself, so +/// `ADMIN` membership can be granted, revoked, and renounced through the standard API. /// /// ## Role hierarchy and exclusive delegation ///