Skip to content
Merged
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 @@ -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
8 changes: 8 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,8 @@ 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 so its pre-image is observable off-chain.
#!
#! Inputs: [ASSET_KEY, ASSET_VALUE, dest_network_id, dest_address(5), pad(2)]
#! Outputs: [pad(16)]
#!
Expand All @@ -107,11 +110,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)
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
62 changes: 62 additions & 0 deletions crates/miden-protocol/asm/protocol/active_note.masm
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ use miden::protocol::kernel_proc_offsets::INPUT_NOTE_GET_METADATA_OFFSET
use miden::protocol::kernel_proc_offsets::INPUT_NOTE_GET_SERIAL_NUMBER_OFFSET
use miden::protocol::kernel_proc_offsets::INPUT_NOTE_GET_SCRIPT_ROOT_OFFSET
use miden::protocol::note
use miden::protocol::util::note::NOTE_TYPE_PUBLIC
use miden::protocol::util::note::NOTE_TYPE_PRIVATE

# ERRORS
# =================================================================================================
Expand Down Expand Up @@ -166,6 +168,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
5 changes: 5 additions & 0 deletions crates/miden-protocol/asm/shared_utils/util/note.masm
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
# The maximum number of storage values associated with a single note.
pub const MAX_NOTE_STORAGE_ITEMS = 1024

# Note type encoding, mirroring the Rust `NoteType` definition in `note/note_type.rs`
# (`PUBLIC = 0b01`, `PRIVATE = 0b10`).
pub const NOTE_TYPE_PUBLIC=1
pub const NOTE_TYPE_PRIVATE=2

#! Signals the absence of a note attachment.
pub const ATTACHMENT_KIND_NONE=0
#! A note attachment consisting of a single Word.
Expand Down
70 changes: 68 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,72 @@ 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()?
};

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