Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 @@ -38,6 +38,7 @@
- Fixed `output_note::add_asset` and `output_note::set_attachment` to no longer accept invalid note indices ([#2824](https://github.com/0xMiden/protocol/pull/2824)).
- [BREAKING] Keyed AggLayer faucet token registry by `(origin_token_address, origin_network)` instead of `origin_token_address` alone, preventing same-address cross-network mint collisions on CLAIM ([#2860](https://github.com/0xMiden/protocol/pull/2860)).
- [BREAKING] Replaced `NoAuth` with the new `AuthNetworkAccount` 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)).
- Enforced `NoteType::Public` for B2AGG bridge-out notes so a recipient-identical private note can no longer be folded into the Local Exit Tree and desync the aggkit mirror ([#2988](https://github.com/0xMiden/protocol/pull/2988)).

## 0.14.3 (2026-04-07)

Expand Down
11 changes: 11 additions & 0 deletions crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use agglayer::common::eth_address::EthereumAddressFormat
# =================================================================================================

const ERR_B2AGG_DESTINATION_NETWORK_IS_MIDEN = "B2AGG note destination network ID must not be Miden's AggLayer network ID"
const ERR_B2AGG_NOTE_MUST_BE_PUBLIC = "B2AGG note bridged to AggLayer must be of public note type"

# CONSTANTS
# =================================================================================================
Expand Down Expand Up @@ -97,6 +98,11 @@ const BURN_NOTE_NUM_STORAGE_ITEMS=0
#! 4. Computes Keccak hash of the leaf data and appends it to the Local Exit Tree
#! 5. Creates a BURN note with the bridged out asset
#!
#! The note being bridged out must be public: AggLayer's off-chain indexer (aggkit) mirrors the
#! Local Exit Tree by observing notes in the public block space, so the appended leaf's pre-image
#! must be publicly observable. The note type lives in the metadata header rather than the recipient
#! commitment, so it is checked explicitly before the note's data is trusted.
Comment thread
partylikeits1983 marked this conversation as resolved.
Outdated
#!
#! Inputs: [ASSET_KEY, ASSET_VALUE, dest_network_id, dest_address(5), pad(2)]
#! Outputs: [pad(16)]
#!
Expand All @@ -107,11 +113,16 @@ const BURN_NOTE_NUM_STORAGE_ITEMS=0
#! - dest_address(5) are 5 u32 values representing a 20-byte Ethereum address.
#!
#! Panics if:
#! - the note being bridged out is not public.
#! - destination network ID is Miden's AggLayer network ID.
#!
#! Invocation: call
@locals(15)
pub proc bridge_out
# The note being bridged out must be public (see above); checked before its data is trusted.
Comment thread
partylikeits1983 marked this conversation as resolved.
Outdated
exec.active_note::is_public assert.err=ERR_B2AGG_NOTE_MUST_BE_PUBLIC
# => [ASSET_KEY, ASSET_VALUE, dest_network_id, dest_address(5), pad(2)]

# Save ASSET to local memory for later BURN note creation
locaddr.BRIDGE_OUT_BURN_ASSET_LOC
exec.asset::store
Expand Down
1 change: 1 addition & 0 deletions crates/miden-agglayer/asm/note_scripts/B2AGG.masm
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const ERR_B2AGG_TARGET_ACCOUNT_MISMATCH="B2AGG note attachment target account do
#! - The note does not contain exactly 6 storage items.
#! - The note does not contain exactly 1 asset.
#! - The note attachment does not target the consuming account.
#! - The note is not public (enforced by `bridge_out`).
#! - The destination network ID equals Miden's AggLayer network ID.
begin
dropw
Expand Down
68 changes: 68 additions & 0 deletions crates/miden-protocol/asm/protocol/active_note.masm
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ const ERR_NOTE_DATA_DOES_NOT_MATCH_COMMITMENT="note data does not match the comm

const ERR_NOTE_INVALID_NUMBER_OF_STORAGE_ITEMS="the specified number of note storage items does not match the actual number"

# CONSTANTS
# =================================================================================================

# Note type encoding, mirroring the Rust `NoteType` definition in `note/note_type.rs`
# (`PUBLIC = 0b01`, `PRIVATE = 0b10`).
const NOTE_TYPE_PUBLIC = 1
const NOTE_TYPE_PRIVATE = 2
Comment thread
partylikeits1983 marked this conversation as resolved.
Outdated

# ACTIVE NOTE PROCEDURES
# =================================================================================================
#
Expand Down Expand Up @@ -166,6 +174,66 @@ pub proc get_metadata
# => [NOTE_ATTACHMENT, METADATA_HEADER]
end

#! Returns whether the active note is public.
#!
#! The note type is stored in the metadata header rather than the recipient commitment, so this
#! reads the active note's metadata and compares the note type against the public encoding.
#!
#! Inputs: []
#! Outputs: [is_public]
#!
#! Where:
#! - is_public is 1 if the active note is public, 0 otherwise.
#!
#! Panics if:
#! - no note is currently active.
#!
#! Invocation: exec
pub proc is_public
exec.get_metadata
# => [NOTE_ATTACHMENT, METADATA_HEADER]

# drop the attachment word, keeping the metadata header
dropw
# => [METADATA_HEADER]

exec.note::extract_note_type_from_metadata
# => [note_type]

eq.NOTE_TYPE_PUBLIC
# => [is_public]
end

#! Returns whether the active note is private.
#!
#! The note type is stored in the metadata header rather than the recipient commitment, so this
#! reads the active note's metadata and compares the note type against the private encoding.
#!
#! Inputs: []
#! Outputs: [is_private]
#!
#! Where:
#! - is_private is 1 if the active note is private, 0 otherwise.
#!
#! Panics if:
#! - no note is currently active.
#!
#! Invocation: exec
pub proc is_private
exec.get_metadata
# => [NOTE_ATTACHMENT, METADATA_HEADER]

# drop the attachment word, keeping the metadata header
dropw
# => [METADATA_HEADER]

exec.note::extract_note_type_from_metadata
# => [note_type]

eq.NOTE_TYPE_PRIVATE
# => [is_private]
end

#! Returns the sender of the active note.
#!
#! Inputs: []
Expand Down
27 changes: 27 additions & 0 deletions crates/miden-protocol/asm/protocol/note.masm
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,30 @@ pub proc extract_attachment_info_from_metadata
u32split swap
# => [attachment_kind, attachment_scheme]
end

#! Extracts the note type from the provided metadata header.
#!
#! Inputs: [METADATA_HEADER]
#! Outputs: [note_type]
#!
#! Where:
#! - METADATA_HEADER is the metadata of a note.
#! - note_type is the note type of the note. It is stored in the low byte of the merged
#! sender_id_suffix_and_note_type felt and is not part of the recipient commitment.
#!
#! Invocation: exec
pub proc extract_note_type_from_metadata
# => [sender_id_suffix_and_note_type, sender_id_prefix, tag, attachment_kind_scheme]

# keep the merged suffix/note_type felt and drop the other three header felts
movdn.3 drop drop drop
# => [sender_id_suffix_and_note_type]

# take the low 32 bits of the felt
u32split swap drop
# => [suffix_and_note_type_lo]

# mask to the low byte, which holds the note type
u32and.0xff
# => [note_type]
end
72 changes: 70 additions & 2 deletions crates/miden-testing/src/kernel_tests/tx/test_active_note.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ use miden_protocol::testing::account_id::{
ACCOUNT_ID_SENDER,
};
use miden_protocol::transaction::memory::{ASSET_SIZE, ASSET_VALUE_OFFSET};
use miden_protocol::{EMPTY_WORD, Felt, ONE, WORD_SIZE, Word};
use miden_protocol::{EMPTY_WORD, Felt, ONE, WORD_SIZE, Word, ZERO};
use miden_standards::code_builder::CodeBuilder;
use miden_standards::testing::mock_account::MockAccountExt;

use crate::kernel_tests::tx::ExecutionOutputExt;
use crate::utils::create_public_p2any_note;
use crate::utils::{create_p2any_note, create_public_p2any_note};
use crate::{
Auth,
MockChain,
Expand Down Expand Up @@ -128,6 +128,74 @@ async fn test_active_note_get_metadata() -> anyhow::Result<()> {
Ok(())
}

/// `is_public` / `is_private` return the correct flag for the active note's type.
#[rstest::rstest]
#[case::public(NoteType::Public)]
#[case::private(NoteType::Private)]
#[tokio::test]
async fn test_active_note_is_public_and_is_private(
#[case] note_type: NoteType,
) -> anyhow::Result<()> {
Comment thread
partylikeits1983 marked this conversation as resolved.
let tx_context = {
let account =
Account::mock(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE, Auth::IncrNonce);
let mut rng = RandomCoin::new(Default::default());
let input_note = create_p2any_note(
ACCOUNT_ID_SENDER.try_into().unwrap(),
note_type,
[FungibleAsset::mock(100)],
&mut rng,
);
TransactionContextBuilder::new(account)
.extend_input_notes(vec![input_note])
.build()?
};

// The Rust and MASM `NoteType` encodings are identical, so derive the expected flags from the
// note type rather than passing them as separate test cases.
Comment thread
partylikeits1983 marked this conversation as resolved.
Outdated
let (expected_public, expected_private) = match note_type {
NoteType::Public => (ONE, ZERO),
NoteType::Private => (ZERO, ONE),
};

let code = format!(
r#"
use $kernel::prologue
use $kernel::note->note_internal
use miden::protocol::active_note

begin
exec.prologue::prepare_transaction
exec.note_internal::prepare_note
dropw dropw dropw dropw

# check whether the active note is public
exec.active_note::is_public
# => [is_public]

push.{expected_public}
assert_eq.err="active note public flag did not match expected value"
# => []

# check whether the active note is private
exec.active_note::is_private
# => [is_private]

push.{expected_private}
assert_eq.err="active note private flag did not match expected value"
# => []

# truncate the stack
swapw dropw
end
"#,
);

tx_context.execute_code(&code).await?;

Ok(())
}

#[tokio::test]
async fn test_active_note_get_sender() -> anyhow::Result<()> {
let tx_context = {
Expand Down
88 changes: 70 additions & 18 deletions crates/miden-testing/tests/agglayer/bridge_out.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ extern crate alloc;

use miden_agglayer::errors::{
ERR_B2AGG_DESTINATION_NETWORK_IS_MIDEN,
ERR_B2AGG_NOTE_MUST_BE_PUBLIC,
ERR_B2AGG_TARGET_ACCOUNT_MISMATCH,
ERR_FAUCET_NOT_REGISTERED,
};
Expand Down Expand Up @@ -29,12 +30,13 @@ use miden_protocol::account::{
StorageMapKey,
};
use miden_protocol::asset::{Asset, FungibleAsset};
use miden_protocol::note::{NoteAssets, NoteType};
use miden_protocol::errors::MasmError;
use miden_protocol::note::{Note, NoteAssets, NoteAttachment, NoteMetadata, NoteType};
use miden_protocol::transaction::RawOutputNote;
use miden_protocol::{Felt, Word};
use miden_standards::account::faucets::TokenMetadata;
use miden_standards::account::mint_policies::OwnerControlledInitConfig;
use miden_standards::note::StandardNote;
use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint, StandardNote};
use miden_testing::{Auth, MockChain, assert_transaction_executor_error};
use miden_tx::utils::hex_to_bytes;
use rand::rngs::StdRng;
Expand Down Expand Up @@ -541,10 +543,37 @@ async fn test_bridge_out_fails_with_unregistered_faucet() -> anyhow::Result<()>
Ok(())
}

/// B2AGG / bridge-out must reject a note whose `destination_network` equals the Miden network ID,
/// even when the faucet is registered and the rest of the bridge-out path would otherwise succeed.
/// The kind of malformation applied to an otherwise-valid B2AGG note in
/// [`test_bridge_out_rejects_invalid_b2agg_note`].
#[derive(Debug, Clone, Copy)]
enum InvalidB2aggNote {
/// `destination_network` equals Miden's AggLayer network ID.
DestinationIsMiden,
/// Recipient-identical note marked `NoteType::Private` instead of public.
PrivateNoteType,
}

/// B2AGG / bridge-out must reject malformed notes even when the faucet is registered and the rest
/// of the bridge-out path would otherwise succeed.
///
/// - `DestinationIsMiden`: the destination network equals Miden's own network ID, which
/// `bridge_out` rejects outright.
/// - `PrivateNoteType`: the note type lives in `NoteMetadata`, not in the recipient commitment, so
/// an attacker can assemble a note with an identical recipient/attachment/asset but
/// `NoteType::Private`. If accepted, the leaf would be folded into the on-chain Local Exit Tree
/// while AggLayer's off-chain indexer (aggkit) could never recover the pre-image, permanently
/// desyncing the LET mirror. The bridge branch must reject it.
#[rstest::rstest]
#[case::destination_is_miden(
InvalidB2aggNote::DestinationIsMiden,
ERR_B2AGG_DESTINATION_NETWORK_IS_MIDEN
)]
#[case::private_note_type(InvalidB2aggNote::PrivateNoteType, ERR_B2AGG_NOTE_MUST_BE_PUBLIC)]
#[tokio::test]
async fn test_bridge_out_fails_when_destination_is_miden_network() -> anyhow::Result<()> {
async fn test_bridge_out_rejects_invalid_b2agg_note(
#[case] invalid_note: InvalidB2aggNote,
#[case] expected_err: MasmError,
) -> anyhow::Result<()> {
let mut builder = MockChain::builder();

// CREATE BRIDGE ADMIN ACCOUNT (sends CONFIG_AGG_BRIDGE notes)
Expand Down Expand Up @@ -607,24 +636,47 @@ async fn test_bridge_out_fails_when_destination_is_miden_network() -> anyhow::Re
)?;
builder.add_output_note(RawOutputNote::Full(config_note.clone()));

// CREATE B2AGG NOTE (targets the bridge)
// Set destination_network to exactly `AggLayerBridge::MIDEN_NETWORK_ID` so `bridge_out`
// fails immediately.
// CREATE THE INVALID B2AGG NOTE (targets the bridge)
// --------------------------------------------------------------------------------------------
let amount = Felt::new(100);
let bridge_asset: Asset =
FungibleAsset::new(faucet.id(), amount.as_canonical_u64()).unwrap().into();
let eth_address =
EthAddress::from_hex(&vectors.destination_addresses[0]).expect("valid destination address");

let b2agg_note = B2AggNote::create(
AggLayerBridge::MIDEN_NETWORK_ID,
eth_address,
NoteAssets::new(vec![bridge_asset])?,
bridge_account.id(),
faucet.id(),
builder.rng_mut(),
)?;
let b2agg_note = match invalid_note {
// Destination network equals Miden's own network ID, which `bridge_out` rejects.
InvalidB2aggNote::DestinationIsMiden => B2AggNote::create(
AggLayerBridge::MIDEN_NETWORK_ID,
eth_address,
NoteAssets::new(vec![bridge_asset])?,
bridge_account.id(),
faucet.id(),
builder.rng_mut(),
)?,
// Build a legitimate (public) B2AGG note with a valid destination network, then assemble an
// attack note reusing its exact recipient and assets but flipping the note type to Private.
// The note type is not part of the recipient commitment, so the two notes share a recipient
// and are indistinguishable to consensus apart from the type bit.
InvalidB2aggNote::PrivateNoteType => {
let public_note = B2AggNote::create(
origin_network,
eth_address,
NoteAssets::new(vec![bridge_asset])?,
bridge_account.id(),
faucet.id(),
builder.rng_mut(),
)?;

let attachment = NoteAttachment::from(
NetworkAccountTarget::new(bridge_account.id(), NoteExecutionHint::Always)
.map_err(|e| anyhow::anyhow!(e.to_string()))?,
);
let metadata =
NoteMetadata::new(faucet.id(), NoteType::Private).with_attachment(attachment);
Note::new(public_note.assets().clone(), metadata, public_note.recipient().clone())
},
};

builder.add_output_note(RawOutputNote::Full(b2agg_note.clone()));

Expand All @@ -644,7 +696,7 @@ async fn test_bridge_out_fails_when_destination_is_miden_network() -> anyhow::Re
mock_chain.add_pending_executed_transaction(&config_executed)?;
mock_chain.prove_next_block()?;

// TX1: EXECUTE B2AGG NOTE AGAINST BRIDGE (must fail: destination_network is Miden's ID)
// TX1: EXECUTE THE INVALID B2AGG NOTE AGAINST BRIDGE (must fail)
// --------------------------------------------------------------------------------------------
let foreign_account_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?;

Expand All @@ -655,7 +707,7 @@ async fn test_bridge_out_fails_when_destination_is_miden_network() -> anyhow::Re
.execute()
.await;

assert_transaction_executor_error!(result, ERR_B2AGG_DESTINATION_NETWORK_IS_MIDEN);
assert_transaction_executor_error!(result, expected_err);

Ok(())
}
Expand Down
Loading