Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
52 changes: 52 additions & 0 deletions crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use miden::core::word
use miden::protocol::note
use miden::protocol::output_note
use miden::protocol::output_note::ATTACHMENT_KIND_NONE
use miden::protocol::native_account
use miden::protocol::tx
use miden::standards::note_tag
use miden::standards::attachments::network_account_target
Expand All @@ -31,10 +32,21 @@ const ERR_LEADING_BITS_NON_ZERO = "leading bits of global index must be zero"
const ERR_ROLLUP_INDEX_NON_ZERO = "rollup index must be zero for a mainnet deposit"
const ERR_SMT_ROOT_VERIFICATION_FAILED = "merkle proof verification failed: provided SMT root does not match the computed root"
const ERR_INVALID_CLAIM_PROOF = "invalid claim proof"
const ERR_CLAIM_ALREADY_SPENT = "claim note has already been spent"

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

# Storage Slots
# -------------------------------------------------------------------------------------------------

# The slot in this component's storage layout where claim nullifiers are stored.
# Map entries: PROOF_DATA_KEY => [1, 0, 0, 0]
const CLAIM_NULLIFIERS_SLOT = word("miden::agglayer::bridge::claim_nullifiers")

# Claim Nullifier Flag
const IS_CLAIMED_FLAG = [1, 0, 0, 0]

# Data sizes
# -------------------------------------------------------------------------------------------------

Expand Down Expand Up @@ -326,6 +338,13 @@ pub proc claim
exec.verify_leaf_bridge
# => [pad(16)]

# Assert the claim has not been spent and mark it as spent
mem_loadw_be.CLAIM_PROOF_DATA_KEY_MEM_ADDR
# => [PROOF_DATA_KEY, pad(16)]

exec.assert_claim_not_spent
# => [pad(16)]

# Look up the faucet account ID from the origin token address
exec.get_origin_token_address
# => [origin_token_addr(5), pad(16)]
Expand All @@ -351,6 +370,39 @@ end
# HELPER PROCEDURES
# =================================================================================================

#! Checks that the CLAIM note identified by PROOF_DATA_KEY has not already been spent,
#! and marks it as spent by storing [1, 0, 0, 0] in the CLAIM_NULLIFIERS_SLOT map.
#!
#! This is analogous to the multisig's `assert_new_tx` procedure which tracks executed
#! transactions. Here we track consumed CLAIM notes via their PROOF_DATA_KEY.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can remove this comment since it might not make sense to someone not familiar with the multisig.

#!
#! Inputs: [PROOF_DATA_KEY]
#! Outputs: []
#!
#! Panics if:
#! - the CLAIM note has already been spent (PROOF_DATA_KEY already exists in the nullifier map).
#!
#! Invocation: exec
proc assert_claim_not_spent(proof_data_key: BeWord)
push.IS_CLAIMED_FLAG
# => [IS_CLAIMED_FLAG, PROOF_DATA_KEY]

swapw
# => [PROOF_DATA_KEY, IS_CLAIMED_FLAG]

push.CLAIM_NULLIFIERS_SLOT[0..2]
# => [slot_prefix, slot_suffix, PROOF_DATA_KEY, IS_CLAIMED_FLAG]

exec.native_account::set_map_item
# => [OLD_VALUE]

drop drop drop
# => [old_is_claimed]

assertz.err=ERR_CLAIM_ALREADY_SPENT
# => []
end

#! Verifies that the faucet_mint_amount matches the raw U256 amount from the leaf data,
#! scaled down by the faucet's scale factor.
#!
Expand Down
13 changes: 13 additions & 0 deletions crates/miden-agglayer/src/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ static GER_MANAGER_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
StorageSlotName::new("miden::agglayer::bridge::ger_manager")
.expect("GER manager storage slot name should be valid")
});
static CLAIM_NULLIFIERS_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
StorageSlotName::new("miden::agglayer::bridge::claim_nullifiers")
.expect("claim nullifiers storage slot name should be valid")
});

/// An [`AccountComponent`] implementing the AggLayer Bridge.
///
Expand All @@ -96,6 +100,8 @@ static GER_MANAGER_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
/// - [`Self::token_registry_slot_name`]: Stores the token address → faucet ID map.
/// - [`Self::bridge_admin_slot_name`]: Stores the bridge admin account ID.
/// - [`Self::ger_manager_slot_name`]: Stores the GER manager account ID.
/// - [`Self::claim_nullifiers_slot_name`]: Stores the CLAIM note nullifiers map (PROOF_DATA_KEY →
/// \[1, 0, 0, 0\]).
///
/// The bridge starts with an empty faucet registry; faucets are registered at runtime via
/// CONFIG_AGG_BRIDGE notes.
Expand Down Expand Up @@ -167,6 +173,11 @@ impl AggLayerBridge {
&GER_MANAGER_SLOT_NAME
}

/// Storage slot name for the CLAIM note nullifiers map.
pub fn claim_nullifiers_slot_name() -> &'static StorageSlotName {
&CLAIM_NULLIFIERS_SLOT_NAME
}

/// Returns a boolean indicating whether the provided GER is present in storage of the provided
/// bridge account.
///
Expand Down Expand Up @@ -331,6 +342,7 @@ impl AggLayerBridge {
&*TOKEN_REGISTRY_SLOT_NAME,
&*BRIDGE_ADMIN_SLOT_NAME,
&*GER_MANAGER_SLOT_NAME,
&*CLAIM_NULLIFIERS_SLOT_NAME,
]
}
}
Expand Down Expand Up @@ -360,6 +372,7 @@ impl From<AggLayerBridge> for AccountComponent {
StorageSlot::with_empty_map(TOKEN_REGISTRY_SLOT_NAME.clone()),
StorageSlot::with_value(BRIDGE_ADMIN_SLOT_NAME.clone(), bridge_admin_word),
StorageSlot::with_value(GER_MANAGER_SLOT_NAME.clone(), ger_manager_word),
StorageSlot::with_empty_map(CLAIM_NULLIFIERS_SLOT_NAME.clone()),
];
bridge_component(bridge_storage_slots)
}
Expand Down
2 changes: 2 additions & 0 deletions crates/miden-agglayer/src/errors/agglayer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ pub const ERR_B2AGG_WRONG_NUMBER_OF_ASSETS: MasmError = MasmError::from_static_s
/// Error Message: "bridge not mainnet"
pub const ERR_BRIDGE_NOT_MAINNET: MasmError = MasmError::from_static_str("bridge not mainnet");

/// Error Message: "claim note has already been spent"
pub const ERR_CLAIM_ALREADY_SPENT: MasmError = MasmError::from_static_str("claim note has already been spent");
/// Error Message: "CLAIM note attachment target account does not match consuming account"
pub const ERR_CLAIM_TARGET_ACCT_MISMATCH: MasmError = MasmError::from_static_str("CLAIM note attachment target account does not match consuming account");

Expand Down
155 changes: 155 additions & 0 deletions crates/miden-testing/tests/agglayer/bridge_in.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use alloc::string::String;

use anyhow::Context;
use miden_agglayer::claim_note::Keccak256Output;
use miden_agglayer::errors::ERR_CLAIM_ALREADY_SPENT;
use miden_agglayer::{
ClaimNoteStorage,
ConfigAggBridgeNote,
Expand Down Expand Up @@ -381,6 +382,160 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a
Ok(())
}

/// Tests that consuming a CLAIM note with the same PROOF_DATA_KEY twice fails.
///
/// This test verifies the nullifier tracking mechanism:
/// 1. Sets up the bridge (CONFIG + UPDATE_GER)
/// 2. Executes the first CLAIM note successfully
/// 3. Creates a second CLAIM note with the same proof data
/// 4. Attempts to execute the second CLAIM note and asserts it fails with "claim note has already
/// been spent"
#[tokio::test]
async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> {
let data_source = ClaimDataSource::Simulated;
let mut builder = MockChain::builder();

// CREATE BRIDGE ADMIN ACCOUNT
let bridge_admin =
builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Rpo })?;

// CREATE GER MANAGER ACCOUNT
let ger_manager =
builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Rpo })?;

// CREATE BRIDGE ACCOUNT
let bridge_seed = builder.rng_mut().draw_word();
let bridge_account =
create_existing_bridge_account(bridge_seed, bridge_admin.id(), ger_manager.id());
builder.add_account(bridge_account.clone())?;

// GET CLAIM DATA FROM JSON
let (proof_data, leaf_data, ger) = data_source.get_data();

// CREATE AGGLAYER FAUCET ACCOUNT
let token_symbol = "AGG";
let decimals = 8u8;
let max_supply = Felt::new(FungibleAsset::MAX_AMOUNT);
let agglayer_faucet_seed = builder.rng_mut().draw_word();

let origin_token_address = leaf_data.origin_token_address;
let origin_network = leaf_data.origin_network;
let scale = 10u8;

let agglayer_faucet = create_existing_agglayer_faucet(
agglayer_faucet_seed,
token_symbol,
decimals,
max_supply,
Felt::ZERO,
bridge_account.id(),
&origin_token_address,
origin_network,
scale,
);
builder.add_account(agglayer_faucet.clone())?;

// Calculate the scaled-down Miden amount
let miden_claim_amount = leaf_data
.amount
.scale_to_token_amount(scale as u32)
.expect("amount should scale successfully");

// CREATE FIRST CLAIM NOTE
let claim_inputs_1 = ClaimNoteStorage {
proof_data: proof_data.clone(),
leaf_data: leaf_data.clone(),
miden_claim_amount,
};

let claim_note_1 = create_claim_note(
claim_inputs_1,
bridge_account.id(),
bridge_admin.id(),
builder.rng_mut(),
)?;
builder.add_output_note(OutputNote::Full(claim_note_1.clone()));

// CREATE SECOND CLAIM NOTE (same proof data = same PROOF_DATA_KEY)
let claim_inputs_2 = ClaimNoteStorage {
proof_data: proof_data.clone(),
leaf_data: leaf_data.clone(),
miden_claim_amount,
};

let claim_note_2 = create_claim_note(
claim_inputs_2,
bridge_account.id(),
bridge_admin.id(),
builder.rng_mut(),
)?;
builder.add_output_note(OutputNote::Full(claim_note_2.clone()));

// CREATE CONFIG_AGG_BRIDGE NOTE
let config_note = ConfigAggBridgeNote::create(
agglayer_faucet.id(),
&origin_token_address,
bridge_admin.id(),
bridge_account.id(),
builder.rng_mut(),
)?;
builder.add_output_note(OutputNote::Full(config_note.clone()));

// CREATE UPDATE_GER NOTE
let update_ger_note =
UpdateGerNote::create(ger, ger_manager.id(), bridge_account.id(), builder.rng_mut())?;
builder.add_output_note(OutputNote::Full(update_ger_note.clone()));

// BUILD MOCK CHAIN
let mut mock_chain = builder.clone().build()?;

// TX0: CONFIG_AGG_BRIDGE
let config_tx_context = mock_chain
.build_tx_context(bridge_account.id(), &[config_note.id()], &[])?
.build()?;
let config_executed = config_tx_context.execute().await?;
mock_chain.add_pending_executed_transaction(&config_executed)?;
mock_chain.prove_next_block()?;

// TX1: UPDATE_GER
let update_ger_tx_context = mock_chain
.build_tx_context(bridge_account.id(), &[update_ger_note.id()], &[])?
.build()?;
let update_ger_executed = update_ger_tx_context.execute().await?;
mock_chain.add_pending_executed_transaction(&update_ger_executed)?;
mock_chain.prove_next_block()?;

// TX2: FIRST CLAIM (should succeed)
let faucet_foreign_inputs_1 = mock_chain.get_foreign_account_inputs(agglayer_faucet.id())?;
let claim_tx_context_1 = mock_chain
.build_tx_context(bridge_account.id(), &[], &[claim_note_1])?
.foreign_accounts(vec![faucet_foreign_inputs_1])
.build()?;
let claim_executed_1 = claim_tx_context_1.execute().await?;
assert_eq!(claim_executed_1.output_notes().num_notes(), 1);

mock_chain.add_pending_executed_transaction(&claim_executed_1)?;
mock_chain.prove_next_block()?;

// TX3: SECOND CLAIM WITH SAME PROOF_DATA_KEY (should fail)
let faucet_foreign_inputs_2 = mock_chain.get_foreign_account_inputs(agglayer_faucet.id())?;
let claim_tx_context_2 = mock_chain
.build_tx_context(bridge_account.id(), &[], &[claim_note_2])?
.foreign_accounts(vec![faucet_foreign_inputs_2])
.build()?;
let result = claim_tx_context_2.execute().await;

assert!(result.is_err(), "Second claim with same PROOF_DATA_KEY should fail");
let error_msg = result.unwrap_err().to_string();
let expected_err_code = ERR_CLAIM_ALREADY_SPENT.code().to_string();
assert!(
error_msg.contains(&expected_err_code),
"Expected error code {expected_err_code} for 'claim note has already been spent', got: {error_msg}"
);

Ok(())
}

#[tokio::test]
async fn solidity_verify_merkle_proof_compatibility() -> anyhow::Result<()> {
let merkle_paths = &*SOLIDITY_MERKLE_PROOF_VECTORS;
Expand Down
Loading