diff --git a/CHANGELOG.md b/CHANGELOG.md index 88ea50f586..a20dd86a50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ ### 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_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)). +- [BREAKING] Moved the initial-state account getters (`get_initial_*`) from `miden::protocol::active_account` to `miden::protocol::native_account`. They now always operate on the native account and panic with `ERR_ACCOUNT_IS_NOT_NATIVE` when invoked from a foreign procedure invocation (FPI) context ([#2034](https://github.com/0xMiden/protocol/issues/2034)). +- Cleaned up `signature.masm` by removing redundant scheme-id validation and duplication, dropping the `neq.0` double-negation in `assert_supported_scheme_word`, and eliminating the unused `NUM_OF_APPROVERS_LOC` slot; also optimized `verify_signatures` to reuse the signer index and approver public key from the operand stack instead of round-tripping them through local memory ([#3230](https://github.com/0xMiden/protocol/pull/3230)). +- Fixed the transaction executor host honoring `AuthRequest` events emitted outside the registered auth procedure, which let untrusted note or transaction scripts force the host to sign; signature production is now restricted to the authentication procedure ([#3233](https://github.com/0xMiden/protocol/pull/3233)). +- Added the `miden::protocol::tx::compute_fee` procedure, which lets account and note code compute the transaction fee during execution ([#3211](https://github.com/0xMiden/protocol/issues/3211)). +- [BREAKING] Refactored RBAC role administration to be fully role-based, removing the `Ownable2Step` owner as an unconditional super-admin over the role graph. Replaced `RoleBasedAccessControl::empty()` with `RoleBasedAccessControl::new(initial_admin)` / `with_admins(..)` (which seed the `ADMIN` role), and renamed the `ERR_SENDER_NOT_OWNER_OR_ROLE_ADMIN` abort to `ERR_SENDER_NOT_ROLE_ADMIN` ([#3215](https://github.com/0xMiden/protocol/pull/3215)). +- Re-exported `LoadedMastForest` from `miden-tx` so consumers implementing the re-exported `MastForestStore` trait can name its return type without depending on `miden-processor` directly ([#3236](https://github.com/0xMiden/protocol/pull/3237)). +- Refactored to use `neq.1` instead of `not` in `AuthSingleSigAcl` exempt-map marker check so a non-binary marker (only reachable via storage authored outside the typed API) degrades to "authentication required" rather than aborting and permanently bricking the account ([#3206](https://github.com/0xMiden/protocol/pull/3206)). +- Split `account_id::validate` into `account_id::validate_structure` (version-independent structural checks) and `account_id::validate` (structure and the version check) ([#3188](https://github.com/0xMiden/protocol/pull/3188)). +- Added a non-zero version check to `account_id::validate_structure` so the zero account ID no longer passes structural validation ([#3216](https://github.com/0xMiden/protocol/pull/3216)). +- 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)). +- [BREAKING] Renamed `AssetId` to `AssetClass`, the identifier that distinguishes assets within a faucet ([#3079](https://github.com/0xMiden/protocol/issues/3079)). +- [BREAKING] Renamed `AssetVaultKey` to `AssetId` (and `AssetVaultKeyHash` to `AssetIdHash`), so an asset is identified by an `AssetId` just as accounts and notes are identified by `AccountId` and `NoteId`. The `Asset::vault_key()` accessor is now `Asset::id()` ([#3079](https://github.com/0xMiden/protocol/issues/3079)). - Added the `tx::get_fee_faucet_id` kernel accessor, exposing the fee faucet ID from the transaction's reference block to user code ([#2899](https://github.com/0xMiden/protocol/discussions/2899)). - [BREAKING] Replaced the owner-only transfer allowlist/blocklist admin components (`AllowlistOwnerControlled` / `BlocklistOwnerControlled`) with authority-gated `AllowlistManager` / `BlocklistManager` ([#3277](https://github.com/0xMiden/protocol/pull/3277)). diff --git a/bin/bench-transaction/src/context_setups.rs b/bin/bench-transaction/src/context_setups.rs index d58dd35859..20695c29f5 100644 --- a/bin/bench-transaction/src/context_setups.rs +++ b/bin/bench-transaction/src/context_setups.rs @@ -1,5 +1,6 @@ use anyhow::Result; pub use miden_agglayer::testing::ClaimDataSource; +use miden_agglayer::testing::create_existing_bridge_account_with_roles; use miden_agglayer::{ AggLayerBridge, B2AggNote, @@ -11,7 +12,6 @@ use miden_agglayer::{ MetadataHash, UpdateGerNote, create_existing_agglayer_faucet, - create_existing_bridge_account, }; use miden_protocol::account::auth::AuthScheme; use miden_protocol::account::{Account, StorageMapKey}; @@ -191,8 +191,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, })?; @@ -208,9 +208,9 @@ pub async fn tx_consume_claim_note(data_source: ClaimDataSource) -> Result Result) -> Result) -> 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). @@ -124,13 +124,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`](#46-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 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 @@ -154,11 +155,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 @@ -174,8 +175,8 @@ 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 that the sender holds the `FAUCET_MNGR` 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 +197,37 @@ 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`](#45-update_ger) - notes. -- **GER remover** (`ger_remover_account_id`): authorizes GER removals via - [`REMOVE_GER`](#46-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 (`RoleBasedAccessControl` + `Authority`) installed on the +bridge account alongside the bridge component. + +- **`ADMIN` role**: the built-in administrative role. Members of `ADMIN` administer (grant and + revoke) the operational roles below. It is the effective admin of any role whose delegated admin + is unset, and it administers itself, so `ADMIN` membership can be granted, revoked, and renounced + through the standard RBAC API. +- **`FAUCET_MNGR` role**: authorizes faucet registration via + [`CONFIG_AGG_BRIDGE`](#43-config_agg_bridge) notes (`register_faucet`, + `store_faucet_metadata_hash`) and faucet deregistration via + [`DEREGISTER_AGG_FAUCET`](#44-deregister_agg_faucet) notes (`deregister_faucet`). +- **`GER_INJECTOR` role**: authorizes GER injection via [`UPDATE_GER`](#45-update_ger) notes + (`update_ger`). +- **`GER_REMOVER` role**: authorizes GER removal via [`REMOVE_GER`](#46-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). Procedures with no mapped role fall +back to requiring the `ADMIN` role. The initial `ADMIN` member and the initial operational-role +holders are seeded at account creation, so the bridge is born fully functional. + +TODO: On-chain role management — notes that call `grant_role` / `revoke_role` / `renounce_role` / +`set_role_admin` — 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). The note scripts and Rust +builders for these `Rbac` procedures are tracked more broadly by +[#3046](https://github.com/0xMiden/protocol/issues/3046) (note configuration for standards +components); once they exist, the bridge only needs to add their script roots to +[`AggLayerBridge::allowed_notes`]. TODO: No emergency pause mechanism exists ([#2696](https://github.com/0xMiden/protocol/issues/2696)). @@ -259,10 +277,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_MNGR` role | -Asserts the note sender matches the bridge admin stored in -`agglayer::bridge::admin_account_id`, then performs a two-step registration: +Asserts that the note sender holds the `FAUCET_MNGR` +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. @@ -286,10 +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 is not the bridge admin; faucet is not currently registered | +| **Panics** | Note sender does not hold the `FAUCET_MNGR` role; faucet is not currently registered | -Asserts the note sender matches the bridge admin stored in -`agglayer::bridge::admin_account_id` 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]`. @@ -299,8 +316,8 @@ Asserts the note sender matches the bridge admin stored in 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 the bridge admin -should warn users with notes in flight. As defense-in-depth, `claim` and `bridge_out` also re-check +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. @@ -312,10 +329,10 @@ should warn users with notes in flight. As defense-in-depth, `claim` and `bridge | **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 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 @@ -373,15 +390,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 +(`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 initial `ADMIN` member and +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 @@ -635,7 +654,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_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 | @@ -662,14 +681,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 that the +sender holds the `FAUCET_MNGR` 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_MNGR` role only -- **enforced** by `bridge_config::register_faucet` | | **Consumer** | Bridge account -- **enforced** via `NetworkAccountTarget` attachment | ### 4.4 DEREGISTER_AGG_FAUCET @@ -682,7 +702,7 @@ two-step registration into `faucet_registry_map` and `token_registry_map`). | Field | Value | |-------|-------| -| `sender` | Bridge admin (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 | @@ -710,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 sender is bridge admin, 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. The bridge admin 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** | Bridge admin 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 @@ -737,7 +757,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) | | `note_type` | `NoteType::Public` | | `tag` | `NoteTag::default()` | | `attachment` | `NetworkAccountTarget` -- target is the bridge account; execution hint: Always | @@ -762,14 +782,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 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` | | **Consumer** | Bridge account -- **enforced** via `NetworkAccountTarget` attachment | ### 4.6 REMOVE_GER @@ -784,7 +805,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) | | `note_type` | `NoteType::Public` | | `tag` | `NoteTag::default()` | | `attachment` | `NetworkAccountTarget` -- target is the bridge account; execution hint: Always | @@ -809,7 +830,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 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)). @@ -818,7 +840,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` | | **Consumer** | Bridge account -- **enforced** via `NetworkAccountTarget` attachment | ### 4.7 BURN (generated) @@ -1258,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 bridge admin 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 @@ -1284,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 bridge admin is a trusted role, 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)). -The bridge admin 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. @@ -1311,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 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_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/asm/agglayer/bridge/bridge_config.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm index e6ee114337..3ad6d460b2 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,11 @@ 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") 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") @@ -78,14 +72,14 @@ 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 @account_procedure 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,14 +116,14 @@ 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 @account_procedure 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 @@ -212,13 +206,14 @@ end #! Outputs: [pad(16)] #! #! Panics if: -#! - the note sender is not the bridge admin. +#! - the note sender does not hold the faucet manager role. #! #! Invocation: call @locals(14) @account_procedure pub proc register_faucet - exec.assert_sender_is_bridge_admin + # 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)] # Save non-address data to locals. @@ -359,12 +354,13 @@ end #! Outputs: [pad(16)] #! #! Panics if: -#! - the note sender is not the bridge admin. +#! - the note sender does not hold the faucet manager role. #! #! Invocation: call @account_procedure pub proc store_faucet_metadata_hash - exec.assert_sender_is_bridge_admin + # 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)] # --- Store METADATA_HASH_LO at key [SUBKEY_HASH_LO, 0, faucet_id_suffix, faucet_id_prefix] --- @@ -399,7 +395,7 @@ end #! Outputs: [pad(16)] #! #! Panics if: -#! - the note sender is not the bridge admin. +#! - the note sender does not hold the faucet manager role. #! - the faucet is not currently registered in the faucet registry. #! #! Locals: @@ -410,8 +406,8 @@ end @locals(2) @account_procedure pub proc deregister_faucet - # assert the note sender is the bridge admin. - exec.assert_sender_is_bridge_admin + # 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)] # Stash the faucet ID; it is needed for every map key below. @@ -704,90 +700,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::eq - 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::eq - 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::eq - 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/asm/note_scripts/config_agg_bridge.masm b/crates/miden-agglayer/asm/note_scripts/config_agg_bridge.masm index 018f114844..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 the bridge admin. +#! 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 affd14a5c1..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 the bridge admin. +#! 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/build.rs b/crates/miden-agglayer/build.rs index db4e1f9b7e..3a8667d4e4 100644 --- a/crates/miden-agglayer/build.rs +++ b/crates/miden-agglayer/build.rs @@ -22,7 +22,7 @@ use miden_protocol::account::{AccountCode, AccountComponent, AccountComponentMet use miden_protocol::assembly::Library; 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, @@ -526,14 +526,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 (RoleBasedAccessControl + + // Authority::RbacControlled), matching `create_bridge_account_builder` in lib.rs. An + // empty admin / role config suffices here since only component code affects the + // commitment. + components.extend(AccessControl::Rbac { + admin: dummy_owner, + procedure_roles: BTreeMap::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 64dd665efd..93f9219c8a 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::procedure_root; use miden_utils_sync::LazyLock; use thiserror::Error; @@ -58,18 +60,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") @@ -131,6 +121,108 @@ static LET_NUM_LEAVES_SLOT_NAME: LazyLock = LazyLock::new(|| { .expect("LET num_leaves storage slot name should be valid") }); +// BRIDGE RBAC ROLES +// ================================================================================================ + +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") +}); +static GER_REMOVER_ROLE: LazyLock = LazyLock::new(|| { + RoleSymbol::new("GER_REMOVER").expect("GER_REMOVER role symbol should be valid") +}); + +/// 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())); + +procedure_root!( + REGISTER_FAUCET_ROOT, + AggLayerBridge::COMPONENT_NAMESPACE, + "register_faucet", + AggLayerBridge::code() +); +procedure_root!( + STORE_FAUCET_METADATA_HASH_ROOT, + AggLayerBridge::COMPONENT_NAMESPACE, + "store_faucet_metadata_hash", + AggLayerBridge::code() +); +procedure_root!( + UPDATE_GER_ROOT, + AggLayerBridge::COMPONENT_NAMESPACE, + "update_ger", + AggLayerBridge::code() +); +procedure_root!( + REMOVE_GER_ROOT, + AggLayerBridge::COMPONENT_NAMESPACE, + "remove_ger", + AggLayerBridge::code() +); +procedure_root!( + DEREGISTER_FAUCET_ROOT, + AggLayerBridge::COMPONENT_NAMESPACE, + "deregister_faucet", + AggLayerBridge::code() +); + +/// The accounts that initially hold each of the bridge's privileged RBAC roles. +/// +/// Used to seed the bridge account's RBAC role membership at creation. Each role gates a distinct +/// set of bridge procedures: +/// - `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_managers: BTreeSet, + ger_injectors: BTreeSet, + ger_removers: BTreeSet, +} + +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_managers: BTreeSet, + ger_injectors: BTreeSet, + ger_removers: BTreeSet, + ) -> Result { + for (role, members) in [ + (AggLayerBridge::faucet_manager_role(), &faucet_managers), + (AggLayerBridge::ger_injector_role(), &ger_injectors), + (AggLayerBridge::ger_remover_role(), &ger_removers), + ] { + if members.is_empty() { + return Err(AgglayerBridgeError::EmptyBridgeRole(role)); + } + } + + Ok(Self { + faucet_managers, + ger_injectors, + ger_removers, + }) + } + + /// 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_manager_role(), self.faucet_managers.clone()), + (AggLayerBridge::ger_injector_role(), self.ger_injectors.clone()), + (AggLayerBridge::ger_remover_role(), self.ger_removers.clone()), + ]) + } +} + /// An [`AccountComponent`] implementing the AggLayer Bridge. /// /// It reexports the procedures from `agglayer::bridge`. When linking against this @@ -146,11 +238,16 @@ 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 +/// (`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 [`BridgeRoles`] 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. @@ -176,12 +273,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,42 +287,78 @@ impl AggLayerBridge { const REGISTERED_GER_MAP_VALUE: Word = Word::new([ONE, ZERO, ZERO, ZERO]); - // CONSTRUCTORS + /// 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 // -------------------------------------------------------------------------------------------- - /// 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, - } + /// Returns the assembled bridge account component code. + pub fn code() -> &'static AccountComponentCode { + &BRIDGE_COMPONENT_CODE } - // PUBLIC ACCESSORS - // -------------------------------------------------------------------------------------------- + /// Returns the `FAUCET_MNGR` role symbol. Holders may register faucets and store faucet + /// metadata (`register_faucet`, `store_faucet_metadata_hash`). + pub fn faucet_manager_role() -> RoleSymbol { + FAUCET_MANAGER_ROLE.clone() + } - // --- bridge config ---- + /// Returns the `GER_INJECTOR` role symbol. Holders may inject GERs (`update_ger`). + pub fn ger_injector_role() -> RoleSymbol { + GER_INJECTOR_ROLE.clone() + } - /// 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 `GER_REMOVER` role symbol. Holders may remove GERs (`remove_ger`). + pub fn ger_remover_role() -> RoleSymbol { + GER_REMOVER_ROLE.clone() } - /// 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 `register_faucet` procedure. + pub fn register_faucet_root() -> AccountProcedureRoot { + *REGISTER_FAUCET_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 procedure root of the bridge's `store_faucet_metadata_hash` procedure. + pub fn store_faucet_metadata_hash_root() -> AccountProcedureRoot { + *STORE_FAUCET_METADATA_HASH_ROOT } + /// Returns the procedure root of the bridge's `update_ger` procedure. + pub fn update_ger_root() -> AccountProcedureRoot { + *UPDATE_GER_ROOT + } + + /// Returns the procedure root of the bridge's `remove_ger` procedure. + pub fn remove_ger_root() -> AccountProcedureRoot { + *REMOVE_GER_ROOT + } + + /// Returns the procedure root of the bridge's `deregister_faucet` procedure. + pub fn deregister_faucet_root() -> AccountProcedureRoot { + *DEREGISTER_FAUCET_ROOT + } + + /// 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_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()), + ]) + } + + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + // --- bridge config ---- + /// Storage slot name for the GERs map. pub fn ger_map_slot_name() -> &'static StorageSlotName { &GER_MAP_SLOT_NAME @@ -538,9 +667,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, @@ -551,11 +677,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()), @@ -565,9 +687,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()), @@ -592,6 +711,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/deregister_note.rs b/crates/miden-agglayer/src/deregister_note.rs index 419fb44a17..8fe269a91e 100644 --- a/crates/miden-agglayer/src/deregister_note.rs +++ b/crates/miden-agglayer/src/deregister_note.rs @@ -87,7 +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 be the bridge admin) + /// - `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 9e78c02bed..77a3494100 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -2,12 +2,14 @@ extern crate alloc; +use alloc::collections::BTreeSet; + use miden_core::{Felt, Word}; use miden_protocol::account::{Account, AccountBuilder, AccountComponent, AccountId, AccountType}; use miden_protocol::assembly::Library; use miden_protocol::asset::TokenSymbol; use miden_protocol::utils::serde::Deserializable; -use miden_standards::account::access::{Authority, Ownable2Step}; +use miden_standards::account::access::{Authority, Ownable2Step, RoleBasedAccessControl}; use miden_standards::account::auth::AuthNetworkAccount; use miden_standards::account::policies::{ BurnAllowAll, @@ -34,7 +36,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, BridgeRoles, RemovedGerHashChain}; pub use claim_note::{ CgiChainHash, ClaimNote, @@ -132,17 +134,25 @@ 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`. /// +/// 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_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 /// [`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, + admin: AccountId, + roles: BridgeRoles, ) -> 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) + .with_component(RoleBasedAccessControl::new(BTreeSet::from([admin]), roles.role_members())) + .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"), @@ -151,33 +161,15 @@ fn create_bridge_account_builder( /// Creates a new bridge account with the standard configuration. /// -/// This creates a new account suitable for production use. -pub fn create_bridge_account( - seed: Word, - bridge_admin_id: AccountId, - ger_injector_id: AccountId, - ger_remover_id: AccountId, -) -> Account { - create_bridge_account_builder(seed, bridge_admin_id, ger_injector_id, ger_remover_id) +/// This creates a new account suitable for production use. `admin` bootstraps the `ADMIN` role +/// (role administration); the initial operational-role holders are seeded from `roles` (see +/// [`BridgeRoles`]). +pub fn create_bridge_account(seed: Word, admin: AccountId, roles: BridgeRoles) -> Account { + create_bridge_account_builder(seed, admin, roles) .build() .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, - bridge_admin_id: AccountId, - ger_injector_id: AccountId, - ger_remover_id: AccountId, -) -> Account { - create_bridge_account_builder(seed, bridge_admin_id, ger_injector_id, ger_remover_id) - .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 fb120ccc76..880b2c1676 100644 --- a/crates/miden-agglayer/src/testing/mod.rs +++ b/crates/miden-agglayer/src/testing/mod.rs @@ -9,15 +9,55 @@ 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}; +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; 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_bridge_account_builder, +}; + +// BRIDGE ACCOUNT HELPERS +// ================================================================================================ + +/// 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_manager: AccountId, + ger_injector: AccountId, + ger_remover: AccountId, +) -> Account { + let roles = BridgeRoles::new( + BTreeSet::from([faucet_manager]), + BTreeSet::from([ger_injector]), + BTreeSet::from([ger_remover]), + ) + .expect("single-holder role sets are non-empty"); + + let admin_account = + AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap(); + + create_bridge_account_builder(seed, admin_account, roles) + .build_existing() + .expect("bridge account should be valid") +} // EMBEDDED TEST VECTOR JSON FILES // ================================================================================================ 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..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; @@ -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![ - RoleBasedAccessControl::new(admin).into(), - Authority::RbacControlled { roles }.into(), + AccessControl::Rbac { admin, procedure_roles } => vec![ + 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 8fc902f304..75b9510517 100644 --- a/crates/miden-standards/src/account/access/rbac.rs +++ b/crates/miden-standards/src/account/access/rbac.rs @@ -1,5 +1,6 @@ -use alloc::collections::BTreeSet; +use alloc::collections::{BTreeMap, BTreeSet}; use alloc::vec; +use alloc::vec::Vec; use miden_protocol::account::component::{ AccountComponentCode, @@ -58,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 /// @@ -127,9 +127,14 @@ pub struct RoleBasedAccessControl { /// construction. Bootstraps role administration; may be empty for an account that seeds /// `ADMIN` membership by other means, but such a component starts with no administrator. 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_role_members`][Self::with_role_members]). + initial_role_members: BTreeMap>, } 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 @@ -138,6 +143,29 @@ impl RoleBasedAccessControl { /// Keep in sync with the `ADMIN_ROLE` constant in `asm/standards/access/rbac.masm`. pub const ADMIN_ROLE: &'static str = "ADMIN"; + // 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. + /// + /// 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 new( + initial_admins: BTreeSet, + role_members: BTreeMap>, + ) -> Self { + 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") @@ -153,27 +181,6 @@ impl RoleBasedAccessControl { &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]), - } - } - - /// 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 } - } - /// Returns the storage slot name for the per-role config map. pub fn role_config_slot() -> &'static StorageSlotName { &ROLE_CONFIG_SLOT_NAME @@ -224,37 +231,51 @@ impl RoleBasedAccessControl { impl From for AccountComponent { fn from(rbac: RoleBasedAccessControl) -> Self { - let admin_symbol: Felt = RoleBasedAccessControl::admin_role().as_element(); - let admins = rbac.initial_admins; - - // Seed ADMIN membership: [0, admin_role, suffix, prefix] -> [1, 0, 0, 0]. - let membership_entries = admins.iter().map(|admin| { - ( - StorageMapKey::new(Word::from([ - Felt::ZERO, - admin_symbol, - admin.suffix(), - admin.prefix().as_felt(), - ])), - Word::from([Felt::ONE, Felt::ZERO, Felt::ZERO, Felt::ZERO]), - ) - }); - let role_membership_map = StorageMap::with_entries(membership_entries) - .expect("seeded role membership map should be valid"); + // 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. Members provided for the `ADMIN` role are merged with + // `initial_admins`. + let mut roles = rbac.initial_role_members; + if !rbac.initial_admins.is_empty() { + roles + .entry(RoleBasedAccessControl::admin_role()) + .or_default() + .extend(rbac.initial_admins); + } - // Seed the ADMIN role config with its member count. The delegated admin is left unset - // (0) so ADMIN administers itself. When there are no admins, the config stays empty. - let role_config_map = if admins.is_empty() { - StorageMap::default() - } else { + // Seed, for every non-empty role: + // - 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(); + for (role, members) in &roles { + if members.is_empty() { + continue; + } + let role_symbol: Felt = role.as_element(); let member_count = - u32::try_from(admins.len()).expect("number of initial admins should fit in u32"); - StorageMap::with_entries(vec![( - StorageMapKey::new(Word::from([Felt::ZERO, Felt::ZERO, Felt::ZERO, admin_symbol])), + u32::try_from(members.len()).expect("role member count should fit in u32"); + config_entries.push(( + StorageMapKey::new(Word::from([Felt::ZERO, Felt::ZERO, Felt::ZERO, role_symbol])), Word::from([Felt::from(member_count), Felt::ZERO, Felt::ZERO, Felt::ZERO]), - )]) - .expect("seeded role config map should be valid") - }; + )); + for member in members { + membership_entries.push(( + StorageMapKey::new(Word::from([ + Felt::ZERO, + role_symbol, + member.suffix(), + member.prefix().as_felt(), + ])), + Word::from([Felt::ONE, Felt::ZERO, Felt::ZERO, Felt::ZERO]), + )); + } + } + + let role_membership_map = StorageMap::with_entries(membership_entries) + .expect("seeded role membership map should be valid"); + let role_config_map = StorageMap::with_entries(config_entries) + .expect("seeded role config map should be valid"); let role_config_slot = StorageSlot::with_map( RoleBasedAccessControl::role_config_slot().clone(), @@ -318,7 +339,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(); @@ -350,7 +372,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/agglayer/bridge_in.rs b/crates/miden-testing/tests/agglayer/bridge_in.rs index dada4b13a3..0288108126 100644 --- a/crates/miden-testing/tests/agglayer/bridge_in.rs +++ b/crates/miden-testing/tests/agglayer/bridge_in.rs @@ -27,7 +27,6 @@ use miden_agglayer::{ agglayer_library, create_existing_agglayer_faucet, create_existing_agglayer_faucet_with_callbacks, - create_existing_bridge_account, }; use miden_protocol::Felt; use miden_protocol::account::auth::AuthScheme; @@ -52,6 +51,7 @@ use super::test_utils::{ ClaimDataSource, MerkleProofVerificationFile, SOLIDITY_MERKLE_PROOF_VECTORS, + create_existing_bridge_account_with_roles, }; // CONSTANTS @@ -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, })?; @@ -156,9 +156,9 @@ 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(), + 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 { @@ -451,9 +451,9 @@ 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(), + 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, })?; @@ -653,9 +653,9 @@ 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(), + 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, })?; @@ -799,9 +799,9 @@ 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(), + 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, })?; @@ -966,9 +966,9 @@ 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(), + 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 { @@ -1114,9 +1118,9 @@ 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(), + 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 { @@ -1396,9 +1400,9 @@ 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(), + 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, })?; @@ -1649,9 +1653,9 @@ 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(), + 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 { @@ -1795,9 +1799,9 @@ 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( + 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 de90769686..8741835288 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::{RngExt, 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. /// @@ -79,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, })?; @@ -94,9 +93,9 @@ 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(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -130,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(), @@ -140,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(), )?; @@ -366,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 { @@ -377,9 +376,9 @@ 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(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -416,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(), )?; @@ -492,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, })?; @@ -509,9 +508,9 @@ 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(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -589,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, })?; @@ -607,9 +606,9 @@ 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(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -648,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(), )?; @@ -761,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, })?; @@ -777,9 +776,9 @@ 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(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -885,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, })?; @@ -901,9 +900,9 @@ 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(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -915,9 +914,9 @@ 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(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -972,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 { @@ -984,9 +983,9 @@ 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(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -1025,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 f319c4c6f2..c2fb70acd9 100644 --- a/crates/miden-testing/tests/agglayer/config_bridge.rs +++ b/crates/miden-testing/tests/agglayer/config_bridge.rs @@ -1,16 +1,18 @@ extern crate alloc; +use alloc::collections::BTreeSet; use alloc::vec::Vec; -use miden_agglayer::errors::{ERR_FAUCET_NOT_REGISTERED, ERR_SENDER_NOT_BRIDGE_ADMIN}; +use miden_agglayer::errors::ERR_FAUCET_NOT_REGISTERED; use miden_agglayer::{ AggLayerBridge, + AgglayerBridgeError, + BridgeRoles, ConfigAggBridgeNote, ConversionMetadata, DeregisterAggFaucetNote, EthAddress, MetadataHash, - create_existing_bridge_account, }; use miden_protocol::account::auth::AuthScheme; use miden_protocol::account::{ @@ -25,8 +27,11 @@ use miden_protocol::crypto::rand::FeltRng; use miden_protocol::errors::MasmError; use miden_protocol::transaction::RawOutputNote; use miden_protocol::{Felt, Hasher, Word}; +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; + /// 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 @@ -38,6 +43,36 @@ 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(), 5, "exactly the five role-gated procedures must be mapped"); + assert_eq!( + roles.get(&AggLayerBridge::register_faucet_root()), + Some(&AggLayerBridge::faucet_manager_role()), + ); + assert_eq!( + roles.get(&AggLayerBridge::store_faucet_metadata_hash_root()), + Some(&AggLayerBridge::faucet_manager_role()), + ); + assert_eq!( + roles.get(&AggLayerBridge::deregister_faucet_root()), + Some(&AggLayerBridge::faucet_manager_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: @@ -50,8 +85,8 @@ fn token_registry_key(origin_token_address: &EthAddress, origin_network: u32) -> 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, })?; @@ -66,9 +101,9 @@ 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(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -103,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(), )?; @@ -144,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, })?; @@ -159,9 +194,9 @@ 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(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -189,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(), )?; @@ -202,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(), )?; @@ -259,6 +294,97 @@ async fn test_config_agg_bridge_distinguishes_origin_network() -> anyhow::Result Ok(()) } +/// 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 faucet_manager = 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(), + faucet_manager.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_MNGR 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_managers, ger_injectors, ger_removers] = sets; + let err = BridgeRoles::new(faucet_managers, ger_injectors, ger_removers).unwrap_err(); + assert!(matches!(err, AgglayerBridgeError::EmptyBridgeRole(_))); + } +} + /// Builds the `faucet_metadata_map` sub-key for a faucet: `[sub_key, 0, suffix, prefix]`. /// /// Mirrors the key layout written by `register_faucet` / read by `get_faucet_conversion_info`. @@ -281,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, })?; @@ -292,9 +418,9 @@ async fn test_deregister_agg_faucet_clears_both_registries() -> 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(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -321,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(), )?; @@ -423,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 { @@ -433,9 +559,9 @@ async fn test_deregister_agg_faucet_clears_native_faucet() -> 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(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -461,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(), )?; @@ -531,14 +657,14 @@ async fn test_deregister_agg_faucet_clears_native_faucet() -> anyhow::Result<()> /// Tests that DEREGISTER_AGG_FAUCET rejects invalid deregistrations: /// - an unregistered faucet panics with `ERR_FAUCET_NOT_REGISTERED`; -/// - a non-admin sender panics with `ERR_SENDER_NOT_BRIDGE_ADMIN`, even when the faucet is -/// registered (so the panic comes from the auth check, not the registration check); +/// - a non-admin sender panics with `ERR_SENDER_LACKS_ROLE`, even when the faucet is registered (so +/// the panic comes from the auth check, not the registration check); /// - an already-deregistered faucet panics with `ERR_FAUCET_NOT_REGISTERED`, demonstrating that /// deregistration revokes the faucet end-to-end (this is the exact check in-flight B2AGG / CLAIM /// notes rely on). #[rstest::rstest] #[case::unregistered_faucet(false, false, false, ERR_FAUCET_NOT_REGISTERED)] -#[case::non_admin_sender(true, false, true, ERR_SENDER_NOT_BRIDGE_ADMIN)] +#[case::non_admin_sender(true, false, true, ERR_SENDER_LACKS_ROLE)] #[case::already_deregistered(true, true, false, ERR_FAUCET_NOT_REGISTERED)] #[tokio::test] async fn test_deregister_agg_faucet_rejects_invalid( @@ -549,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 { @@ -562,9 +688,9 @@ async fn test_deregister_agg_faucet_rejects_invalid( 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(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -592,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(), )?) @@ -604,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(), )?) @@ -615,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 1903968220..407184421e 100644 --- a/crates/miden-testing/tests/agglayer/faucet_helpers.rs +++ b/crates/miden-testing/tests/agglayer/faucet_helpers.rs @@ -1,21 +1,19 @@ 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(); - 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,9 +23,9 @@ 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(), + 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 0b9054069e..d2c9b174f1 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 @@ -45,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 { @@ -56,9 +53,9 @@ 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(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -97,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 { @@ -108,15 +105,15 @@ 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(), + 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"); @@ -148,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, })?; @@ -158,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())?; @@ -189,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, })?; @@ -199,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 d31ee6407c..c4523df4bd 100644 --- a/crates/miden-testing/tests/agglayer/remove_ger.rs +++ b/crates/miden-testing/tests/agglayer/remove_ger.rs @@ -1,31 +1,28 @@ 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, ]; -/// 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 { @@ -36,9 +33,9 @@ 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(), + faucet_manager.id(), ger_injector.id(), ger_remover.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 ba905d20c5..8fb2082327 100644 --- a/crates/miden-testing/tests/agglayer/test_utils.rs +++ b/crates/miden-testing/tests/agglayer/test_utils.rs @@ -11,6 +11,7 @@ pub use miden_agglayer::testing::{ MtfVectorsFile, SOLIDITY_CANONICAL_ZEROS, SOLIDITY_MERKLE_PROOF_VECTORS, + create_existing_bridge_account_with_roles, }; use miden_assembly::{Assembler, DefaultSourceManager, Linkage}; use miden_core_lib::CoreLibrary; diff --git a/crates/miden-testing/tests/agglayer/update_ger.rs b/crates/miden-testing/tests/agglayer/update_ger.rs index 5c46c92091..e47838a904 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, Linkage}; use miden_core_lib::CoreLibrary; use miden_core_lib::handlers::keccak256::KeccakPreimage; @@ -21,11 +15,15 @@ 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; -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 // ================================================================================================ @@ -53,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,9 +72,9 @@ 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(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -286,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,9 +301,9 @@ 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(), + faucet_manager.id(), ger_injector.id(), ger_remover.id(), ); @@ -348,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 faucet_manager = 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, + faucet_manager.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(()) +} diff --git a/crates/miden-testing/tests/scripts/allowlist.rs b/crates/miden-testing/tests/scripts/allowlist.rs index cd7a98650e..669151fa63 100644 --- a/crates/miden-testing/tests/scripts/allowlist.rs +++ b/crates/miden-testing/tests/scripts/allowlist.rs @@ -548,7 +548,10 @@ fn add_rbac_faucet_with_allowlist( .account_type(AccountType::Public) .with_asset_callbacks(AssetCallbackFlag::Enabled) .with_component(faucet) - .with_components(AccessControl::Rbac { admin, roles: allowlister_roles() }) + .with_components(AccessControl::Rbac { + admin, + procedure_roles: allowlister_roles(), + }) .with_components( TokenPolicyManager::builder() .active_mint_policy(MintPolicy::allow_all()) diff --git a/crates/miden-testing/tests/scripts/authority.rs b/crates/miden-testing/tests/scripts/authority.rs index 8d21da986d..7e36cd7ea8 100644 --- a/crates/miden-testing/tests/scripts/authority.rs +++ b/crates/miden-testing/tests/scripts/authority.rs @@ -71,7 +71,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() @@ -84,7 +84,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/blocklist.rs b/crates/miden-testing/tests/scripts/blocklist.rs index 88a351ada1..a17136c7f8 100644 --- a/crates/miden-testing/tests/scripts/blocklist.rs +++ b/crates/miden-testing/tests/scripts/blocklist.rs @@ -539,7 +539,10 @@ fn add_rbac_faucet_with_blocklist( .account_type(AccountType::Public) .with_asset_callbacks(AssetCallbackFlag::Enabled) .with_component(faucet) - .with_components(AccessControl::Rbac { admin, roles: blocklister_roles() }) + .with_components(AccessControl::Rbac { + admin, + procedure_roles: blocklister_roles(), + }) .with_components( TokenPolicyManager::builder() .active_mint_policy(MintPolicy::allow_all()) diff --git a/crates/miden-testing/tests/scripts/pausable.rs b/crates/miden-testing/tests/scripts/pausable.rs index fa6e65bb41..c0e9b28090 100644 --- a/crates/miden-testing/tests/scripts/pausable.rs +++ b/crates/miden-testing/tests/scripts/pausable.rs @@ -272,11 +272,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 { @@ -291,7 +291,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 3ba566876e..45161462dd 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; @@ -32,7 +32,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) @@ -910,3 +910,45 @@ async fn test_rbac_admin_can_renounce_admin_role() -> anyhow::Result<()> { Ok(()) } + +/// `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_role_members_seeds_admin_and_operator_roles() -> anyhow::Result<()> { + let admin = test_account_id(201); + let minter = test_account_id(202); + let burner = test_account_id(203); + + let admin_role = RoleBasedAccessControl::admin_role(); + let minter_role = role("MINTER"); + let burner_role = role("BURNER"); + let empty_role = role("EMPTY"); + + let account = AccountBuilder::new([9; 32]) + .account_type(AccountType::Public) + .with_auth_component(Auth::IncrNonce) + .with_component(RoleBasedAccessControl::new( + BTreeSet::from([admin]), + BTreeMap::from([ + (minter_role.clone(), BTreeSet::from([minter])), + (burner_role.clone(), BTreeSet::from([burner])), + (empty_role.clone(), BTreeSet::new()), + ]), + )) + .build_existing()?; + + // ADMIN is seeded and administers itself (delegated admin unset). + assert!(is_role_member(&account, &admin_role, admin)?); + assert_eq!(get_role_config(&account, &admin_role)?, (Felt::ONE, Felt::ZERO)); + + // Operator roles are seeded with their delegated admin unset (0 -> administered by ADMIN). + assert!(is_role_member(&account, &minter_role, minter)?); + assert_eq!(get_role_config(&account, &minter_role)?, (Felt::ONE, Felt::ZERO)); + assert!(is_role_member(&account, &burner_role, burner)?); + + // A role mapped to an empty member set is dropped. + assert_eq!(get_role_config(&account, &empty_role)?.0, Felt::ZERO); + + Ok(()) +}