diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eeeb3e4cd..1e08254c63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,12 @@ ### Features +- [BREAKING] AggLayer bridge and faucet accounts now take their fee policy manager at deployment, built by the new `NetworkNotePricer::agglayer_bridge_fee_policy_manager` / `agglayer_faucet_fee_policy_manager` ([#3486](https://github.com/0xMiden/protocol/pull/3486)). +- [BREAKING] AggLayer bridge and faucet accounts install the `ConstantFeeManager` so their `ADMIN` role can reprice the deployed fee schedule; the faucet is now `Authority::RbacControlled` and its constructors take a deployment admin ([#3486](https://github.com/0xMiden/protocol/pull/3486)). + ### Changes +- [BREAKING] Renamed `AggLayerBridge::allowed_notes` / `AggLayerFaucet::allowed_notes` to `bridge_notes` / `faucet_notes`; `allowed_notes` now returns the full set of note script roots the deployed account accepts (the account-specific roots plus the `AuthNetworkAccount` defaults), which its fee policy must price ([#3486](https://github.com/0xMiden/protocol/pull/3486)). - Documented the RBAC freeze-only actor pattern on `Authority` and added test coverage pinning that a `FREEZER` can trip the emergency switch but can never unfreeze the account ([#3520](https://github.com/0xMiden/protocol/pull/3520)). ### Fixes diff --git a/bin/bench-transaction/src/context_setups/mod.rs b/bin/bench-transaction/src/context_setups/mod.rs index 765ab5d2a5..2989d0f7f9 100644 --- a/bin/bench-transaction/src/context_setups/mod.rs +++ b/bin/bench-transaction/src/context_setups/mod.rs @@ -2,7 +2,11 @@ use std::collections::BTreeSet; use anyhow::Result; pub use miden_agglayer::testing::ClaimDataSource; -use miden_agglayer::testing::{bridge_admin_account_id, create_existing_bridge_account_with_roles}; +use miden_agglayer::testing::{ + bridge_admin_account_id, + create_existing_agglayer_faucet, + create_existing_bridge_account_with_roles, +}; use miden_agglayer::{ AggLayerBridge, B2AggNote, @@ -14,7 +18,6 @@ use miden_agglayer::{ MetadataHash, RemoveGerNote, UpdateGerNote, - create_existing_agglayer_faucet, }; use miden_protocol::account::auth::AuthScheme; use miden_protocol::account::{Account, StorageMapKey}; diff --git a/crates/miden-agglayer/SPEC.md b/crates/miden-agglayer/SPEC.md index b116e85624..4162c2f59f 100644 --- a/crates/miden-agglayer/SPEC.md +++ b/crates/miden-agglayer/SPEC.md @@ -24,7 +24,7 @@ implementation are called out inline with `TODO (Future)` markers. | **AggLayer Faucet** | Fungible faucet that represents a single bridged token. Mints on bridge-in claims, burns on bridge-out. Each foreign token has its own faucet instance. | `FungibleFaucet`, network-mode, with `agglayer_faucet` component | | **Integration Service** (offchain) | Observes L1 events (deposits, GER updates) and creates UPDATE_GER and CLAIM notes on Miden. Trusted to provide correct proofs and data. | Not an onchain entity; creates notes targeting bridge/faucet | | **Bridge Operator** (offchain) | Deploys bridge and faucet accounts. Creates CONFIG_AGG_BRIDGE notes to register faucets. Must hold the `FAUCET_MNGR` role. | Not an onchain entity; creates config notes | -| **Role Admin** (offchain) | Holds the bridge's `ADMIN` role and manages role membership via RBAC_CONFIG notes. Root authority: effective admin of every operational role unless delegated, so compromise of this key is equivalent to compromise of all operational roles (see [Section 2.5](#25-administration)). | Not an onchain entity; creates RBAC_CONFIG notes | +| **Role Admin** (offchain) | Holds the `ADMIN` role on the bridge and on each faucet. Manages bridge role membership via RBAC_CONFIG notes and reprices both accounts' fee schedules via CONSTANT_FEE_POLICY_CONFIG notes. Root authority: effective admin of every operational role unless delegated, and on a faucet it can additionally retarget the `Ownable2Step` owner, so compromise of this key is equivalent to compromise of all operational roles (see [Section 2.5](#25-administration)). | Not an onchain entity; creates RBAC_CONFIG and CONSTANT_FEE_POLICY_CONFIG notes | --- @@ -255,7 +255,8 @@ so while the bridge is paused it rejects all bridge-out, claim, GER-injection, a faucet-management operations. `remove_ger` is deliberately exempt: a paused bridge can still revoke a fraudulent GER, and because `update_ger` is paused the revoked GER cannot be re-injected until unpause. The management notes (`RBAC_CONFIG`, `NETWORK_ACCOUNT_CONFIG`, -`PAUSE_CONFIG`) remain consumable while paused, so a paused bridge can still be administered. +`PAUSE_CONFIG`, `CONSTANT_FEE_POLICY_CONFIG`) remain consumable while paused, so a paused bridge +can still be administered. The pause is toggled via the standards [`PAUSE_CONFIG`](#411-pause_config-standards) note, which dispatches to `PausableManager`'s `pause` / `unpause`. These have no entry in the bridge's @@ -265,6 +266,39 @@ The pause complements the `Authority` freeze switch: freezing blocks every autho procedure - including `remove_ger` - but not `claim` / `bridge_out`, while the pause is the inverse. +#### Fee schedule administration + +Both network accounts are deployed with a `BasicConstantFeePolicy` whose schedule prices every +note they can consume from that note's benchmarked consumption cost under the chain's +`FeeParameters` (`NetworkNotePricer::agglayer_bridge_fee_policy_manager` / +`agglayer_faucet_fee_policy_manager`). A schedule entry is a bare amount denominated in the +chain's fee asset, so it is only correct for the verification base fee it was priced under. + +Because that base fee is a chain parameter which moves independently of these accounts, both +install the `miden-standards` `ConstantFeeManager` and allowlist the +[`CONSTANT_FEE_POLICY_CONFIG`](#412-constant_fee_policy_config-standards) note, which dispatches +to its `set_note_fee`. Neither account maps that procedure in its `Authority` procedure-to-role +map, so authorization falls back to the `ADMIN` role, exactly as for the pause toggles. Without +it a schedule would be frozen at its deployment prices for the account's whole lifetime. + +Repricing the faucet matters as much as repricing the bridge. When the bridge creates a `MINT` or +`BURN` note it sizes that note's fee sponsorship by reading the *faucet's* schedule as a foreign +account, so a faucet left at stale prices makes the bridge under-sponsor the note it has just +created, and the faucet's transaction can no longer pay its own fee. A frozen faucet schedule +therefore stalls bridging, not merely faucet administration. + +The config note's own script root is deliberately scheduled at zero on both accounts, even though +it has a benchmarked cost like any other note. Consuming one is the only route to `set_note_fee`, +so a priced entry could raise the config note's own fee past what a sponsor covers and put the +schedule permanently out of reach. + +That reach is not hypothetical: a priced schedule requires every consumed note's fee to be +prepaid. A note whose schedule entry is non-zero can only be consumed together with +`FEE_SPONSORSHIP` notes bound to it that cover that entry, independently of the chain's own +verification base fee. Operators therefore fund a sponsorship alongside each management note +(`RBAC_CONFIG`, `NETWORK_ACCOUNT_CONFIG`, `PAUSE_CONFIG`), and repricing is the one +administrative action that never needs one. + --- ## 3. Contracts and Public Interfaces @@ -454,7 +488,8 @@ access-control components at account creation time. ### 3.2 Faucet Account Component The faucet account has the `agglayer_faucet` component (`components/faucet.masm`), -which is a thin wrapper, on top of `Ownable2Step` + `OwnerControlled`, that re-exports the +which is a thin wrapper, on top of `Ownable2Step` + the RBAC access-control stack +(`RoleBasedAccessControl` + `Authority::RbacControlled`), that re-exports the standard Miden fungible-faucet procedures: - `mint_and_send` (from `miden::standards::faucets::fungible::mint_and_send`) @@ -501,12 +536,25 @@ This is a re-export of `miden::standards::faucets::fungible::receive_and_burn`. |-----------|-----------|----------------|---------| | Faucet metadata (standard) | Value | `[token_supply, max_supply, decimals, token_symbol]` | Standard `NetworkFungibleFaucet` metadata | -**Companion component storage slots:** The faucet account also includes storage from -companion components required by `fungible::mint_and_send`: +**Companion component storage slots:** The faucet account also includes storage from its +companion components - those required by `fungible::mint_and_send`, plus the access-control and +fee-administration stack: - `Ownable2Step` owner config slot: stores the bridge account ID as owner. -- `OwnerControlled` slots (3): `active_policy_proc_root`, `allowed_policy_proc_roots`, - `policy_authority`. +- `TokenPolicyManager` slots: `active_policy_proc_root`, `allowed_policy_proc_roots` per policy + kind. +- `RoleBasedAccessControl` role storage, seeded with the deployment `ADMIN` member, and the + `Authority` config slot (`RbacControlled`) with an empty procedure-to-role map. +- `ConstantFeeManager` value slot: the ID of the fee schedule slot it reprices. + +**Access control:** minting and burning are gated on the `Ownable2Step` owner (the bridge), which +`MintOwnerOnly` / `BurnOwnerOnly` assert directly via `ownable2step::assert_sender_is_owner` +rather than through `Authority`. Everything else on the faucet - its fee schedule, its mint / burn +/ transfer policies and its metadata - is authority-gated and so resolves to the `ADMIN` role +through the unmapped-procedure fallback. The two authorities are therefore separate: `ADMIN` +administers the faucet's configuration without being able to mint, and the bridge mints without +being able to reconfigure. Note that `ADMIN` can retarget the `Ownable2Step` owner, so it must be +held by a strongly authenticated account (see [Section 1](#1-entities-and-trust-model)). --- @@ -1072,7 +1120,7 @@ note via `output_note::add_asset`. **Purpose:** Toggles the bridge's emergency pause (see [Section 2.5](#25-administration)). This is the `miden-standards` `PAUSE_CONFIG` note (`pause_config.masm` / `PauseConfigNote`), not an agglayer-specific note; the bridge merely includes its script root in -[`AggLayerBridge::allowed_notes`]. Its single storage felt is a selector: `0` dispatches to +[`AggLayerBridge::bridge_notes`]. Its single storage felt is a selector: `0` dispatches to `PausableManager::pause`, `1` to `PausableManager::unpause`. **Consumption:** The script loads the selector and `call`s the matching `PausableManager` @@ -1092,6 +1140,40 @@ with clearer error reporting for non-public targets. --- +### 4.12 CONSTANT_FEE_POLICY_CONFIG (standards) + +**Purpose:** Reprices one entry of a network account's `BasicConstantFeePolicy` fee schedule +after deployment (see [Section 2.5](#25-administration)). This is the `miden-standards` +`CONSTANT_FEE_POLICY_CONFIG` note (`constant_fee_policy_config.masm` / +`ConstantFeePolicyConfigNote`), not an agglayer-specific note; both the bridge and the faucet +include its script root in their allowlists. Its storage is +`[NOTE_SCRIPT_ROOT, FEE_ASSET_ID, FEE_ASSET_VALUE]`: the schedule key to rewrite, and the fee to +schedule for it. + +**Consumption:** The script asserts the consuming account matches its `NetworkAccountTarget` +attachment, then `call`s `ConstantFeeManager::set_note_fee`, which runs +`authority::assert_authorized` before writing the schedule entry. The supplied asset's ID must +match the account's configured fee asset ID. On both AggLayer accounts `set_note_fee` has no +mapped role, so the note sender must hold the `ADMIN` role. + +`set_note_fee` carries no `pausable::assert_not_paused`, so a paused bridge can still be +repriced, consistent with the other management notes. + +Because the note is allowlisted and scheduled free, anyone can author one targeting either +account; unauthorized or misdirected notes abort at the target and authorization checks with no +state change, but since the transaction aborts no nullifier is produced and such notes remain as +permanently-unconsumable entries that may require operator-side filtering. This is inherent to +every allowlisted network-note root, not specific to this note. + +#### Permissions + +| Role | Enforcement | +|------|------------| +| **Issuer** | Holders of the `ADMIN` role only -- **enforced** by `ConstantFeeManager::set_note_fee` via `authority::assert_authorized` (unmapped-procedure fallback) | +| **Consumer** | Bridge or faucet account -- **enforced**: the script asserts the consuming account matches the `NetworkAccountTarget` attachment | + +--- + ## 5. Amount Conversion *This section is a placeholder. Content to be added.* diff --git a/crates/miden-agglayer/build.rs b/crates/miden-agglayer/build.rs index a041096e8e..dc0d9b186d 100644 --- a/crates/miden-agglayer/build.rs +++ b/crates/miden-agglayer/build.rs @@ -25,9 +25,13 @@ use miden_protocol_build_utils::{ generate_error_file, }; use miden_standards::StandardsLib; -use miden_standards::account::access::{AccessControl, Authority, Pausable, PausableManager}; +use miden_standards::account::access::{AccessControl, Pausable, PausableManager}; use miden_standards::account::auth::AuthNetworkAccount; -use miden_standards::account::fees::{BasicConstantFeePolicy, FeePolicyManager}; +use miden_standards::account::fees::{ + BasicConstantFeePolicy, + ConstantFeeManager, + FeePolicyManager, +}; use miden_standards::account::policies::{ BurnPolicy, MintPolicy, @@ -188,12 +192,12 @@ fn generate_agglayer_constants( AccountComponent::new(Arc::unwrap_or_clone(package), vec![], dummy_metadata.clone()) .unwrap(); - // The faucet account includes Ownable2Step and OwnerControlled components for mint and burn - // policies alongside the agglayer faucet component, since - // fungible::mint_and_send requires these for access control. + // The faucet account includes an Ownable2Step component alongside the agglayer faucet + // component, since fungible::mint_and_send gates minting on the owner, plus the RBAC + // access-control stack that gates everything else. // - // 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. + // Use a dummy owner and admin for commitment computation - the real ones are 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, ) @@ -231,11 +235,19 @@ fn generate_agglayer_constants( }); components.push(AccountComponent::from(Pausable::unpaused())); components.push(AccountComponent::from(PausableManager)); + components + .push(AccountComponent::from(ConstantFeeManager::for_basic_constant_fee_policy())); } else if component_name == "faucet" { components.push(AccountComponent::from( miden_standards::account::access::Ownable2Step::new(dummy_owner), )); - components.push(AccountComponent::from(Authority::OwnerControlled)); + // The faucet installs the same RBAC access-control stack as the bridge, so its + // ADMIN role can reprice the fee schedule and administer its policies. Minting and + // burning remain gated on the Ownable2Step owner, which the policies check directly. + components.extend(AccessControl::Rbac { + admin: dummy_owner, + procedure_roles: std::collections::BTreeMap::new(), + }); // Mirror the component order used by `create_agglayer_faucet_builder` in lib.rs so // the compile-time code commitment matches the one computed at runtime. // @@ -251,6 +263,8 @@ fn generate_agglayer_constants( .build(); components.extend(token_policy_manager); + components + .push(AccountComponent::from(ConstantFeeManager::for_basic_constant_fee_policy())); } // use `AccountCode` to merge codes of agglayer and authentication components diff --git a/crates/miden-agglayer/src/bridge.rs b/crates/miden-agglayer/src/bridge.rs index 6f33c38dad..e4c4c64909 100644 --- a/crates/miden-agglayer/src/bridge.rs +++ b/crates/miden-agglayer/src/bridge.rs @@ -20,7 +20,9 @@ use miden_protocol::note::{Note, NoteScriptRoot}; #[cfg(any(feature = "testing", test))] use miden_standards::account::access::PausableStorage; use miden_standards::account::access::RoleConfig; +use miden_standards::account::auth::AuthNetworkAccount; use miden_standards::note::{ + ConstantFeePolicyConfigNote, NetworkAccountTarget, NetworkAccountTargetError, NoteExecutionHint, @@ -470,20 +472,21 @@ impl AggLayerBridge { // ALLOWED SCRIPTS // -------------------------------------------------------------------------------------------- - /// Returns the set of input-note script roots that AggLayer bridge accounts accept. + /// Returns the bridge-specific input-note script roots. /// - /// The bridge's [`AuthNetworkAccount`] component is initialized with this allowlist, which - /// means any transaction consuming a note outside this set is rejected before reaching - /// `output_note::create`. + /// The bridge's [`AuthNetworkAccount`] component is initialized with this set as its explicit + /// allowlist; [`Self::allowed_notes`] is the resulting full set of notes the account accepts. /// - /// Besides the agglayer-specific notes, the bridge accepts two standards notes: the - /// [`PauseConfigNote`], so the `ADMIN` role can toggle the emergency pause, and the + /// Besides the agglayer-specific notes, the set contains three standards notes: the + /// [`PauseConfigNote`], so the `ADMIN` role can toggle the emergency pause, the /// role-management [`RbacConfigNote`], which makes the bridge's RBAC role graph mutable /// on-chain (see the [`RbacConfigNote`] security considerations and the Administration - /// section of `SPEC.md` for the associated caveats). + /// section of `SPEC.md` for the associated caveats), and the + /// [`ConstantFeePolicyConfigNote`], so the `ADMIN` role can reprice the bridge's fee + /// schedule after deployment. /// /// [`AuthNetworkAccount`]: miden_standards::account::auth::AuthNetworkAccount - pub fn allowed_notes() -> BTreeSet { + pub fn bridge_notes() -> BTreeSet { BTreeSet::from([ ClaimNote::script_root(), B2AggNote::script_root(), @@ -493,9 +496,26 @@ impl AggLayerBridge { RemoveGerNote::script_root(), PauseConfigNote::script_root(), RbacConfigNote::script_root(), + ConstantFeePolicyConfigNote::script_root(), ]) } + /// Returns every input-note script root a newly deployed AggLayer bridge account accepts. + /// + /// This is [`Self::bridge_notes`] plus the configuration and sponsorship notes that + /// [`AuthNetworkAccount`] adds to every standard network account. Any transaction consuming a + /// note outside the account's allowlist is rejected before reaching `output_note::create`, + /// and the bridge's deployed fee policy must schedule a fee for every root in it. + /// + /// This is the deployment-time allowlist: an `ADMIN`-authored `NETWORK_ACCOUNT_CONFIG` note + /// can add or remove entries later, so read a live account's allowlist via + /// [`NetworkAccount::allowed_notes`](miden_standards::account::auth::NetworkAccount::allowed_notes). + pub fn allowed_notes() -> BTreeSet { + let mut notes = Self::bridge_notes(); + notes.extend(AuthNetworkAccount::default_allowed_note_scripts()); + notes + } + // PAUSE NOTE // -------------------------------------------------------------------------------------------- diff --git a/crates/miden-agglayer/src/faucet.rs b/crates/miden-agglayer/src/faucet.rs index 0cc73d42f1..06c1d4bb47 100644 --- a/crates/miden-agglayer/src/faucet.rs +++ b/crates/miden-agglayer/src/faucet.rs @@ -12,6 +12,7 @@ use miden_protocol::asset::{AssetAmount, TokenSymbol}; use miden_protocol::errors::AccountIdError; use miden_protocol::note::NoteScriptRoot; use miden_standards::account::access::{Authority, Ownable2Step}; +use miden_standards::account::auth::AuthNetworkAccount; use miden_standards::account::faucets::{FungibleFaucet, FungibleFaucetError, TokenName}; use miden_standards::account::policies::TokenPolicyManager; pub use miden_standards::interop::eth::{ @@ -20,7 +21,7 @@ pub use miden_standards::interop::eth::{ EthAmountError, EthEmbeddedAccountId, }; -use miden_standards::note::{BurnNote, MintNote}; +use miden_standards::note::{BurnNote, ConstantFeePolicyConfigNote, MintNote}; use thiserror::Error; use super::agglayer_faucet_component_package; @@ -151,14 +152,39 @@ impl AggLayerFaucet { // ALLOWED NOTES // -------------------------------------------------------------------------------------------- - /// Returns the set of input-note script roots that AggLayer faucet accounts accept. + /// Returns the faucet-specific input-note script roots. /// - /// The faucet's [`AuthNetworkAccount`] component is initialized with this allowlist so only - /// MINT and BURN notes can drive the faucet. + /// The faucet's [`AuthNetworkAccount`] component is initialized with this set as its explicit + /// allowlist; [`Self::allowed_notes`] is the resulting full set of notes the account accepts. + /// + /// The set contains only the MINT and BURN notes plus the [`ConstantFeePolicyConfigNote`], + /// through which the `ADMIN` role reprices the faucet's fee schedule after deployment. The + /// bridge sizes the sponsorship of every MINT and BURN note it creates from that schedule, so + /// a schedule frozen at its deployment prices stalls bridging as soon as the chain's + /// verification base fee moves. /// /// [`AuthNetworkAccount`]: miden_standards::account::auth::AuthNetworkAccount + pub fn faucet_notes() -> BTreeSet { + BTreeSet::from([ + MintNote::script_root(), + BurnNote::script_root(), + ConstantFeePolicyConfigNote::script_root(), + ]) + } + + /// Returns every input-note script root a newly deployed AggLayer faucet account accepts. + /// + /// This is [`Self::faucet_notes`] plus the configuration and sponsorship notes that + /// [`AuthNetworkAccount`] adds to every standard network account. The faucet's deployed fee + /// policy must schedule a fee for every root in this set. + /// + /// This is the deployment-time allowlist: an `ADMIN`-authored `NETWORK_ACCOUNT_CONFIG` note + /// can add or remove entries later, so read a live account's allowlist via + /// [`NetworkAccount::allowed_notes`](miden_standards::account::auth::NetworkAccount::allowed_notes). pub fn allowed_notes() -> BTreeSet { - BTreeSet::from([MintNote::script_root(), BurnNote::script_root()]) + let mut notes = Self::faucet_notes(); + notes.extend(AuthNetworkAccount::default_allowed_note_scripts()); + notes } /// Extracts the underlying [`FungibleFaucet`] component (which holds the token metadata) diff --git a/crates/miden-agglayer/src/lib.rs b/crates/miden-agglayer/src/lib.rs index 0606655466..af43e6dd48 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -2,13 +2,13 @@ extern crate alloc; -use alloc::collections::BTreeSet; +use alloc::collections::BTreeMap; use miden_core::{Felt, Word}; use miden_protocol::account::{Account, AccountBuilder, AccountComponent, AccountId}; use miden_protocol::assembly::Path; -use miden_protocol::asset::{AssetAmount, TokenSymbol}; -use miden_protocol::note::{NoteScript, NoteScriptRoot}; +use miden_protocol::asset::TokenSymbol; +use miden_protocol::note::NoteScript; use miden_protocol::vm::Package; use miden_standards::account::access::{ Authority, @@ -19,7 +19,7 @@ use miden_standards::account::access::{ RoleConfig, }; use miden_standards::account::auth::NetworkAccount; -use miden_standards::account::fees::{BasicConstantFeePolicy, FeePolicyManager}; +use miden_standards::account::fees::{ConstantFeeManager, FeePolicyManager}; use miden_standards::account::policies::{ BurnAllowAll, BurnPolicy, @@ -136,7 +136,7 @@ fn agglayer_faucet_component_package() -> Package { /// - `token_symbol`: The symbol for the fungible token (e.g., "AGG") /// - `decimals`: Number of decimal places for the token /// - `max_supply`: Maximum supply of the token -/// - `token_supply`: Initial outstanding token supply (0 for new faucets) +/// - `initial_supply`: Initial outstanding token supply (0 for new faucets) /// /// # Returns /// Returns an [`AccountComponent`] configured for agglayer faucet operations. @@ -147,42 +147,66 @@ fn create_agglayer_faucet_component( token_symbol: &str, decimals: u8, max_supply: Felt, - token_supply: Felt, + initial_supply: Felt, ) -> AccountComponent { let symbol = TokenSymbol::new(token_symbol).expect("token symbol should be valid"); - AggLayerFaucet::new(symbol, decimals, max_supply, token_supply) + AggLayerFaucet::new(symbol, decimals, max_supply, initial_supply) .expect("agglayer faucet metadata should be valid") .into() } -/// Returns the `FeePolicyManager` installed on the agglayer bridge and faucet accounts so their -/// auth procedure can collect sponsored fees and answer sponsorship fee estimates. The active -/// policy schedules an explicit 0 fee for every note script in `auth`'s allowlist, so it charges -/// and collects nothing while still letting fee estimation resolve every note the account can -/// consume; a real fee faucet and schedule are configured when fees are enabled on these accounts. -/// -/// Because every scheduled fee is 0, the fee asset (and hence the placeholder faucet id below) -/// never funds a transfer; only the components' procedure code contributes to the account code -/// commitment, which `build.rs` mirrors when computing the compile-time commitment constants (the -/// fee schedule entries and the manager's fee asset id are storage, so they do not affect the -/// commitment). -fn agglayer_fee_policy_manager(allowed_notes: BTreeSet) -> FeePolicyManager { - // A placeholder public faucet id; see the note above on why its value is immaterial. - let fee_faucet_id = AccountId::from_hex("0xab0000000000cd110000ac000000de") - .expect("placeholder fee faucet id is valid"); - - // The basic constant fee policy aborts fee estimation for note scripts without a schedule - // entry, so enumerate the allowlist and schedule each as free. - let mut basic_constant_fee_policy = BasicConstantFeePolicy::new(); - for note_script in allowed_notes { - basic_constant_fee_policy = - basic_constant_fee_policy.with_fee(note_script, AssetAmount::ZERO); +impl AggLayerBridge { + /// Returns an [`AccountBuilder`] for a bridge account with the specified deployment + /// configuration. + /// + /// `fee_policy_manager` prices the notes the bridge consumes and must cover every root + /// returned by [`AggLayerBridge::allowed_notes`]; production callers should normally + /// construct it with `NetworkNotePricer::agglayer_bridge_fee_policy_manager` from the + /// network's current fee parameters. + pub fn account_builder( + seed: Word, + admin: AccountId, + roles: BridgeRoles, + network_id: u32, + fee_policy_manager: FeePolicyManager, + ) -> AccountBuilder { + create_bridge_account_builder(seed, admin, roles, network_id, fee_policy_manager) } +} - FeePolicyManager::builder() - .active_fee_policy(basic_constant_fee_policy.into()) - .fee_faucet_id(fee_faucet_id) - .build() +impl AggLayerFaucet { + /// Returns an [`AccountBuilder`] for a faucet account with the specified deployment + /// configuration. + /// + /// `admin` is seeded as the sole member of the faucet's built-in `ADMIN` role, which gates + /// every authority-controlled procedure, including the `set_note_fee` that reprices the fee + /// schedule. `bridge_account_id` stays the [`Ownable2Step`] owner and so remains the only + /// account that can mint or burn. The faucet starts with no outstanding supply. + /// + /// `fee_policy_manager` prices the notes the faucet consumes and must cover every root + /// returned by [`AggLayerFaucet::allowed_notes`]; production callers should normally + /// construct it with `NetworkNotePricer::agglayer_faucet_fee_policy_manager` from the + /// network's current fee parameters. + pub fn account_builder( + seed: Word, + token_symbol: &str, + decimals: u8, + max_supply: Felt, + admin: AccountId, + bridge_account_id: AccountId, + fee_policy_manager: FeePolicyManager, + ) -> AccountBuilder { + create_agglayer_faucet_builder( + seed, + token_symbol, + decimals, + max_supply, + Felt::ZERO, + admin, + bridge_account_id, + fee_policy_manager, + ) + } } /// Creates a complete bridge account builder with the standard configuration. @@ -199,7 +223,7 @@ fn agglayer_fee_policy_manager(allowed_notes: BTreeSet) -> FeePo /// bridge's [`AggLayerBridge::network_id_slot_name`] storage slot at account creation. /// /// The builder is pre-wired with the [`AuthNetworkAccount`] auth component, initialized with -/// [`AggLayerBridge::allowed_notes()`] so the bridge only accepts its sanctioned input notes. The +/// [`AggLayerBridge::bridge_notes()`] so the bridge only accepts its sanctioned input notes. The /// tx-script allowlist contains only the canonical `ExpirationTransactionScript` so the network /// transaction builder can bound how long the bridge's transactions stay valid. /// @@ -207,14 +231,22 @@ fn agglayer_fee_policy_manager(allowed_notes: BTreeSet) -> FeePo /// pauses, gated by the `ADMIN` role via the [`Authority`] unmapped-procedure fallback. While /// paused, all bridge entry points abort except `remove_ger`, which stays available so a /// fraudulent GER can still be revoked. +/// +/// Finally, it installs the [`ConstantFeeManager`], whose `set_note_fee` reprices the deployed +/// [`BasicConstantFeePolicy`](miden_standards::account::fees::BasicConstantFeePolicy) schedule. +/// It is left out of +/// [`AggLayerBridge::procedure_roles`] on purpose, so the same unmapped-procedure fallback gates +/// it on the `ADMIN` role, and it is driven by the allowlisted `CONSTANT_FEE_POLICY_CONFIG` note. +/// Without it the schedule would be frozen at its deployment prices, which go stale as soon as +/// the chain's verification base fee changes. fn create_bridge_account_builder( seed: Word, admin: AccountId, roles: BridgeRoles, network_id: u32, + fee_policy_manager: FeePolicyManager, ) -> AccountBuilder { - let fee_policy_manager = agglayer_fee_policy_manager(AggLayerBridge::allowed_notes()); - NetworkAccount::builder(seed.into(), AggLayerBridge::allowed_notes(), fee_policy_manager) + NetworkAccount::builder(seed.into(), AggLayerBridge::bridge_notes(), fee_policy_manager) .expect("bridge note allowlist is non-empty") .with_component(AggLayerBridge::new(network_id)) .with_component( @@ -229,20 +261,24 @@ fn create_bridge_account_builder( }) .with_component(Pausable::unpaused()) .with_component(PausableManager) + .with_component(ConstantFeeManager::for_basic_constant_fee_policy()) } /// Creates a new bridge account with the standard configuration. /// /// 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`]). `network_id` is the AggLayer network ID assigned to the Miden chain. +/// [`BridgeRoles`]). `network_id` is the AggLayer network ID assigned to the Miden chain. The +/// supplied `fee_policy_manager` must price every root returned by +/// [`AggLayerBridge::allowed_notes`]. pub fn create_bridge_account( seed: Word, admin: AccountId, roles: BridgeRoles, network_id: u32, + fee_policy_manager: FeePolicyManager, ) -> Account { - create_bridge_account_builder(seed, admin, roles, network_id) + AggLayerBridge::account_builder(seed, admin, roles, network_id, fee_policy_manager) .build() .expect("bridge account should be valid") } @@ -252,26 +288,40 @@ pub fn create_bridge_account( /// The builder includes: /// - The `AggLayerFaucet` component (token metadata only). /// - The `Ownable2Step` component (bridge account ID as owner for mint authorization). -/// - A [`TokenPolicyManager`] (owner-controlled) configured with [`MintPolicy::owner_only`] and +/// - A [`TokenPolicyManager`] configured with [`MintPolicy::owner_only`] and /// [`BurnPolicy::owner_only`]. The manager additionally registers `BurnAllowAll::root()` as an /// allowed burn policy so the owner can open burns at runtime via `set_burn_policy`. The active /// mint policy component (`MintOwnerOnly`) and burn policy component (`BurnOwnerOnly`) are /// produced by the manager; `BurnAllowAll` is installed separately as the additional allowed burn /// policy procedure. +/// - The [`RoleBasedAccessControl`] stack, seeding `admin` as the sole member of the built-in +/// `ADMIN` role, with [`Authority::RbacControlled`] and an empty procedure-role map so every +/// authority-gated procedure resolves to `ADMIN` through the unmapped-procedure fallback. +/// - The [`ConstantFeeManager`], whose `ADMIN`-gated `set_note_fee` reprices the deployed +/// [`BasicConstantFeePolicy`](miden_standards::account::fees::BasicConstantFeePolicy) schedule, +/// driven by the allowlisted `CONSTANT_FEE_POLICY_CONFIG` note. /// - The network-account auth component, installed via [`NetworkAccount::builder`] with -/// [`AggLayerFaucet::allowed_notes()`] so the faucet only accepts MINT and BURN notes. The -/// tx-script allowlist contains only the canonical +/// [`AggLayerFaucet::faucet_notes()`]. The tx-script allowlist contains only the canonical /// [`ExpirationTransactionScript`](miden_standards::tx_script::ExpirationTransactionScript). -fn create_agglayer_faucet_builder( +/// +/// Minting and burning are authorized independently of [`Authority`]: `MintOwnerOnly` and +/// `BurnOwnerOnly` call `ownable2step::assert_sender_is_owner` directly, so they remain gated on +/// the bridge as the [`Ownable2Step`] owner. `admin` therefore administers the faucet's +/// configuration (its fee schedule, its policies and its metadata) without gaining the ability to +/// mint, while the bridge keeps minting exactly as before. Note that `ADMIN` can retarget the +/// [`Ownable2Step`] owner, so it must be held by a strongly authenticated account. +pub(crate) fn create_agglayer_faucet_builder( seed: Word, token_symbol: &str, decimals: u8, max_supply: Felt, - token_supply: Felt, + initial_supply: Felt, + admin: AccountId, bridge_account_id: AccountId, + fee_policy_manager: FeePolicyManager, ) -> AccountBuilder { let agglayer_component = - create_agglayer_faucet_component(token_symbol, decimals, max_supply, token_supply); + create_agglayer_faucet_component(token_symbol, decimals, max_supply, initial_supply); // `allow_all` is explicitly registered as Reserved so the owner can open burns at runtime // via `set_burn_policy`. @@ -283,90 +333,48 @@ fn create_agglayer_faucet_builder( .active_receive_policy(TransferPolicy::allow_all()) .build(); - let fee_policy_manager = agglayer_fee_policy_manager(AggLayerFaucet::allowed_notes()); - NetworkAccount::builder(seed.into(), AggLayerFaucet::allowed_notes(), fee_policy_manager) + NetworkAccount::builder(seed.into(), AggLayerFaucet::faucet_notes(), fee_policy_manager) .expect("faucet note allowlist is non-empty") .with_component(agglayer_component) .with_component(Ownable2Step::new(bridge_account_id)) - .with_component(Authority::OwnerControlled) + .with_component( + RoleBasedAccessControl::with_admins([admin]) + .expect("the faucet seeds a non-empty ADMIN role"), + ) + .with_component(Authority::RbacControlled { procedure_roles: BTreeMap::new() }) .with_components(token_policy_manager) .with_component(BurnAllowAll) + .with_component(ConstantFeeManager::for_basic_constant_fee_policy()) } /// Creates a new agglayer faucet account with the specified configuration. /// -/// This creates a new account suitable for production use. +/// This creates a new account suitable for production use. `admin` is seeded as the faucet's +/// `ADMIN` role member and administers its configuration; `bridge_account_id` stays the owner and +/// so remains the only minter. The supplied `fee_policy_manager` must price every root returned by +/// [`AggLayerFaucet::allowed_notes`]. pub fn create_agglayer_faucet( seed: Word, token_symbol: &str, decimals: u8, max_supply: Felt, + admin: AccountId, bridge_account_id: AccountId, + fee_policy_manager: FeePolicyManager, ) -> Account { - create_agglayer_faucet_builder( + AggLayerFaucet::account_builder( seed, token_symbol, decimals, max_supply, - Felt::ZERO, + admin, bridge_account_id, + fee_policy_manager, ) .build() .expect("agglayer faucet account should be valid") } -/// Creates an existing agglayer faucet account with the specified configuration. -/// -/// This creates an existing account suitable for testing scenarios. -#[cfg(any(feature = "testing", test))] -pub fn create_existing_agglayer_faucet( - seed: Word, - token_symbol: &str, - decimals: u8, - max_supply: Felt, - token_supply: Felt, - bridge_account_id: AccountId, -) -> Account { - create_agglayer_faucet_builder( - seed, - token_symbol, - decimals, - max_supply, - token_supply, - bridge_account_id, - ) - .build_existing() - .expect("agglayer faucet account should be valid") -} - -/// Creates an existing agglayer faucet account with the specified configuration and the asset -/// callback flag enabled. -/// -/// This creates an existing account suitable for testing scenarios. -#[cfg(any(feature = "testing", test))] -pub fn create_existing_agglayer_faucet_with_callbacks( - seed: Word, - token_symbol: &str, - decimals: u8, - max_supply: Felt, - token_supply: Felt, - bridge_account_id: AccountId, -) -> Account { - use miden_protocol::account::AssetCallbackFlag; - - create_agglayer_faucet_builder( - seed, - token_symbol, - decimals, - max_supply, - token_supply, - bridge_account_id, - ) - .with_asset_callbacks(AssetCallbackFlag::Enabled) - .build_existing() - .expect("agglayer faucet account should be valid") -} - // TESTS // ================================================================================================ @@ -376,7 +384,10 @@ mod tests { use miden_standards::tx_script::ExpirationTransactionScript; use super::*; - use crate::testing::create_existing_bridge_account_with_roles; + use crate::testing::{ + create_existing_agglayer_faucet, + create_existing_bridge_account_with_roles, + }; /// Both agglayer network accounts allowlist the canonical [`ExpirationTransactionScript`], /// which the network transaction builder attaches to every network transaction. diff --git a/crates/miden-agglayer/src/testing/mod.rs b/crates/miden-agglayer/src/testing/mod.rs index 870b6efdf1..d4ab94c6ce 100644 --- a/crates/miden-agglayer/src/testing/mod.rs +++ b/crates/miden-agglayer/src/testing/mod.rs @@ -1,6 +1,7 @@ //! Shared test vector types and embedded JSON constants for agglayer testing. //! //! This module is gated behind the `testing` feature and provides: +//! - Account fixture helpers (zero-fee policy manager, existing bridge and faucet accounts) //! - Embedded JSON test vector files from `solidity-compat/test-vectors/` //! - Serde helpers for deserializing Foundry-generated JSON //! - Deserialized test vector structs (`LeafValueVector`, `ProofValueVector`, etc.) @@ -13,28 +14,50 @@ 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::account::{Account, AccountBuilder, AccountId}; +use miden_protocol::asset::AssetAmount; +use miden_protocol::note::NoteScriptRoot; 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 miden_protocol::{Felt, Word}; +use miden_standards::account::fees::{BasicConstantFeePolicy, FeePolicyManager}; use miden_standards::interop::eth::{EthAddress, EthAmount}; use serde::Deserialize; use crate::claim_note::{ProofData, SmtNode}; use crate::{ + AggLayerBridge, + AggLayerFaucet, BridgeRoles, CgiChainHash, ExitRoot, GlobalIndex, LeafData, MetadataHash, - create_bridge_account_builder, }; -// BRIDGE ACCOUNT HELPERS +// ACCOUNT HELPERS // ================================================================================================ +/// Returns a zero-fee policy manager for tests that exercise AggLayer behavior independently of +/// fee sponsorship. Production constructors require their deployment-time manager explicitly. +pub fn zero_fee_policy_manager(allowed_notes: BTreeSet) -> FeePolicyManager { + let fee_faucet_id = AccountId::from_hex("0xab0000000000cd110000ac000000de") + .expect("placeholder fee faucet id is valid"); + + let mut basic_constant_fee_policy = BasicConstantFeePolicy::new(); + for note_script in allowed_notes { + basic_constant_fee_policy = + basic_constant_fee_policy.with_fee(note_script, AssetAmount::ZERO); + } + + FeePolicyManager::builder() + .active_fee_policy(basic_constant_fee_policy.into()) + .fee_faucet_id(fee_faucet_id) + .build() +} + /// Returns the fixed dummy account ID commonly seeded as the bridge's built-in `ADMIN` role /// member in tests. /// @@ -56,6 +79,7 @@ pub fn create_existing_bridge_account_with_roles( ger_remover: AccountId, network_id: u32, ) -> Account { + let fee_policy_manager = zero_fee_policy_manager(AggLayerBridge::allowed_notes()); let roles = BridgeRoles::new( BTreeSet::from([faucet_manager]), BTreeSet::from([ger_injector]), @@ -63,11 +87,61 @@ pub fn create_existing_bridge_account_with_roles( ) .expect("single-holder role sets are non-empty"); - create_bridge_account_builder(seed, admin, roles, network_id) + AggLayerBridge::account_builder(seed, admin, roles, network_id, fee_policy_manager) .build_existing() .expect("bridge account should be valid") } +/// Returns an [`AccountBuilder`] for an AggLayer faucet test fixture. +/// +/// Unlike [`AggLayerFaucet::account_builder`], this takes the initial outstanding supply, which +/// production faucets always deploy at zero. +pub fn faucet_account_builder( + seed: Word, + token_symbol: &str, + decimals: u8, + max_supply: Felt, + initial_supply: Felt, + admin: AccountId, + bridge_account_id: AccountId, + fee_policy_manager: FeePolicyManager, +) -> AccountBuilder { + crate::create_agglayer_faucet_builder( + seed, + token_symbol, + decimals, + max_supply, + initial_supply, + admin, + bridge_account_id, + fee_policy_manager, + ) +} + +/// Creates an existing agglayer faucet account with the specified configuration, priced by a +/// zero-fee policy and administered by [`bridge_admin_account_id`]. +pub fn create_existing_agglayer_faucet( + seed: Word, + token_symbol: &str, + decimals: u8, + max_supply: Felt, + initial_supply: Felt, + bridge_account_id: AccountId, +) -> Account { + faucet_account_builder( + seed, + token_symbol, + decimals, + max_supply, + initial_supply, + bridge_admin_account_id(), + bridge_account_id, + zero_fee_policy_manager(AggLayerFaucet::allowed_notes()), + ) + .build_existing() + .expect("agglayer faucet account should be valid") +} + // EMBEDDED TEST VECTOR JSON FILES // ================================================================================================ diff --git a/crates/miden-standards/src/account/auth/network_account/auth_network_account.rs b/crates/miden-standards/src/account/auth/network_account/auth_network_account.rs index 06764b27b6..18a61e5ca8 100644 --- a/crates/miden-standards/src/account/auth/network_account/auth_network_account.rs +++ b/crates/miden-standards/src/account/auth/network_account/auth_network_account.rs @@ -232,12 +232,20 @@ impl AuthNetworkAccount { mut allowed_notes: BTreeSet, fee_policy_manager: FeePolicyManager, ) -> Result { - allowed_notes.insert(NetworkAccountConfigNote::script_root()); - allowed_notes.insert(FeeSponsorshipNote::script_root()); + allowed_notes.extend(Self::default_allowed_note_scripts()); Ok(Self::custom(allowed_notes, fee_policy_manager)? .with_allowed_tx_scripts([ExpirationTransactionScript::script_root()])) } + /// Returns the note script roots added to the allowlist of every standard network account. + /// + /// Callers that derive configuration from a network account's complete allowlist, such as a + /// root-keyed fee schedule, should include these roots in addition to their account-specific + /// roots. + pub fn default_allowed_note_scripts() -> [NoteScriptRoot; 2] { + [NetworkAccountConfigNote::script_root(), FeeSponsorshipNote::script_root()] + } + /// Creates a raw [`AuthNetworkAccount`] component from the given note-script allowlist, with an /// empty tx-script allowlist and without any default configuration. /// diff --git a/crates/miden-testing/tests/agglayer/bridge_in.rs b/crates/miden-testing/tests/agglayer/bridge_in.rs index 0289d9648c..99668439b1 100644 --- a/crates/miden-testing/tests/agglayer/bridge_in.rs +++ b/crates/miden-testing/tests/agglayer/bridge_in.rs @@ -9,6 +9,7 @@ use miden_agglayer::errors::{ ERR_GER_NOT_FOUND, ERR_TOKEN_NOT_REGISTERED, }; +use miden_agglayer::testing::create_existing_agglayer_faucet; use miden_agglayer::{ B2AggNote, ClaimNote, @@ -21,12 +22,10 @@ use miden_agglayer::{ SmtNode, UpdateGerNote, agglayer_package, - create_existing_agglayer_faucet, - create_existing_agglayer_faucet_with_callbacks, }; use miden_protocol::Felt; use miden_protocol::account::auth::AuthScheme; -use miden_protocol::account::{Account, AccountId, AccountType}; +use miden_protocol::account::{Account, AccountId, AccountType, AssetCallbackFlag}; use miden_protocol::asset::{Asset, AssetAmount, FungibleAsset}; use miden_protocol::crypto::SequentialCommit; use miden_protocol::crypto::rand::FeltRng; @@ -37,7 +36,7 @@ use miden_standards::account::wallets::BasicWallet; use miden_standards::code_builder::CodeBuilder; use miden_standards::errors::standards::ERR_FUNGIBLE_MINT_NOTE_ASSET_NOT_FROM_THIS_FAUCET; use miden_standards::interop::eth::{EthAddress, EthEmbeddedAccountId}; -use miden_standards::note::P2idNote; +use miden_standards::note::{FeeSponsorshipNote, P2idNote, StandardNote}; use miden_standards::testing::account_component::IncrNonceAuthComponent; use miden_standards::testing::mock_account::MockAccountExt; use miden_testing::{AccountState, Auth, MockChain, assert_transaction_executor_error}; @@ -49,8 +48,14 @@ use super::test_utils::{ MIDEN_NETWORK_ID, MerkleProofVerificationFile, SOLIDITY_MERKLE_PROOF_VECTORS, + VERIFICATION_BASE_FEE, + add_fee_sponsorship, + assert_transaction_paid_fee, bridge_admin_account_id, create_existing_bridge_account_with_roles, + create_existing_priced_bridge, + find_output_note, + priced_faucet_builder, }; // CONSTANTS @@ -125,14 +130,23 @@ fn merkle_proof_verification_code( /// `claim_asset_vectors_l1_tx.json`, produced by simulating a `bridgeAsset()` call. /// - [`ClaimDataSource::L2ToMiden`]: uses rollup deposit data from /// `claim_asset_vectors_l2_tx.json`, produced by simulating a rollup deposit. +/// +/// The additional fee-enabled L1 case installs production-priced policies on the bridge and +/// faucet, pairs every network input with its sponsorship, and verifies that each network-account +/// transaction pays a non-zero fee before the final destination balance assertion. #[rstest::rstest] -#[case::l1_to_miden(ClaimDataSource::L1ToMiden)] -#[case::l2_to_miden(ClaimDataSource::L2ToMiden)] +#[case::l1_to_miden(ClaimDataSource::L1ToMiden, 0)] +#[case::l2_to_miden(ClaimDataSource::L2ToMiden, 0)] +#[case::l1_to_miden_with_fees(ClaimDataSource::L1ToMiden, VERIFICATION_BASE_FEE)] #[tokio::test] -async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> anyhow::Result<()> { +async fn test_bridge_in_claim_to_p2id( + #[case] data_source: ClaimDataSource, + #[case] verification_base_fee: u32, +) -> anyhow::Result<()> { use miden_agglayer::AggLayerBridge; - let mut builder = MockChain::builder(); + let fees_enabled = verification_base_fee > 0; + let mut builder = MockChain::builder().verification_base_fee(verification_base_fee); // CREATE FAUCET MANAGER ACCOUNT (sends CONFIG_AGG_BRIDGE notes) // -------------------------------------------------------------------------------------------- @@ -155,14 +169,14 @@ 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_with_roles( + let bridge_account = create_existing_priced_bridge( bridge_seed, bridge_admin_account_id(), faucet_manager.id(), ger_injector.id(), ger_remover.id(), - MIDEN_NETWORK_ID, - ); + verification_base_fee, + )?; assert_eq!(AggLayerBridge::network_id(&bridge_account)?, MIDEN_NETWORK_ID); builder.add_account(bridge_account.clone())?; @@ -182,14 +196,17 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a let origin_network = leaf_data.origin_network; let scale = 10u8; - let agglayer_faucet = create_existing_agglayer_faucet_with_callbacks( + let agglayer_faucet = priced_faucet_builder( agglayer_faucet_seed, token_symbol, decimals, max_supply, Felt::ZERO, bridge_account.id(), - ); + verification_base_fee, + )? + .with_asset_callbacks(AssetCallbackFlag::Enabled) + .build_existing()?; builder.add_account(agglayer_faucet.clone())?; // Get the destination account ID from the leaf data. @@ -265,6 +282,21 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a UpdateGerNote::create(ger, ger_injector.id(), bridge_account.id(), builder.rng_mut())?; builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); + let config_sponsorship = add_fee_sponsorship( + &mut builder, + &config_note, + bridge_account.id(), + verification_base_fee, + )?; + let update_ger_sponsorship = add_fee_sponsorship( + &mut builder, + &update_ger_note, + bridge_account.id(), + verification_base_fee, + )?; + let claim_sponsorship = + add_fee_sponsorship(&mut builder, &claim_note, bridge_account.id(), verification_base_fee)?; + // BUILD MOCK CHAIN WITH ALL ACCOUNTS // -------------------------------------------------------------------------------------------- let mut mock_chain = builder.clone().build()?; @@ -274,8 +306,12 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a let config_mock_tx = mock_chain .build_transaction(bridge_account.id()) .authenticated_input_note(config_note.id()) + .authenticated_input_notes(config_sponsorship.as_ref().map(Note::id)) .build()?; let config_executed = config_mock_tx.execute().await?; + if fees_enabled { + assert_transaction_paid_fee(&config_executed); + } mock_chain.add_pending_executed_transaction(&config_executed)?; mock_chain.prove_next_block()?; @@ -285,8 +321,12 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a let update_ger_mock_tx = mock_chain .build_transaction(bridge_account.id()) .authenticated_input_note(update_ger_note.id()) + .authenticated_input_notes(update_ger_sponsorship.as_ref().map(Note::id)) .build()?; let update_ger_executed = update_ger_mock_tx.execute().await?; + if fees_enabled { + assert_transaction_paid_fee(&update_ger_executed); + } mock_chain.add_pending_executed_transaction(&update_ger_executed)?; mock_chain.prove_next_block()?; @@ -298,12 +338,16 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a .build_transaction(bridge_account.id()) .unauthenticated_input_note(claim_note) .foreign_accounts(vec![faucet_foreign_inputs]) + .authenticated_input_notes(claim_sponsorship.as_ref().map(Note::id)) .build()?; let claim_executed = claim_mock_tx .execute() .await .context("TX2: CLAIM note execution against bridge failed")?; + if fees_enabled { + assert_transaction_paid_fee(&claim_executed); + } // VERIFY CGI CHAIN HASH WAS SUCCESSFULLY UPDATED // -------------------------------------------------------------------------------------------- @@ -323,8 +367,13 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a // VERIFY MINT NOTE WAS CREATED BY THE BRIDGE // -------------------------------------------------------------------------------------------- - assert_eq!(claim_executed.output_notes().num_notes(), 1); - let mint_output_note = claim_executed.output_notes().get_note(0); + let mint_output_note = find_output_note(&claim_executed, StandardNote::MINT.script_root()) + .expect("CLAIM should create a MINT note"); + let mint_sponsorship_id = fees_enabled.then(|| { + find_output_note(&claim_executed, FeeSponsorshipNote::script_root()) + .expect("fee-enabled CLAIM should sponsor its MINT note") + .id() + }); // Verify the MINT note was sent by the bridge assert_eq!(mint_output_note.metadata().sender(), bridge_account.id()); @@ -340,19 +389,23 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a .build_transaction(agglayer_faucet.id()) .authenticated_input_note(mint_output_note.id()) .add_note_script(P2idNote::script()) + .authenticated_input_notes(mint_sponsorship_id) .build()?; let mint_executed = mint_mock_tx .execute() .await .context("TX3: MINT note execution against faucet failed")?; + if fees_enabled { + assert_transaction_paid_fee(&mint_executed); + } // VERIFY P2ID NOTE WAS CREATED BY THE FAUCET // -------------------------------------------------------------------------------------------- // Check that exactly one P2ID note was created by the faucet - assert_eq!(mint_executed.output_notes().num_notes(), 1); - let output_note = mint_executed.output_notes().get_note(0); + let output_note = find_output_note(&mint_executed, StandardNote::P2ID.script_root()) + .expect("MINT should create a P2ID note"); // Verify note metadata properties assert_eq!(output_note.metadata().sender(), agglayer_faucet.id()); diff --git a/crates/miden-testing/tests/agglayer/bridge_out.rs b/crates/miden-testing/tests/agglayer/bridge_out.rs index e5fbd90387..37e826540b 100644 --- a/crates/miden-testing/tests/agglayer/bridge_out.rs +++ b/crates/miden-testing/tests/agglayer/bridge_out.rs @@ -6,6 +6,7 @@ use miden_agglayer::errors::{ ERR_B2AGG_TARGET_ACCOUNT_MISMATCH, ERR_FAUCET_NOT_REGISTERED, }; +use miden_agglayer::testing::create_existing_agglayer_faucet; use miden_agglayer::{ AggLayerBridge, B2AggNote, @@ -14,7 +15,6 @@ use miden_agglayer::{ ExitRoot, Keccak256Output, MetadataHash, - create_existing_agglayer_faucet, }; use miden_crypto::hash::keccak::Keccak256Digest; use miden_crypto::rand::FeltRng; @@ -35,7 +35,12 @@ use miden_protocol::{Felt, Word}; use miden_standards::account::faucets::FungibleFaucet; use miden_standards::account::policies::MintPolicy; use miden_standards::interop::eth::EthAddress; -use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint, StandardNote}; +use miden_standards::note::{ + FeeSponsorshipNote, + NetworkAccountTarget, + NoteExecutionHint, + StandardNote, +}; use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; use miden_tx::utils::hex_to_bytes; use rand::rngs::StdRng; @@ -45,11 +50,24 @@ use super::merkle_tree_frontier::MerkleTreeFrontier32; use super::test_utils::{ MIDEN_NETWORK_ID, SOLIDITY_MTF_VECTORS, + VERIFICATION_BASE_FEE, + add_fee_sponsorship, + assert_transaction_paid_fee, bridge_admin_account_id, create_existing_bridge_account_with_roles, + create_existing_priced_bridge, + find_output_note, + priced_faucet_builder, }; -/// Tests that 32 sequential B2AGG note consumptions match all 32 Solidity MTF roots. +/// Number of frontier evolutions pinned by the bundled Solidity MTF test vectors. +const SOLIDITY_MTF_VECTOR_COUNT: usize = 32; + +/// Tests the complete B2AGG-to-BURN bridge-out lifecycle. +/// +/// The fee-free case consumes all 32 vectors and pins every Solidity MTF root. The fee-enabled +/// case consumes one vector with production-priced policies, sponsorships, and non-zero fee +/// payments on the bridge and faucet transactions. /// /// This test exercises the complete bridge-out lifecycle: /// 1. Creates a bridge account (empty faucet registry) and an agglayer faucet with conversion @@ -64,24 +82,30 @@ use super::test_utils::{ /// - Creates a BURN note addressed to the faucet /// 5. Verifies the BURN note was created with the correct asset, tag, and script /// 6. Consumes the BURN note with the faucet to burn the tokens +#[rstest::rstest] +#[case::fee_free(SOLIDITY_MTF_VECTOR_COUNT, 0)] +#[case::fees_enabled(1, VERIFICATION_BASE_FEE)] #[tokio::test] -async fn bridge_out_consecutive() -> anyhow::Result<()> { +async fn bridge_out_consecutive( + #[case] note_count: usize, + #[case] verification_base_fee: u32, +) -> anyhow::Result<()> { + let fees_enabled = verification_base_fee > 0; let vectors = &*SOLIDITY_MTF_VECTORS; - let note_count = 32usize; - assert_eq!(vectors.amounts.len(), note_count, "amount vectors should contain 32 entries"); - assert_eq!(vectors.roots.len(), note_count, "root vectors should contain 32 entries"); - assert_eq!( - vectors.destination_networks.len(), - note_count, - "destination network vectors should contain 32 entries" - ); - assert_eq!( - vectors.destination_addresses.len(), - note_count, - "destination address vectors should contain 32 entries" - ); + // the bundled fixture pins 32 frontier evolutions; the fee-free case consumes all of them. + for (name, len) in [ + ("amount", vectors.amounts.len()), + ("root", vectors.roots.len()), + ("destination network", vectors.destination_networks.len()), + ("destination address", vectors.destination_addresses.len()), + ] { + assert_eq!( + len, SOLIDITY_MTF_VECTOR_COUNT, + "{name} vectors should contain {SOLIDITY_MTF_VECTOR_COUNT} entries" + ); + } - let mut builder = MockChain::builder(); + let mut builder = MockChain::builder().verification_base_fee(verification_base_fee); // CREATE FAUCET MANAGER ACCOUNT (sends CONFIG_AGG_BRIDGE notes) let faucet_manager = builder.add_existing_wallet(Auth::BasicAuth { @@ -98,19 +122,21 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; - let mut bridge_account = create_existing_bridge_account_with_roles( - builder.rng_mut().draw_word(), + let bridge_seed = builder.rng_mut().draw_word(); + let mut bridge_account = create_existing_priced_bridge( + bridge_seed, bridge_admin_account_id(), faucet_manager.id(), ger_injector.id(), ger_remover.id(), - MIDEN_NETWORK_ID, - ); + verification_base_fee, + )?; builder.add_account(bridge_account.clone())?; let expected_amounts = vectors .amounts .iter() + .take(note_count) .map(|amount| amount.parse::().expect("valid amount decimal string")) .collect::>(); let total_burned: u64 = expected_amounts.iter().sum(); @@ -126,14 +152,17 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { &vectors.token_symbol, vectors.token_decimals, ); - let faucet = create_existing_agglayer_faucet( - builder.rng_mut().draw_word(), + let faucet_seed = builder.rng_mut().draw_word(); + let faucet = priced_faucet_builder( + faucet_seed, &vectors.token_symbol, vectors.token_decimals, FungibleAsset::MAX_AMOUNT.into(), Felt::new_unchecked(total_burned), bridge_account.id(), - ); + verification_base_fee, + )? + .build_existing()?; builder.add_account(faucet.clone())?; // CONFIG_AGG_BRIDGE note to register the faucet in the bridge (sent by faucet manager) @@ -155,7 +184,7 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { // CREATE ALL B2AGG NOTES UPFRONT (before building mock chain) // -------------------------------------------------------------------------------------------- let mut notes = Vec::with_capacity(note_count); - for (i, &amount) in expected_amounts.iter().enumerate().take(note_count) { + for (i, &amount) in expected_amounts.iter().enumerate() { let destination_network = vectors.destination_networks[i]; let eth_address = EthAddress::from_hex(&vectors.destination_addresses[i]) .expect("valid destination address"); @@ -173,6 +202,22 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { notes.push(note); } + let config_sponsorship = add_fee_sponsorship( + &mut builder, + &config_note, + bridge_account.id(), + verification_base_fee, + )?; + let mut b2agg_sponsorships = Vec::with_capacity(note_count); + for note in ¬es { + b2agg_sponsorships.push(add_fee_sponsorship( + &mut builder, + note, + bridge_account.id(), + verification_base_fee, + )?); + } + let mut mock_chain = builder.build()?; mock_chain.prove_next_block()?; @@ -181,16 +226,20 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { let config_executed = mock_chain .build_transaction(bridge_account.id()) .authenticated_input_note(config_note.id()) + .authenticated_input_notes(config_sponsorship.as_ref().map(Note::id)) .build()? .execute() .await?; + if fees_enabled { + assert_transaction_paid_fee(&config_executed); + } bridge_account.apply_patch(config_executed.account_patch())?; mock_chain.add_pending_executed_transaction(&config_executed)?; mock_chain.prove_next_block()?; - // STEP 2: CONSUME 32 B2AGG NOTES AND VERIFY FRONTIER EVOLUTION + // STEP 2: CONSUME B2AGG NOTES AND VERIFY FRONTIER EVOLUTION // -------------------------------------------------------------------------------------------- - let mut burn_note_ids = Vec::with_capacity(note_count); + let mut burn_notes = Vec::with_capacity(note_count); for (i, note) in notes.iter().enumerate() { // creating the BURN note requires reading its note fee from the target account @@ -199,21 +248,25 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { .build_transaction(bridge_account.clone()) .authenticated_input_note(note.id()) .foreign_accounts(vec![faucet_inputs]) + .authenticated_input_notes(b2agg_sponsorships[i].as_ref().map(Note::id)) .build()? .execute() .await?; - - assert_eq!( - executed_tx.output_notes().num_notes(), - 1, - "Expected one BURN note after consume #{}", - i + 1 - ); - let burn_note = match executed_tx.output_notes().get_note(0) { - RawOutputNote::Full(note) => note, - _ => panic!("Expected OutputNote::Full variant for BURN note"), + if fees_enabled { + assert_transaction_paid_fee(&executed_tx); + } + + let burn_output = find_output_note(&executed_tx, StandardNote::BURN.script_root()) + .expect("B2AGG should create a BURN note"); + let RawOutputNote::Full(burn_note) = burn_output else { + panic!("B2AGG should create the full BURN note") }; - burn_note_ids.push(burn_note.id()); + let burn_sponsorship_id = fees_enabled.then(|| { + find_output_note(&executed_tx, FeeSponsorshipNote::script_root()) + .expect("fee-enabled B2AGG should sponsor its BURN note") + .id() + }); + burn_notes.push((burn_note.id(), burn_sponsorship_id)); let expected_asset = Asset::from(FungibleAsset::new(faucet.id(), expected_amounts[i])?); assert!( @@ -279,17 +332,21 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { ); let mut faucet = faucet; - for burn_note_id in burn_note_ids { + for (burn_note_id, sponsorship_id) in burn_notes { let burn_executed_tx = mock_chain .build_transaction(faucet.id()) .authenticated_input_note(burn_note_id) + .authenticated_input_notes(sponsorship_id) .build()? .execute() .await?; + if fees_enabled { + assert_transaction_paid_fee(&burn_executed_tx); + } assert_eq!( burn_executed_tx.output_notes().num_notes(), - 0, - "Burn transaction should not create output notes" + usize::from(fees_enabled), + "Burn transaction should only create its fee note when fees are enabled" ); faucet.apply_patch(burn_executed_tx.account_patch())?; mock_chain.add_pending_executed_transaction(&burn_executed_tx)?; @@ -300,7 +357,7 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { assert_eq!( final_token_supply, AssetAmount::new(initial_token_supply.as_u64() - total_burned)?, - "Token supply should decrease by the sum of 32 bridged amounts" + "Token supply should decrease by the sum of the bridged amounts" ); Ok(()) diff --git a/crates/miden-testing/tests/agglayer/faucet_helpers.rs b/crates/miden-testing/tests/agglayer/faucet_helpers.rs index 940f0413dc..3220993c1f 100644 --- a/crates/miden-testing/tests/agglayer/faucet_helpers.rs +++ b/crates/miden-testing/tests/agglayer/faucet_helpers.rs @@ -1,6 +1,7 @@ extern crate alloc; -use miden_agglayer::{AggLayerFaucet, create_existing_agglayer_faucet}; +use miden_agglayer::AggLayerFaucet; +use miden_agglayer::testing::create_existing_agglayer_faucet; use miden_protocol::Felt; use miden_protocol::account::auth::AuthScheme; use miden_protocol::asset::FungibleAsset; diff --git a/crates/miden-testing/tests/agglayer/fee_policy.rs b/crates/miden-testing/tests/agglayer/fee_policy.rs new file mode 100644 index 0000000000..26cc08b231 --- /dev/null +++ b/crates/miden-testing/tests/agglayer/fee_policy.rs @@ -0,0 +1,316 @@ +use alloc::collections::BTreeSet; + +use miden_agglayer::testing::bridge_admin_account_id; +use miden_agglayer::{AggLayerBridge, AggLayerFaucet, BridgeRoles}; +use miden_protocol::Word; +use miden_protocol::account::{Account, AccountId, AccountType, StorageMapKey}; +use miden_protocol::asset::{AssetId, FungibleAsset}; +use miden_protocol::note::{Note, NoteScriptRoot}; +use miden_protocol::transaction::RawOutputNote; +use miden_standards::account::auth::NetworkAccount; +use miden_standards::account::fees::{BasicConstantFeePolicy, FeePolicyManager}; +use miden_standards::errors::standards::ERR_SENDER_LACKS_ROLE; +use miden_standards::note::{ + BurnNote, + ConstantFeePolicyConfigNote, + FeeSponsorshipNote, + MintNote, + NetworkAccountConfigNote, + PauseConfig, +}; +use miden_testing::{MockChain, assert_transaction_executor_error}; +use rstest::rstest; + +use super::test_utils::{ + MIDEN_NETWORK_ID, + VERIFICATION_BASE_FEE, + add_fee_sponsorship, + fee_faucet_id, + is_bridge_paused, + network_note_pricer, +}; +use crate::consume_note; + +fn assert_priced_account(account: &Account, roots: BTreeSet) -> anyhow::Result<()> { + let pricer = network_note_pricer(VERIFICATION_BASE_FEE); + let network_account = NetworkAccount::new(account.clone())?; + assert_eq!(network_account.allowed_notes().allowed_script_roots(), &roots); + + assert_eq!( + account.storage().get_item(FeePolicyManager::active_fee_policy_slot())?, + BasicConstantFeePolicy::root().as_word() + ); + assert_eq!( + account.storage().get_item(FeePolicyManager::fee_asset_id_slot())?, + AssetId::new_fungible(fee_faucet_id()).to_word() + ); + + for root in roots { + let entry = account.storage().get_map_item( + BasicConstantFeePolicy::fee_schedule_slot_name(), + StorageMapKey::new(root.as_word()), + )?; + // Consuming a config note is the only route to `set_note_fee`, so it is scheduled free + // even though it has a benchmarked cost: a priced one could put repricing out of reach. + let expected_fee = if root == ConstantFeePolicyConfigNote::script_root() { + assert!(pricer.price(root)?.as_u64() > 0, "the config note should have a real price"); + 0 + } else { + pricer.price(root)?.as_u64() + }; + assert_eq!(entry[0].as_canonical_u64(), expected_fee); + assert_eq!(entry[3].as_canonical_u64(), 1, "the schedule entry must carry its set marker"); + } + + Ok(()) +} + +#[test] +fn agglayer_accounts_install_priced_basic_constant_fee_policies() -> anyhow::Result<()> { + assert_priced_account( + &build_managed_account(ManagedAccount::Bridge)?, + AggLayerBridge::allowed_notes(), + )?; + assert_priced_account( + &build_managed_account(ManagedAccount::Faucet)?, + AggLayerFaucet::allowed_notes(), + ) +} + +/// Pins the faucet's input-note allowlist. The allowlist decides which notes can drive an account +/// that holds an `ADMIN` role, so it should not grow silently. +#[test] +fn faucet_allowed_notes_pin() { + let expected = BTreeSet::from([ + MintNote::script_root(), + BurnNote::script_root(), + ConstantFeePolicyConfigNote::script_root(), + ]); + assert_eq!(AggLayerFaucet::faucet_notes(), expected); + + let mut effective = expected; + effective.insert(NetworkAccountConfigNote::script_root()); + effective.insert(FeeSponsorshipNote::script_root()); + assert_eq!(AggLayerFaucet::allowed_notes(), effective); +} + +// POST-DEPLOYMENT FEE SCHEDULE UPDATES +// ================================================================================================ + +/// Which AggLayer network account a repricing case runs against. Both install the +/// `ConstantFeeManager` behind the same `ADMIN`-gated authority, so the cases are shared. +#[derive(Clone, Copy, Debug)] +enum ManagedAccount { + Bridge, + Faucet, +} + +/// An account ID that holds no role on either AggLayer account. +fn outsider_id() -> AccountId { + AccountId::builder().account_type(AccountType::Public).build_with_seed([42; 32]) +} + +/// Builds the requested AggLayer account with its production-priced fee schedule, administered by +/// [`bridge_admin_account_id`]. +fn build_managed_account(managed: ManagedAccount) -> anyhow::Result { + let pricer = network_note_pricer(VERIFICATION_BASE_FEE); + let admin = bridge_admin_account_id(); + let roles = BridgeRoles::new([admin].into(), [admin].into(), [admin].into())?; + let bridge = AggLayerBridge::account_builder( + Word::default(), + admin, + roles, + MIDEN_NETWORK_ID, + pricer.agglayer_bridge_fee_policy_manager()?, + ) + .build_existing()?; + + Ok(match managed { + ManagedAccount::Bridge => bridge, + ManagedAccount::Faucet => AggLayerFaucet::account_builder( + Word::from([1u32, 0, 0, 0]), + "AGG", + 6, + 1_000u32.into(), + admin, + bridge.id(), + pricer.agglayer_faucet_fee_policy_manager()?, + ) + .build_existing()?, + }) +} + +/// Builds a config note repricing `repriced_root()` on `account` to `amount`, authored by `sender`. +/// +/// `serial_seed` keeps otherwise-identical notes from sharing a note ID. +fn build_repricing_note( + sender: AccountId, + account: AccountId, + amount: u64, + serial_seed: u32, +) -> anyhow::Result { + let note = ConstantFeePolicyConfigNote::builder() + .sender(sender) + .target(account) + .note_script_root(repriced_root()) + .fee_asset(FungibleAsset::new(fee_faucet_id(), amount)?) + .serial_number(Word::from([serial_seed, 0, 0, 0])) + .build()?; + Ok(Note::from(note)) +} + +/// The root the repricing cases rewrite. Every network account schedules it, so the bridge and +/// faucet cases can share it, and it is not the config note's own root, which must stay free. +fn repriced_root() -> NoteScriptRoot { + NetworkAccountConfigNote::script_root() +} + +/// Reads the committed fee schedule entry for [`repriced_root`] on `account_id`. +fn committed_fee(mock_chain: &MockChain, account_id: AccountId) -> anyhow::Result { + let account = mock_chain.committed_account(account_id)?; + let entry = account.storage().get_map_item( + BasicConstantFeePolicy::fee_schedule_slot_name(), + StorageMapKey::new(repriced_root().as_word()), + )?; + Ok(entry) +} + +/// An `ADMIN`-authored config note reprices a scheduled note, both upwards and back down, on the +/// bridge and on the faucet. Repricing downwards matters as much as upwards: it is how an +/// operator walks fees back after the chain's verification base fee falls, and it exercises the +/// manager overwriting a set-marked entry rather than filling an empty one. +/// +/// The chain runs at a zero verification base fee so the transactions themselves cost nothing and +/// no sponsorship plumbing is needed; the schedule under test is still the production-priced one. +#[rstest] +#[case::bridge(ManagedAccount::Bridge)] +#[case::faucet(ManagedAccount::Faucet)] +#[tokio::test] +async fn admin_reprices_the_fee_schedule(#[case] managed: ManagedAccount) -> anyhow::Result<()> { + const RAISED_FEE: u64 = 9_000; + const LOWERED_FEE: u64 = 12; + + let admin = bridge_admin_account_id(); + let account = build_managed_account(managed)?; + let deployed_fee = network_note_pricer(VERIFICATION_BASE_FEE).price(repriced_root())?.as_u64(); + + let raise = build_repricing_note(admin, account.id(), RAISED_FEE, 1)?; + let lower = build_repricing_note(admin, account.id(), LOWERED_FEE, 2)?; + + let mut builder = MockChain::builder(); + builder.add_account(account.clone())?; + builder.add_output_note(RawOutputNote::Full(raise.clone())); + builder.add_output_note(RawOutputNote::Full(lower.clone())); + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + assert_eq!( + committed_fee(&mock_chain, account.id())?, + Word::from([deployed_fee as u32, 0, 0, 1]), + "the account should start at its deployment price" + ); + + consume_note(&mut mock_chain, account.id(), &raise).await?; + assert_eq!( + committed_fee(&mock_chain, account.id())?, + Word::from([RAISED_FEE as u32, 0, 0, 1]), + "the raised fee should replace the deployment price" + ); + + consume_note(&mut mock_chain, account.id(), &lower).await?; + assert_eq!( + committed_fee(&mock_chain, account.id())?, + Word::from([LOWERED_FEE as u32, 0, 0, 1]), + "the lowered fee should replace the raised one" + ); + + Ok(()) +} + +/// A config note authored by an account outside the `ADMIN` role cannot reprice either account: +/// `set_note_fee` runs `authority::assert_authorized`, which under `Authority::RbacControlled` +/// resolves the unmapped procedure to `ADMIN` and rejects a sender that does not hold it. The +/// schedule is left untouched. +#[rstest] +#[case::bridge(ManagedAccount::Bridge)] +#[case::faucet(ManagedAccount::Faucet)] +#[tokio::test] +async fn non_admin_cannot_reprice_the_fee_schedule( + #[case] managed: ManagedAccount, +) -> anyhow::Result<()> { + let account = build_managed_account(managed)?; + let deployed_fee = network_note_pricer(VERIFICATION_BASE_FEE).price(repriced_root())?.as_u64(); + let attacker_note = build_repricing_note(outsider_id(), account.id(), 1, 3)?; + + let mut builder = MockChain::builder(); + builder.add_account(account.clone())?; + builder.add_output_note(RawOutputNote::Full(attacker_note.clone())); + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let result = mock_chain + .build_transaction(account.id()) + .authenticated_input_note(attacker_note.id()) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_SENDER_LACKS_ROLE); + assert_eq!( + committed_fee(&mock_chain, account.id())?, + Word::from([deployed_fee as u32, 0, 0, 1]), + "a rejected config note must leave the schedule untouched" + ); + + Ok(()) +} + +/// A paused bridge can still be repriced. `set_note_fee` belongs to the standards +/// `ConstantFeeManager`, not to the bridge component, so it carries no +/// `pausable::assert_not_paused` - repricing stays available alongside the other management +/// notes while every bridging entry point is halted. +/// +/// The pause note needs a `FEE_SPONSORSHIP` covering its scheduled fee, because a priced schedule +/// requires every consumed note's fee to be prepaid regardless of the chain's own base fee. The +/// repricing note needs none: it is scheduled free precisely so that repricing can never be +/// gated on someone funding a sponsorship for it. +#[tokio::test] +async fn paused_bridge_allows_repricing() -> anyhow::Result<()> { + const REPRICED_FEE: u64 = 4_242; + + let admin = bridge_admin_account_id(); + let bridge = build_managed_account(ManagedAccount::Bridge)?; + + let mut builder = MockChain::builder(); + builder.add_account(bridge.clone())?; + let pause = + AggLayerBridge::pause_note(PauseConfig::Pause, admin, bridge.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(pause.clone())); + let pause_sponsorship = + add_fee_sponsorship(&mut builder, &pause, bridge.id(), VERIFICATION_BASE_FEE)? + .expect("a non-zero base fee should produce a sponsorship"); + let reprice = build_repricing_note(admin, bridge.id(), REPRICED_FEE, 4)?; + builder.add_output_note(RawOutputNote::Full(reprice.clone())); + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let paused = mock_chain + .build_transaction(bridge.id()) + .authenticated_input_note(pause.id()) + .authenticated_input_note(pause_sponsorship.id()) + .build()? + .execute() + .await?; + mock_chain.add_pending_executed_transaction(&paused)?; + mock_chain.prove_next_block()?; + assert!(is_bridge_paused(&mock_chain, bridge.id())?, "the bridge should be paused"); + + consume_note(&mut mock_chain, bridge.id(), &reprice).await?; + assert_eq!( + committed_fee(&mock_chain, bridge.id())?, + Word::from([REPRICED_FEE as u32, 0, 0, 1]), + "a paused bridge should still accept a repricing note" + ); + + Ok(()) +} diff --git a/crates/miden-testing/tests/agglayer/mod.rs b/crates/miden-testing/tests/agglayer/mod.rs index 8da58c5aee..a3ffbd9f77 100644 --- a/crates/miden-testing/tests/agglayer/mod.rs +++ b/crates/miden-testing/tests/agglayer/mod.rs @@ -2,6 +2,7 @@ mod bridge_in; mod bridge_out; mod config_bridge; mod faucet_helpers; +mod fee_policy; mod global_index; mod leaf_utils; mod merkle_tree_frontier; diff --git a/crates/miden-testing/tests/agglayer/network_account_regression.rs b/crates/miden-testing/tests/agglayer/network_account_regression.rs index 42513e185f..9bc2b102da 100644 --- a/crates/miden-testing/tests/agglayer/network_account_regression.rs +++ b/crates/miden-testing/tests/agglayer/network_account_regression.rs @@ -9,7 +9,8 @@ //! //! [`AuthNetworkAccount`]: miden_standards::account::auth::AuthNetworkAccount -use miden_agglayer::{ExitRoot, UpdateGerNote, create_existing_agglayer_faucet}; +use miden_agglayer::testing::create_existing_agglayer_faucet; +use miden_agglayer::{ExitRoot, UpdateGerNote}; use miden_crypto::rand::FeltRng; use miden_protocol::Felt; use miden_protocol::account::auth::AuthScheme; diff --git a/crates/miden-testing/tests/agglayer/rbac_rotation.rs b/crates/miden-testing/tests/agglayer/rbac_rotation.rs index 5d62dd1510..4b0cd8db4a 100644 --- a/crates/miden-testing/tests/agglayer/rbac_rotation.rs +++ b/crates/miden-testing/tests/agglayer/rbac_rotation.rs @@ -31,6 +31,7 @@ use miden_protocol::transaction::RawOutputNote; use miden_standards::account::auth::NetworkAccount; use miden_standards::errors::standards::ERR_SENDER_LACKS_ROLE; use miden_standards::note::{ + ConstantFeePolicyConfigNote, FeeSponsorshipNote, NetworkAccountConfigNote, PauseConfig, @@ -228,8 +229,9 @@ fn bridge_allowed_notes_pin() { RemoveGerNote::script_root(), PauseConfigNote::script_root(), RbacConfigNote::script_root(), + ConstantFeePolicyConfigNote::script_root(), ]); - assert_eq!(AggLayerBridge::allowed_notes(), expected); + assert_eq!(AggLayerBridge::bridge_notes(), expected); let dummy = bridge_admin_account_id(); let bridge = create_existing_bridge_account_with_roles( diff --git a/crates/miden-testing/tests/agglayer/test_utils.rs b/crates/miden-testing/tests/agglayer/test_utils.rs index 0627003e10..60676447cd 100644 --- a/crates/miden-testing/tests/agglayer/test_utils.rs +++ b/crates/miden-testing/tests/agglayer/test_utils.rs @@ -1,6 +1,5 @@ extern crate alloc; -use miden_agglayer::agglayer_package; pub use miden_agglayer::testing::{ ClaimDataSource, LEAF_VALUE_VECTORS_JSON, @@ -11,9 +10,10 @@ pub use miden_agglayer::testing::{ SOLIDITY_MERKLE_PROOF_VECTORS, bridge_admin_account_id, create_existing_bridge_account_with_roles, + faucet_account_builder, }; +use miden_agglayer::{AggLayerBridge, BridgeRoles, agglayer_package}; use miden_core_lib::CoreLibrary; -use miden_crypto::Felt; use miden_crypto::hash::keccak::Keccak256; use miden_processor::advice::AdviceInputs; use miden_processor::utils::bytes_to_packed_u32_elements; @@ -26,14 +26,20 @@ use miden_processor::{ StackInputs, }; use miden_protocol::account::auth::AuthScheme; -use miden_protocol::account::{Account, AccountId}; +use miden_protocol::account::{Account, AccountBuilder, AccountId}; +use miden_protocol::asset::FungibleAsset; +use miden_protocol::block::FeeParameters; use miden_protocol::crypto::rand::FeltRng; -use miden_protocol::transaction::TransactionKernel; +use miden_protocol::note::{Note, NoteScriptRoot}; +use miden_protocol::testing::account_id::ACCOUNT_ID_FEE_FAUCET; +use miden_protocol::transaction::{ExecutedTransaction, RawOutputNote, TransactionKernel}; use miden_protocol::utils::sync::LazyLock; -use miden_protocol::{ProtocolLib, Word}; +use miden_protocol::{Felt, ProtocolLib, Word}; use miden_standards::StandardsLib; use miden_standards::account::access::PausableStorage; +use miden_standards::note::{FeeSponsorshipNote, StandardNote}; use miden_testing::{Auth, MockChain, MockChainBuilder}; +use miden_tx::NetworkNotePricer; // TEST NETWORK ID // ================================================================================================ @@ -42,6 +48,9 @@ use miden_testing::{Auth, MockChain, MockChainBuilder}; /// test vectors. pub const MIDEN_NETWORK_ID: u32 = 77; +/// Non-zero base fee used by the AggLayer fee-enabled end-to-end cases. +pub const VERIFICATION_BASE_FEE: u32 = 500; + // KECCAK-256 // ================================================================================================ @@ -83,6 +92,112 @@ pub static SOLIDITY_MTF_VECTORS: LazyLock = LazyLock::new(|| { // HELPER FUNCTIONS // ================================================================================================ +/// Returns the native fee faucet used by [`MockChainBuilder`] by default. +pub fn fee_faucet_id() -> AccountId { + ACCOUNT_ID_FEE_FAUCET + .try_into() + .expect("mock-chain fee faucet ID should be valid") +} + +/// Returns the production note pricer configured for the fee-enabled AggLayer test chain. +pub fn network_note_pricer(verification_base_fee: u32) -> NetworkNotePricer { + NetworkNotePricer::builder() + .fee_parameters(FeeParameters::new(fee_faucet_id(), verification_base_fee)) + .build() +} + +/// Returns the first output note of `executed` created from `script_root`. +pub fn find_output_note( + executed: &ExecutedTransaction, + script_root: NoteScriptRoot, +) -> Option<&RawOutputNote> { + executed.output_notes().iter().find(|note| { + note.recipient().map(|recipient| recipient.script().root()) == Some(script_root) + }) +} + +/// Adds the sponsorship paired with `feature_note`, using the production price of its script. +/// +/// Returns `None` on a chain with a zero verification base fee, where nothing needs sponsoring. +pub fn add_fee_sponsorship( + builder: &mut MockChainBuilder, + feature_note: &Note, + target: AccountId, + verification_base_fee: u32, +) -> anyhow::Result> { + if verification_base_fee == 0 { + return Ok(None); + } + + let fee = network_note_pricer(verification_base_fee).price(feature_note.script().root())?; + let sponsorship: Note = FeeSponsorshipNote::builder() + .sender(feature_note.metadata().sender()) + .target_account(target) + .feature_note_id(feature_note.id()) + .asset(FungibleAsset::new(fee_faucet_id(), fee.as_u64())?) + .generate_serial_number(builder.rng_mut()) + .build()? + .into(); + builder.add_output_note(RawOutputNote::Full(sponsorship.clone())); + Ok(Some(sponsorship)) +} + +/// Asserts that a fee-enabled transaction charged a non-zero fee and emitted its TX_FEE note. +pub fn assert_transaction_paid_fee(executed: &ExecutedTransaction) { + assert!(executed.compute_fee().as_u64() > 0, "transaction fee should be non-zero"); + assert!( + find_output_note(executed, StandardNote::TX_FEE.script_root()).is_some(), + "fee-enabled transaction should emit a TX_FEE note" + ); +} + +/// Builds an existing AggLayer bridge with its production-priced fee policy. +pub fn create_existing_priced_bridge( + seed: Word, + admin: AccountId, + faucet_manager: AccountId, + ger_injector: AccountId, + ger_remover: AccountId, + verification_base_fee: u32, +) -> anyhow::Result { + let roles = + BridgeRoles::new([faucet_manager].into(), [ger_injector].into(), [ger_remover].into())?; + let fee_policy_manager = + network_note_pricer(verification_base_fee).agglayer_bridge_fee_policy_manager()?; + Ok( + AggLayerBridge::account_builder(seed, admin, roles, MIDEN_NETWORK_ID, fee_policy_manager) + .build_existing()?, + ) +} + +/// Returns a builder for an existing AggLayer faucet with its production-priced fee policy, +/// administered by [`bridge_admin_account_id`]. +/// +/// Callers finish with `build_existing`, after opting into any account settings the scenario +/// needs (e.g. asset callbacks). +pub fn priced_faucet_builder( + seed: Word, + token_symbol: &str, + decimals: u8, + max_supply: Felt, + initial_supply: Felt, + bridge_account_id: AccountId, + verification_base_fee: u32, +) -> anyhow::Result { + let fee_policy_manager = + network_note_pricer(verification_base_fee).agglayer_faucet_fee_policy_manager()?; + Ok(faucet_account_builder( + seed, + token_symbol, + decimals, + max_supply, + initial_supply, + bridge_admin_account_id(), + bridge_account_id, + fee_policy_manager, + )) +} + /// Execute a program with a default host and optional advice inputs. pub async fn execute_program_with_default_host( program: Program, diff --git a/crates/miden-tx/src/pricer.rs b/crates/miden-tx/src/pricer.rs index 0c2366e0c4..0ff7c1a72d 100644 --- a/crates/miden-tx/src/pricer.rs +++ b/crates/miden-tx/src/pricer.rs @@ -1,13 +1,14 @@ use alloc::vec::Vec; -use miden_agglayer::AgglayerNote; +use miden_agglayer::{AggLayerBridge, AggLayerFaucet, AgglayerNote}; use miden_protocol::asset::AssetAmount; use miden_protocol::block::FeeParameters; use miden_protocol::errors::AssetError; use miden_protocol::note::NoteScriptRoot; use miden_protocol::transaction::{TransactionFee, TransactionFeeError}; -use miden_standards::note::StandardNote; +use miden_standards::account::fees::{BasicConstantFeePolicy, FeePolicyManager}; use miden_standards::note::costs::NoteCost; +use miden_standards::note::{ConstantFeePolicyConfigNote, StandardNote}; // NETWORK NOTE PRICER // ================================================================================================ @@ -113,6 +114,56 @@ impl NetworkNotePricer { AssetAmount::new(price).map_err(NotePricingError::PriceExceedsMaxAssetAmount) } + /// Builds a fee policy manager whose active [`BasicConstantFeePolicy`] prices every supplied + /// note script root from its benchmarked consumption cost. + /// + /// The manager charges in the fee asset configured by [`Self::fee_parameters`], keeping the + /// policy's bare fee amounts and their denomination together. Each root is priced through + /// [`Self::price`], so the fee includes the default safety margin and the recursively priced + /// notes created by consuming it. + /// + /// [`ConstantFeePolicyConfigNote`] is the sole exception: it is always scheduled free. + /// Consuming one is the only way to reach `set_note_fee`, so pricing it would let the schedule + /// put the very note that repairs it out of reach, bricking fee management (see that note's + /// operational notes). + pub fn basic_constant_fee_policy_manager( + &self, + note_script_roots: impl IntoIterator, + ) -> Result { + let config_note_root = ConstantFeePolicyConfigNote::script_root(); + + let mut policy = BasicConstantFeePolicy::new(); + for root in note_script_roots { + let fee = if root == config_note_root { + AssetAmount::ZERO + } else { + self.price(root)? + }; + policy = policy.with_fee(root, fee); + } + + Ok(FeePolicyManager::builder() + .fee_faucet_id(self.fee_parameters.fee_faucet_id()) + .active_fee_policy(policy.into()) + .build()) + } + + /// Builds the production fee policy manager for an AggLayer bridge account. + /// + /// Every root in [`AggLayerBridge::allowed_notes`] is priced through [`Self::price`] and + /// installed in the active [`BasicConstantFeePolicy`]. + pub fn agglayer_bridge_fee_policy_manager(&self) -> Result { + self.basic_constant_fee_policy_manager(AggLayerBridge::allowed_notes()) + } + + /// Builds the production fee policy manager for an AggLayer faucet account. + /// + /// Every root in [`AggLayerFaucet::allowed_notes`] is priced through [`Self::price`] and + /// installed in the active [`BasicConstantFeePolicy`]. + pub fn agglayer_faucet_fee_policy_manager(&self) -> Result { + self.basic_constant_fee_policy_manager(AggLayerFaucet::allowed_notes()) + } + /// Computes the recursive price of `root` as a raw `u64`, tracking the roots currently /// being priced to cut off self-recursion. fn price_recursive( @@ -334,4 +385,40 @@ mod tests { + pricer.fee(fee_inputs(P2ID_CONSUMPTION_CYCLES)).unwrap().as_u64(); assert_eq!(pricer.price(ClaimNote::script_root()).unwrap().as_u64(), expected); } + + #[test] + fn basic_constant_fee_policy_manager_prices_every_root_in_the_native_fee_asset() { + let pricer = pricer(500, 0); + let roots = [SwapNote::script_root(), ClaimNote::script_root()]; + + let manager = pricer.basic_constant_fee_policy_manager(roots).unwrap(); + assert_eq!(manager.active_fee_policy(), BasicConstantFeePolicy::root()); + assert_eq!( + manager.fee_asset_id(), + miden_protocol::asset::AssetId::new_fungible(pricer.fee_parameters().fee_faucet_id()) + ); + } + + #[test] + fn basic_constant_fee_policy_manager_rejects_unknown_roots() { + let unknown = NoteScriptRoot::from_array([9, 9, 9, 9]); + assert!(matches!( + pricer(500, 0).basic_constant_fee_policy_manager([unknown]), + Err(NotePricingError::UnknownNoteScriptRoot(root)) if root == unknown + )); + } + + /// The constant-fee config note is scheduled free even though it has a benchmarked cost of its + /// own, so a repricing note can always be consumed. The zero in the schedule is an override, + /// not an artifact of the note being costless; asserting the underlying price is non-zero keeps + /// the two apart. The scheduled zero itself is asserted against real account storage in the + /// AggLayer `fee_policy` tests. + #[test] + fn config_note_carries_a_real_price_but_is_scheduled_free() { + let pricer = pricer(500, 0); + let config_note_root = ConstantFeePolicyConfigNote::script_root(); + + assert!(pricer.price(config_note_root).unwrap().as_u64() > 0); + assert!(pricer.basic_constant_fee_policy_manager([config_note_root]).is_ok()); + } }