miden-agglayer 0.18.0

Agglayer components for the Miden protocol
Documentation
#![no_std]

extern crate alloc;

use miden_core::Word;
use miden_protocol::account::{AccountBuilder, AccountId};
use miden_protocol::assembly::Path;
use miden_protocol::asset::{AssetAmount, TokenSymbol};
use miden_protocol::note::NoteScript;
use miden_protocol::vm::Package;
use miden_standards::account::access::{
    Authority,
    Ownable2Step,
    Pausable,
    PausableManager,
    RoleBasedAccessControl,
    RoleConfig,
};
use miden_standards::account::auth::NetworkAccount;
use miden_standards::account::faucets::{FungibleFaucet, TokenName};
use miden_standards::account::fees::{
    BasicConstantFeePolicy,
    ConstantFeeManager,
    FeePolicyManager,
};
use miden_standards::account::policies::{BurnPolicy, MintPolicy, TokenPolicyManager};
use miden_utils_sync::LazyLock;

pub mod agglayer_note;
pub mod b2agg_note;
pub mod bridge;
pub mod claim_note;
pub mod config_note;
pub mod costs;
pub mod deregister_note;
pub mod errors;
pub mod eth_types;
pub mod faucet;
mod ger_note;
pub mod remove_ger_note;
#[cfg(any(feature = "testing", test))]
pub mod testing;
pub mod update_ger_note;
pub mod utils;

pub use agglayer_note::AgglayerNote;
pub use b2agg_note::B2AggNote;
pub use bridge::{AggLayerBridge, AgglayerBridgeError, BridgeRoles, RemovedGerHashChain};
pub use claim_note::{
    CgiChainHash,
    ClaimNote,
    ClaimNoteStorage,
    ExitRoot,
    LeafData,
    LeafValue,
    ProofData,
    SmtNode,
};
pub use config_note::{ConfigAggBridgeNote, ConversionMetadata};
pub use deregister_note::DeregisterAggFaucetNote;
#[cfg(any(test, feature = "testing"))]
pub use eth_types::GlobalIndexExt;
pub use eth_types::{GlobalIndex, GlobalIndexError, MetadataHash};
pub use faucet::AggLayerFaucet;
pub use remove_ger_note::RemoveGerNote;
pub use update_ger_note::UpdateGerNote;
pub use utils::Keccak256Output;

// AGGLAYER ACCOUNT COMPONENTS
// ================================================================================================

static AGGLAYER_PACKAGE: LazyLock<Package> = LazyLock::new(|| {
    let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/assets/miden-agglayer.masp"));
    Package::read_from_bytes_trusted(bytes).expect("shipped AggLayer package is well-formed")
});

static BRIDGE_COMPONENT_PACKAGE: LazyLock<Package> = LazyLock::new(|| {
    let bytes =
        include_bytes!(concat!(env!("OUT_DIR"), "/assets/components/miden-agglayer-bridge.masp"));
    Package::read_from_bytes_trusted(bytes)
        .expect("shipped bridge component package is well-formed")
});

/// Returns the AggLayer package containing all agglayer modules, including the note scripts.
///
/// The note scripts this crate builds are external references into this package rather than
/// self-contained copies of it, so it must be registered with the MAST store of any executor that
/// runs AggLayer notes. This mirrors the standard note scripts, which are external references into
/// the standards library. `TransactionMastStore::new` preloads both packages, so the in-repo
/// prover and test executors resolve AggLayer notes automatically; a downstream executor that
/// supplies its own `DataStore` must register this package into it (e.g. via
/// `TransactionMastStore::insert_package`), exactly as it must already register the standards
/// package to run standard notes.
pub fn agglayer_package() -> Package {
    AGGLAYER_PACKAGE.clone()
}

/// Resolves the note script exported at `path` from the AggLayer package.
///
/// `path` must be the fully qualified path of a procedure carrying the `@note_script` attribute,
/// e.g. `::agglayer::notes::claim::main`.
pub(crate) fn note_script(path: &str) -> NoteScript {
    NoteScript::from_package_reference(&AGGLAYER_PACKAGE, Path::new(path))
        .expect("agglayer package contains the note script procedure")
}

/// Returns the Bridge component package.
fn agglayer_bridge_component_package() -> Package {
    BRIDGE_COMPONENT_PACKAGE.clone()
}

// AGGLAYER ACCOUNT CREATION HELPERS
// ================================================================================================

impl AggLayerBridge {
    /// Returns an [`AccountBuilder`] for a bridge account with the standard configuration.
    ///
    /// `bridge_admin` is the initial member of the bridge's built-in `ADMIN` role. `fee_policy`
    /// must contain entries for [`AggLayerBridge::allowed_notes`], denominated in the asset issued
    /// by `fee_faucet_id`.
    pub fn account_builder(
        seed: Word,
        bridge_admin: AccountId,
        roles: BridgeRoles,
        network_id: u32,
        fee_faucet_id: AccountId,
        fee_policy: BasicConstantFeePolicy,
    ) -> AccountBuilder {
        let fee_policy_manager = FeePolicyManager::builder()
            .fee_faucet_id(fee_faucet_id)
            .active_fee_policy(fee_policy.into())
            .build();
        NetworkAccount::builder(seed.into(), AggLayerBridge::allowed_notes(), fee_policy_manager)
            .expect("bridge note allowlist is non-empty")
            .with_component(AggLayerBridge::new(network_id))
            .with_component(
                RoleBasedAccessControl::builder()
                    .role(
                        RoleConfig::new(RoleBasedAccessControl::admin_role())
                            .with_member(bridge_admin),
                    )
                    .roles(roles)
                    .build()
                    .expect("the bridge seeds distinct non-empty roles administered by ADMIN"),
            )
            .with_component(Authority::RbacControlled {
                procedure_roles: AggLayerBridge::procedure_roles(),
            })
            .with_component(Pausable::unpaused())
            .with_component(PausableManager)
            .with_component(ConstantFeeManager::for_basic_constant_fee_policy())
    }
}

impl AggLayerFaucet {
    /// Returns an [`AccountBuilder`] for a faucet account with the specified deployment
    /// configuration.
    ///
    /// The account is a standard [`FungibleFaucet`]: `mint_and_send` and `receive_and_burn` drive
    /// bridge-in and bridge-out, and the standard metadata getters expose the token name, symbol
    /// and decimals. Conversion metadata (origin address, origin network, scale, metadata hash)
    /// lives on the bridge and is written there at registration time.
    ///
    /// No send or receive policies are registered, so the account is created with asset callbacks
    /// disabled.
    ///
    /// `faucet_admin` is the initial member of the faucet's built-in `ADMIN` role; `fee_manager`
    /// is the initial member of its `FEE_MNGR` role; `bridge_account_id` is its [`Ownable2Step`]
    /// owner, which is what the `owner_only` mint and burn policies gate on. `fee_policy` must
    /// contain entries for [`AggLayerFaucet::allowed_notes`], denominated in the asset issued by
    /// `fee_faucet_id`.
    ///
    /// # Panics
    ///
    /// Panics if:
    /// - `decimals` exceeds [`FungibleFaucet::MAX_DECIMALS`];
    /// - `initial_supply` exceeds `max_supply`.
    #[allow(clippy::too_many_arguments)]
    pub fn account_builder(
        seed: Word,
        token_name: TokenName,
        token_symbol: TokenSymbol,
        decimals: u8,
        max_supply: AssetAmount,
        initial_supply: AssetAmount,
        faucet_admin: AccountId,
        fee_manager: AccountId,
        bridge_account_id: AccountId,
        fee_faucet_id: AccountId,
        fee_policy: BasicConstantFeePolicy,
    ) -> AccountBuilder {
        let fee_policy_manager = FeePolicyManager::builder()
            .fee_faucet_id(fee_faucet_id)
            .active_fee_policy(fee_policy.into())
            .build();
        let faucet = FungibleFaucet::builder()
            .name(token_name)
            .symbol(token_symbol)
            .decimals(decimals)
            .max_supply(max_supply)
            .token_supply(initial_supply)
            .build()
            .expect("agglayer faucet decimals and supplies should be within their valid ranges");

        let token_policy_manager = TokenPolicyManager::builder()
            .active_mint_policy(MintPolicy::owner_only())
            .active_burn_policy(BurnPolicy::owner_only())
            .build();

        let rbac = RoleBasedAccessControl::builder()
            .role(RoleConfig::new(RoleBasedAccessControl::admin_role()).with_member(faucet_admin))
            .role(RoleConfig::new(AggLayerFaucet::fee_manager_role()).with_member(fee_manager))
            .build()
            .expect("the faucet seeds non-empty roles administered by ADMIN");

        NetworkAccount::builder(seed.into(), AggLayerFaucet::allowed_notes(), fee_policy_manager)
            .expect("faucet note allowlist is non-empty")
            .with_component(faucet)
            .with_component(Ownable2Step::new(bridge_account_id))
            .with_component(rbac)
            .with_component(Authority::RbacControlled {
                procedure_roles: AggLayerFaucet::procedure_roles(),
            })
            .with_components(token_policy_manager)
            .with_component(ConstantFeeManager::for_basic_constant_fee_policy())
    }
}

// TESTS
// ================================================================================================

#[cfg(test)]
mod tests {
    use miden_core::Felt;
    use miden_protocol::account::AssetCallbackFlag;
    use miden_protocol::asset::AssetCallbacks;
    use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE;
    use miden_standards::tx_script::ExpirationTransactionScript;

    use super::*;
    use crate::testing::{
        create_existing_agglayer_faucet,
        create_existing_bridge_account_with_roles,
    };

    /// AggLayer faucets omit transfer policies and callback slots, so moving their assets skips
    /// callback dispatch and the associated foreign-account read.
    #[test]
    fn agglayer_faucet_has_asset_callbacks_disabled() {
        let id = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap();

        let faucet = create_existing_agglayer_faucet(
            Word::default(),
            "AggLayer Token",
            "AGG",
            6,
            Felt::from(1000u32),
            Felt::ZERO,
            id,
            id,
        );

        for slot_name in AssetCallbacks::slot_names() {
            assert!(
                faucet.storage().get(slot_name).is_none(),
                "faucet should not install the {slot_name} callback slot"
            );
        }
        assert_eq!(faucet.id().asset_callback_flag(), AssetCallbackFlag::Disabled);
    }

    /// Both agglayer network accounts allowlist the canonical [`ExpirationTransactionScript`],
    /// which the network transaction builder attaches to every network transaction.
    #[test]
    fn agglayer_accounts_allowlist_expiration_tx_script() {
        let id = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap();

        let bridge =
            create_existing_bridge_account_with_roles(Word::default(), id, id, id, id, id, id, 77);
        let faucet = create_existing_agglayer_faucet(
            Word::default(),
            "AggLayer Token",
            "AGG",
            6,
            Felt::from(1000u32),
            Felt::ZERO,
            id,
            id,
        );

        for account in [bridge, faucet] {
            let network_account = NetworkAccount::try_from(account).unwrap();
            assert!(network_account.allows_tx_script(&ExpirationTransactionScript::script_root()));
        }
    }
}