Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
### Fixes

- Made deserialization of `AccountCode` more robust ([#2788](https://github.com/0xMiden/protocol/pull/2788)).
- [BREAKING] Replaced `NoAuth` with the new `NetworkAccount` auth component on the AggLayer bridge and AggLayer faucet, closing the forged-MINT attack surface where any transaction against the bridge could emit a bridge-authored MINT note ([#2797](https://github.com/0xMiden/protocol/issues/2797), [#2818](https://github.com/0xMiden/protocol/pull/2818)).


## 0.14.3 (2026-04-07)
Expand Down
4 changes: 2 additions & 2 deletions crates/miden-agglayer/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use miden_protocol::account::{
AccountType,
};
use miden_protocol::transaction::TransactionKernel;
use miden_standards::account::auth::NoAuth;
use miden_standards::account::auth::NetworkAccount;
use miden_standards::account::burn_policies::BurnOwnerControlled;
use miden_standards::account::mint_policies::MintOwnerControlled;

Expand Down Expand Up @@ -246,7 +246,7 @@ fn generate_agglayer_constants(
// policies alongside the agglayer faucet component, since
// network_fungible::mint_and_send requires these for access control.
let mut components: Vec<AccountComponent> =
vec![AccountComponent::from(NoAuth), agglayer_component];
vec![AccountComponent::from(NetworkAccount::new(Vec::new())), agglayer_component];
if lib_name == "faucet" {
// Use a dummy owner for commitment computation - the actual owner is set at runtime
let dummy_owner = miden_protocol::account::AccountId::try_from(
Expand Down
35 changes: 30 additions & 5 deletions crates/miden-agglayer/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ use miden_protocol::asset::TokenSymbol;
use miden_protocol::note::NoteScript;
use miden_protocol::vm::Program;
use miden_standards::account::access::Ownable2Step;
use miden_standards::account::auth::NoAuth;
use miden_standards::account::auth::NetworkAccount;
use miden_standards::account::burn_policies::BurnOwnerControlled;
use miden_standards::account::mint_policies::MintOwnerControlled;
use miden_standards::note::{BurnNote, MintNote};
use miden_utils_sync::LazyLock;

pub mod b2agg_note;
Expand Down Expand Up @@ -75,6 +76,30 @@ pub fn claim_script() -> NoteScript {
CLAIM_SCRIPT.clone()
}

/// Returns the root of the CLAIM (Bridge from AggLayer) note script.
pub fn claim_script_root() -> Word {
CLAIM_SCRIPT.root()
}

/// Returns the set of input-note script roots that AggLayer bridge accounts accept. The bridge's
/// `NetworkAccount` auth component is initialized with this whitelist, which means any transaction
/// consuming a note outside this set is rejected before reaching `output_note::create`.
pub fn bridge_note_whitelist() -> alloc::vec::Vec<Word> {
alloc::vec![
claim_script_root(),
B2AggNote::script_root(),
ConfigAggBridgeNote::script_root(),
UpdateGerNote::script_root(),
]
}

/// Returns the set of input-note script roots that AggLayer faucet accounts accept. The faucet's
/// `NetworkAccount` auth component is initialized with this whitelist so only MINT and BURN notes
/// can drive the faucet.
pub fn faucet_note_whitelist() -> alloc::vec::Vec<Word> {
alloc::vec![MintNote::script_root(), BurnNote::script_root()]
}

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

Expand Down Expand Up @@ -183,7 +208,7 @@ pub fn create_bridge_account(
ger_manager_id: AccountId,
) -> Account {
create_bridge_account_builder(seed, bridge_admin_id, ger_manager_id)
.with_auth_component(AccountComponent::from(NoAuth))
.with_auth_component(NetworkAccount::new(bridge_note_whitelist()))
.build()
.expect("bridge account should be valid")
}
Expand All @@ -198,7 +223,7 @@ pub fn create_existing_bridge_account(
ger_manager_id: AccountId,
) -> Account {
create_bridge_account_builder(seed, bridge_admin_id, ger_manager_id)
.with_auth_component(AccountComponent::from(NoAuth))
.with_auth_component(NetworkAccount::new(bridge_note_whitelist()))
.build_existing()
.expect("bridge account should be valid")
}
Expand Down Expand Up @@ -272,7 +297,7 @@ pub fn create_agglayer_faucet(
scale,
metadata_hash,
)
.with_auth_component(AccountComponent::from(NoAuth))
.with_auth_component(NetworkAccount::new(faucet_note_whitelist()))
.build()
.expect("agglayer faucet account should be valid")
}
Expand Down Expand Up @@ -306,7 +331,7 @@ pub fn create_existing_agglayer_faucet(
scale,
metadata_hash,
)
.with_auth_component(AccountComponent::from(NoAuth))
.with_auth_component(NetworkAccount::new(faucet_note_whitelist()))
.build_existing()
.expect("agglayer faucet account should be valid")
}
1 change: 1 addition & 0 deletions crates/miden-testing/tests/agglayer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mod faucet_helpers;
mod global_index;
mod leaf_utils;
mod merkle_tree_frontier;
mod network_account_regression;
mod solidity_miden_address_conversion;
pub mod test_utils;
mod update_ger;
106 changes: 106 additions & 0 deletions crates/miden-testing/tests/agglayer/network_account_regression.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//! Regression tests for the forged-MINT attack on the AggLayer bridge.
//!
//! When the AggLayer bridge was installed with `NoAuth`, any transaction that caused a state
//! change against the bridge could emit a MINT note whose metadata sender was the bridge. The
//! faucet's owner-only mint policy would then accept that note as owner-authorised, even though
//! the transaction came from an attacker, because the transaction kernel's `output_note::create`
//! does not require any specific bridge procedure to appear on the call stack.
//!
//! Swapping `NoAuth` for `NetworkAccount` closes the attack. The tests below exercise the two
//! rejection paths that together eliminate the forged-MINT attack surface:
//!
//! 1. A transaction script cannot be executed against the bridge.
//! 2. Any consumed input note whose script root is not in the bridge's whitelist is rejected.

extern crate alloc;

use core::slice;

use miden_agglayer::create_existing_bridge_account;
use miden_crypto::rand::FeltRng;
use miden_protocol::account::auth::AuthScheme;
use miden_protocol::transaction::RawOutputNote;
use miden_standards::code_builder::CodeBuilder;
use miden_standards::errors::standards::{
ERR_NETWORK_ACCOUNT_NOTE_NOT_WHITELISTED,
ERR_NETWORK_ACCOUNT_TX_SCRIPT_NOT_ALLOWED,
};
use miden_standards::testing::note::NoteBuilder;
use miden_testing::{Auth, MockChain, assert_transaction_executor_error};

/// The forged-MINT attack required the attacker's transaction to finalize against the bridge. The
/// attacker can no longer attach a tx script that drives an output-note creation, because the
/// bridge's `NetworkAccount` auth procedure rejects any transaction that executed a tx script.
#[tokio::test]
async fn bridge_rejects_tx_script() -> anyhow::Result<()> {
let mut builder = MockChain::builder();

let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth {
auth_scheme: AuthScheme::Falcon512Poseidon2,
})?;
let ger_manager = builder.add_existing_wallet(Auth::BasicAuth {
auth_scheme: AuthScheme::Falcon512Poseidon2,
})?;

let bridge_account = create_existing_bridge_account(
builder.rng_mut().draw_word(),
bridge_admin.id(),
ger_manager.id(),
);
builder.add_account(bridge_account.clone())?;

let mock_chain = builder.build()?;

// An attacker tries to run an arbitrary transaction script against the bridge.
let tx_script = CodeBuilder::default().compile_tx_script("begin nop end")?;

let result = mock_chain
.build_tx_context(bridge_account.id(), &[], &[])?
.tx_script(tx_script)
.build()?
.execute()
.await;

assert_transaction_executor_error!(result, ERR_NETWORK_ACCOUNT_TX_SCRIPT_NOT_ALLOWED);

Ok(())
}

/// The second rejection path: consuming any note not in the bridge whitelist is forbidden, so the
/// attacker cannot finalize a transaction by consuming an arbitrary zero-asset note.
#[tokio::test]
async fn bridge_rejects_non_whitelisted_input_note() -> anyhow::Result<()> {
let mut builder = MockChain::builder();

let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth {
auth_scheme: AuthScheme::Falcon512Poseidon2,
})?;
let ger_manager = builder.add_existing_wallet(Auth::BasicAuth {
auth_scheme: AuthScheme::Falcon512Poseidon2,
})?;

let bridge_account = create_existing_bridge_account(
builder.rng_mut().draw_word(),
bridge_admin.id(),
ger_manager.id(),
);
builder.add_account(bridge_account.clone())?;

// Build a note whose script root is not CLAIM, B2AGG, CONFIG_AGG_BRIDGE, or UPDATE_GER.
let attack_note = NoteBuilder::new(bridge_account.id(), &mut rand::rng())
.build()
.expect("failed to build attack note");
builder.add_output_note(RawOutputNote::Full(attack_note.clone()));

let mock_chain = builder.build()?;

let result = mock_chain
.build_tx_context(bridge_account.id(), &[], slice::from_ref(&attack_note))?
.build()?
.execute()
.await;

assert_transaction_executor_error!(result, ERR_NETWORK_ACCOUNT_NOTE_NOT_WHITELISTED);

Ok(())
}
Loading