Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
12 changes: 12 additions & 0 deletions crates/miden-agglayer/asm/note_scripts/B2AGG.masm
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use miden::protocol::active_account
use miden::protocol::active_note
use miden::protocol::asset
use miden::standards::attachments::network_account_target
use miden::standards::note::metadata
use miden::standards::wallets::basic->basic_wallet

# CONSTANTS
Expand All @@ -20,6 +21,7 @@ const B2AGG_NOTE_NUM_STORAGE_ITEMS = 6
const ERR_B2AGG_WRONG_NUMBER_OF_ASSETS="B2AGG script requires exactly 1 note asset"
const ERR_B2AGG_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS="B2AGG script expects exactly 6 note storage items"
const ERR_B2AGG_TARGET_ACCOUNT_MISMATCH="B2AGG note attachment target account does not match consuming account"
const ERR_B2AGG_NOTE_MUST_BE_PUBLIC="B2AGG note bridged to AggLayer must be of public note type"

# NOTE SCRIPT
# =================================================================================================
Expand All @@ -32,6 +34,11 @@ const ERR_B2AGG_TARGET_ACCOUNT_MISMATCH="B2AGG note attachment target account do
#! - If the consuming account is the Agglayer Bridge: the note's assets are moved to a BURN note,
#! and the note details are hashed into a leaf and appended to the Local Exit Tree.
#!
#! When consumed by the bridge, the note must be public: AggLayer's off-chain indexer (aggkit)
#! mirrors the Local Exit Tree by observing notes in the public block space, so the leaf 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 storage is trusted.
#!
#! Inputs: []
#! Outputs: []
#!
Expand All @@ -50,6 +57,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.
#! - The destination network ID equals Miden's AggLayer network ID.
begin
dropw
Expand All @@ -75,6 +83,10 @@ begin
assert.err=ERR_B2AGG_TARGET_ACCOUNT_MISMATCH
# => [pad(16)]

# The bridged note must be public (see the note documentation above).
exec.metadata::is_note_public assert.err=ERR_B2AGG_NOTE_MUST_BE_PUBLIC
Comment thread
partylikeits1983 marked this conversation as resolved.
Outdated
# => [pad(16)]

# Store note storage -> mem[8..14]
push.B2AGG_NOTE_STORAGE_PTR exec.active_note::get_storage
# => [num_storage_items, storage_ptr, pad(16)]
Expand Down
48 changes: 48 additions & 0 deletions crates/miden-standards/asm/standards/note/metadata.masm
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
use miden::protocol::active_note

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

# Note type encoding for a public note (see the protocol note type definition).
const NOTE_TYPE_PUBLIC = 1
Comment thread
partylikeits1983 marked this conversation as resolved.
Outdated

# PROCEDURES
# =================================================================================================

#! Returns whether the currently executing note is public.
#!
#! The note type is stored in the low byte of the metadata header's `sender_id_suffix_and_note_type`
#! felt and is not part of the recipient commitment. 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.
pub proc is_note_public
Comment thread
partylikeits1983 marked this conversation as resolved.
Outdated
exec.active_note::get_metadata
# => [NOTE_ATTACHMENT, METADATA_HEADER]

# drop the attachment word, keeping the metadata header
dropw
# => [sender_id_suffix_and_note_type, sender_id_prefix, tag, attachment_kind_scheme]

# move the merged suffix/note_type felt to the bottom 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]

eq.NOTE_TYPE_PUBLIC
# => [is_public]
Comment thread
partylikeits1983 marked this conversation as resolved.
Outdated
end
58 changes: 56 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,60 @@ async fn test_active_note_get_metadata() -> anyhow::Result<()> {
Ok(())
}

/// `is_note_public` returns 1 for a public active note and 0 for a private one.
#[rstest::rstest]
#[case::public(NoteType::Public, ONE)]
#[case::private(NoteType::Private, ZERO)]
#[tokio::test]
async fn test_active_note_is_note_public(
#[case] note_type: NoteType,
#[case] expected_is_public: Felt,
) -> 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 code = format!(
r#"
use $kernel::prologue
use $kernel::note->note_internal
use miden::standards::note::metadata

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

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

push.{expected_is_public}
assert_eq.err="active note public 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