From 2569f4412b1748b084bd2e1c441e540748a02abf Mon Sep 17 00:00:00 2001 From: Alexander John Lee <77119221+partylikeits1983@users.noreply.github.com> Date: Tue, 26 May 2026 17:40:18 -0400 Subject: [PATCH 1/6] fix(agglayer): enforce NoteType::Public for B2AGG bridge-out notes The B2AGG note type lives in NoteMetadata, not in the recipient commitment, so a recipient-identical note marked NoteType::Private was accepted on-chain. Its leaf would be folded into the Local Exit Tree while aggkit could never recover the pre-image, permanently desyncing the LET mirror and bricking the bridge-out path. Enforce the public note type in the bridge branch of B2AGG.masm before the note storage is trusted. The public/private check is factored into a reusable miden::standards::note::metadata::is_note_public procedure that returns whether the active note is public. Add a parameterized test covering the recipient-identical private note alongside the existing invalid-destination case. Closes #2984 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../asm/note_scripts/B2AGG.masm | 12 +++ .../asm/standards/note/metadata.masm | 45 ++++++++++ .../src/kernel_tests/tx/test_active_note.rs | 58 +++++++++++- .../tests/agglayer/bridge_out.rs | 88 +++++++++++++++---- 4 files changed, 183 insertions(+), 20 deletions(-) create mode 100644 crates/miden-standards/asm/standards/note/metadata.masm diff --git a/crates/miden-agglayer/asm/note_scripts/B2AGG.masm b/crates/miden-agglayer/asm/note_scripts/B2AGG.masm index e4605dc74e..80e26959b4 100644 --- a/crates/miden-agglayer/asm/note_scripts/B2AGG.masm +++ b/crates/miden-agglayer/asm/note_scripts/B2AGG.masm @@ -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 @@ -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 # ================================================================================================= @@ -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: [] #! @@ -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 @@ -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 + # => [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)] diff --git a/crates/miden-standards/asm/standards/note/metadata.masm b/crates/miden-standards/asm/standards/note/metadata.masm new file mode 100644 index 0000000000..c557734d59 --- /dev/null +++ b/crates/miden-standards/asm/standards/note/metadata.masm @@ -0,0 +1,45 @@ +use miden::protocol::active_note + +# CONSTANTS +# ================================================================================================= + +# Note type encoding for a public note (see the protocol note type definition). +const NOTE_TYPE_PUBLIC = 1 + +# PROCEDURES +# ================================================================================================= + +#! Returns whether the currently executing note is public. +#! +#! The note type is stored in the note's metadata header, specifically in the low byte of the +#! `sender_id_suffix_and_note_type` felt (the suffix's low byte is reserved for the note type), 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 + 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] + + # extract the note type: take the low 32 bits, then mask off the low byte + u32split swap drop u32and.0xff + # => [note_type] + + eq.NOTE_TYPE_PUBLIC + # => [is_public] +end diff --git a/crates/miden-testing/src/kernel_tests/tx/test_active_note.rs b/crates/miden-testing/src/kernel_tests/tx/test_active_note.rs index 3130c622f5..7efdce2868 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_active_note.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_active_note.rs @@ -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, @@ -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<()> { + 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 = { diff --git a/crates/miden-testing/tests/agglayer/bridge_out.rs b/crates/miden-testing/tests/agglayer/bridge_out.rs index 4438ee2aa1..2f433f5e61 100644 --- a/crates/miden-testing/tests/agglayer/bridge_out.rs +++ b/crates/miden-testing/tests/agglayer/bridge_out.rs @@ -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, }; @@ -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; @@ -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) @@ -607,9 +636,7 @@ 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 = @@ -617,14 +644,39 @@ async fn test_bridge_out_fails_when_destination_is_miden_network() -> anyhow::Re 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())); @@ -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())?; @@ -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(()) } From 6142fa39ee1c932752daabe1201f9c6506db1496 Mon Sep 17 00:00:00 2001 From: riemann Date: Tue, 26 May 2026 22:30:21 -0400 Subject: [PATCH 2/6] chore(agglayer): add changelog entry for #2988 and clarify is_note_public comments Add the missing CHANGELOG.md entry for the B2AGG public-note enforcement so the changelog CI check passes, and tidy the is_note_public doc/inline comments (drop a redundant parenthetical, fix the 'mask off' wording, and split the note-type extraction into one op per line to match the repo's masm style). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + .../asm/standards/note/metadata.masm | 15 +++++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f23a0b4bed..3d6b220c72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/crates/miden-standards/asm/standards/note/metadata.masm b/crates/miden-standards/asm/standards/note/metadata.masm index c557734d59..5f1f294d4c 100644 --- a/crates/miden-standards/asm/standards/note/metadata.masm +++ b/crates/miden-standards/asm/standards/note/metadata.masm @@ -11,10 +11,9 @@ const NOTE_TYPE_PUBLIC = 1 #! Returns whether the currently executing note is public. #! -#! The note type is stored in the note's metadata header, specifically in the low byte of the -#! `sender_id_suffix_and_note_type` felt (the suffix's low byte is reserved for the note type), and -#! is not part of the recipient commitment. This reads the active note's metadata and compares the -#! note type against the public encoding. +#! 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] @@ -36,8 +35,12 @@ pub proc is_note_public movdn.3 drop drop drop # => [sender_id_suffix_and_note_type] - # extract the note type: take the low 32 bits, then mask off the low byte - u32split swap drop u32and.0xff + # 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 From 8c07834fa631523861632730e18c6f8af54f47fc Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Wed, 27 May 2026 11:24:21 -0400 Subject: [PATCH 3/6] refactor: enforce B2AGG public-note check in bridge_out, add active_note::is_public/is_private Address review feedback on #2988: - Move the public-note check out of the B2AGG note script and into bridge::bridge_out::bridge_out, where it belongs (per Bobbin). - Move the is_public logic into the protocol active_note module as active_note::is_public, backed by a new reusable note::extract_note_type_from_metadata primitive (per Bobbin & Philipp). - Add active_note::is_private for completeness (per Philipp), and drop the redundant expected_is_public test column by deriving the expected flags from NoteType. - Remove the standalone standards/note/metadata.masm module. Co-Authored-By: Claude Opus 4.7 --- .../asm/agglayer/bridge/bridge_out.masm | 11 +++ .../asm/note_scripts/B2AGG.masm | 13 +--- .../asm/protocol/active_note.masm | 68 +++++++++++++++++++ crates/miden-protocol/asm/protocol/note.masm | 27 ++++++++ .../asm/standards/note/metadata.masm | 48 ------------- .../src/kernel_tests/tx/test_active_note.rs | 30 +++++--- 6 files changed, 129 insertions(+), 68 deletions(-) delete mode 100644 crates/miden-standards/asm/standards/note/metadata.masm diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm index c0bcff19af..12002ca5d2 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm @@ -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 # ================================================================================================= @@ -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. +#! #! Inputs: [ASSET_KEY, ASSET_VALUE, dest_network_id, dest_address(5), pad(2)] #! Outputs: [pad(16)] #! @@ -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. + 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 diff --git a/crates/miden-agglayer/asm/note_scripts/B2AGG.masm b/crates/miden-agglayer/asm/note_scripts/B2AGG.masm index 80e26959b4..8b98314cce 100644 --- a/crates/miden-agglayer/asm/note_scripts/B2AGG.masm +++ b/crates/miden-agglayer/asm/note_scripts/B2AGG.masm @@ -4,7 +4,6 @@ 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 @@ -21,7 +20,6 @@ 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 # ================================================================================================= @@ -34,11 +32,6 @@ const ERR_B2AGG_NOTE_MUST_BE_PUBLIC="B2AGG note bridged to AggLayer must be of p #! - 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: [] #! @@ -57,7 +50,7 @@ const ERR_B2AGG_NOTE_MUST_BE_PUBLIC="B2AGG note bridged to AggLayer must be of p #! - 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 note is not public (enforced by `bridge_out`). #! - The destination network ID equals Miden's AggLayer network ID. begin dropw @@ -83,10 +76,6 @@ 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 - # => [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)] diff --git a/crates/miden-protocol/asm/protocol/active_note.masm b/crates/miden-protocol/asm/protocol/active_note.masm index cf47a36aa1..36d071ddc6 100644 --- a/crates/miden-protocol/asm/protocol/active_note.masm +++ b/crates/miden-protocol/asm/protocol/active_note.masm @@ -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 + # ACTIVE NOTE PROCEDURES # ================================================================================================= # @@ -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: [] diff --git a/crates/miden-protocol/asm/protocol/note.masm b/crates/miden-protocol/asm/protocol/note.masm index 8962a86263..e7aa136f48 100644 --- a/crates/miden-protocol/asm/protocol/note.masm +++ b/crates/miden-protocol/asm/protocol/note.masm @@ -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 diff --git a/crates/miden-standards/asm/standards/note/metadata.masm b/crates/miden-standards/asm/standards/note/metadata.masm deleted file mode 100644 index 5f1f294d4c..0000000000 --- a/crates/miden-standards/asm/standards/note/metadata.masm +++ /dev/null @@ -1,48 +0,0 @@ -use miden::protocol::active_note - -# CONSTANTS -# ================================================================================================= - -# Note type encoding for a public note (see the protocol note type definition). -const NOTE_TYPE_PUBLIC = 1 - -# 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 - 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] -end diff --git a/crates/miden-testing/src/kernel_tests/tx/test_active_note.rs b/crates/miden-testing/src/kernel_tests/tx/test_active_note.rs index 7efdce2868..b97b491fdd 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_active_note.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_active_note.rs @@ -128,14 +128,13 @@ 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. +/// `is_public` / `is_private` return the correct flag for the active note's type. #[rstest::rstest] -#[case::public(NoteType::Public, ONE)] -#[case::private(NoteType::Private, ZERO)] +#[case::public(NoteType::Public)] +#[case::private(NoteType::Private)] #[tokio::test] -async fn test_active_note_is_note_public( +async fn test_active_note_is_public_and_is_private( #[case] note_type: NoteType, - #[case] expected_is_public: Felt, ) -> anyhow::Result<()> { let tx_context = { let account = @@ -152,11 +151,18 @@ async fn test_active_note_is_note_public( .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. + 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::standards::note::metadata + use miden::protocol::active_note begin exec.prologue::prepare_transaction @@ -164,13 +170,21 @@ async fn test_active_note_is_note_public( dropw dropw dropw dropw # check whether the active note is public - exec.metadata::is_note_public + exec.active_note::is_public # => [is_public] - push.{expected_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 From 336b5c610d1ae4c2fe3971e104ed81540c6646f4 Mon Sep 17 00:00:00 2001 From: Alexander John Lee <77119221+partylikeits1983@users.noreply.github.com> Date: Wed, 27 May 2026 15:41:40 -0400 Subject: [PATCH 4/6] Update crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm Co-authored-by: Marti --- crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm index 12002ca5d2..4c07a31510 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm @@ -119,7 +119,7 @@ const BURN_NOTE_NUM_STORAGE_ITEMS=0 #! Invocation: call @locals(15) pub proc bridge_out - # The note being bridged out must be public (see above); checked before its data is trusted. + # 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)] From 2ba80873a4db8a8731c2b520f620c418f4dda082 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Wed, 27 May 2026 16:14:47 -0400 Subject: [PATCH 5/6] docs(agglayer): trim verbose bridge_out doc comment and test narration Address review nits on #2988: reduce the bridge_out public-note doc comment to a single behavior-level line (dropping indexer mechanism and metadata-header implementation detail), and remove the change-narrating comment in test_active_note_is_public_and_is_private. Co-Authored-By: Claude (Opus) --- crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm | 5 +---- crates/miden-testing/src/kernel_tests/tx/test_active_note.rs | 2 -- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm index 4c07a31510..4933d9c4e3 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm @@ -98,10 +98,7 @@ 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. +#! 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)] diff --git a/crates/miden-testing/src/kernel_tests/tx/test_active_note.rs b/crates/miden-testing/src/kernel_tests/tx/test_active_note.rs index b97b491fdd..05ae2c87ed 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_active_note.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_active_note.rs @@ -151,8 +151,6 @@ async fn test_active_note_is_public_and_is_private( .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. let (expected_public, expected_private) = match note_type { NoteType::Public => (ONE, ZERO), NoteType::Private => (ZERO, ONE), From e5d35fbf5362102a71e1b892b3b77ada3c9502ed Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Wed, 27 May 2026 17:51:22 -0400 Subject: [PATCH 6/6] refactor(protocol): source note-type constants from shared util module Move NOTE_TYPE_PUBLIC / NOTE_TYPE_PRIVATE out of active_note.masm and into the note shared utils module (miden::protocol::util::note), alongside MAX_NOTE_STORAGE_ITEMS and ATTACHMENT_KIND_*, then import them rather than redefining locally. Addresses review feedback on duplicating protocol-level data. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/miden-protocol/asm/protocol/active_note.masm | 10 ++-------- crates/miden-protocol/asm/shared_utils/util/note.masm | 5 +++++ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/miden-protocol/asm/protocol/active_note.masm b/crates/miden-protocol/asm/protocol/active_note.masm index 36d071ddc6..b8691485a4 100644 --- a/crates/miden-protocol/asm/protocol/active_note.masm +++ b/crates/miden-protocol/asm/protocol/active_note.masm @@ -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 # ================================================================================================= @@ -16,14 +18,6 @@ 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 - # ACTIVE NOTE PROCEDURES # ================================================================================================= # diff --git a/crates/miden-protocol/asm/shared_utils/util/note.masm b/crates/miden-protocol/asm/shared_utils/util/note.masm index 066dfcd2fb..bd7a311840 100644 --- a/crates/miden-protocol/asm/shared_utils/util/note.masm +++ b/crates/miden-protocol/asm/shared_utils/util/note.masm @@ -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.