Skip to content
130 changes: 128 additions & 2 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::note_tag::DEFAULT_TAG
Expand All @@ -33,10 +34,21 @@ const ERR_LEADING_BITS_NON_ZERO = "leading bits of global index must be zero"
const ERR_MAINNET_FLAG_INVALID = "mainnet flag must be 0 or 1"
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_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: RPO(leaf_index, source_bridge_network) => [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 @@ -88,6 +100,9 @@ const CLAIM_OUTPUT_NOTE_FAUCET_AMOUNT = 568
const CLAIM_PROOF_DATA_KEY_MEM_ADDR = 700
const CLAIM_LEAF_DATA_KEY_MEM_ADDR = 704

# Memory address used as scratch space for computing the claim nullifier hash
const CLAIM_NULLIFIER_HASH_INPUT_PTR = 900

# Memory addresses for leaf data fields (derived from leaf data layout at CLAIM_LEAF_DATA_START_PTR=536)
const ORIGIN_TOKEN_ADDRESS_0 = 538
const ORIGIN_TOKEN_ADDRESS_1 = 539
Expand Down Expand Up @@ -201,7 +216,9 @@ end
#! Invocation: exec
pub proc process_global_index_mainnet
# the top 191 bits of the global index are zero
repeat.5 assertz.err=ERR_LEADING_BITS_NON_ZERO end
repeat.5
assertz.err=ERR_LEADING_BITS_NON_ZERO
end

# the next element is the mainnet flag (LE-packed u32)
# byte-swap to get the BE value, then assert it is exactly 1
Expand Down Expand Up @@ -392,6 +409,72 @@ end
# HELPER PROCEDURES
# =================================================================================================

#! Computes the claim nullifier as RPO(leaf_index, source_bridge_network), then checks
#! that the claim has not been spent and marks it as spent.
#!
#! This mimics the Solidity `_setAndCheckClaimed(leafIndex, sourceBridgeNetwork)` function.
#! See: https://github.com/agglayer/agglayer-contracts/blob/60d06fc3224792ce55dc2690d66b6719a73398e7/contracts/v2/PolygonZkEVMBridgeV2.sol#L987
#!
#! Inputs: [leaf_index, source_bridge_network]
#! Outputs: []
#!
#! Panics if:
#! - the CLAIM note has already been spent (nullifier already exists in the nullifier map).
#!
#! Invocation: exec
proc set_and_check_claimed
# Write both values to scratch memory for hashing
# mem[CLAIM_NULLIFIER_HASH_INPUT_PTR] = leaf_index
# mem[CLAIM_NULLIFIER_HASH_INPUT_PTR + 1] = source_bridge_network
mem_store.CLAIM_NULLIFIER_HASH_INPUT_PTR
# => [source_bridge_network]

push.CLAIM_NULLIFIER_HASH_INPUT_PTR add.1 mem_store
# => []

# Hash the two elements using RPO to produce the nullifier
# TODO: Replace rpo256::hash_elements with poseidon2::hash_elements when VM is updated
push.2 push.CLAIM_NULLIFIER_HASH_INPUT_PTR
exec.rpo256::hash_elements
# => [NULLIFIER]

exec.assert_claim_not_spent
# => []
end

#! Checks that the CLAIM note has not already been spent, and marks it as spent
#! by storing [1, 0, 0, 0] in the CLAIM_NULLIFIERS_SLOT map.
#!
#! The nullifier is computed as RPO(leaf_index, source_bridge_network), which uniquely
#! identifies a claim in the Global Exit Root (GER) as per the AggLayer protocol.
#!
#! Inputs: [NULLIFIER]
#! Outputs: []
#!
#! Panics if:
#! - the CLAIM note has already been spent (nullifier already exists in the nullifier map).
#!
#! Invocation: exec
proc assert_claim_not_spent(nullifier: BeWord)
push.IS_CLAIMED_FLAG
# => [IS_CLAIMED_FLAG, NULLIFIER]

swapw
# => [NULLIFIER, IS_CLAIMED_FLAG]

push.CLAIM_NULLIFIERS_SLOT[0..2]
# => [slot_prefix, slot_suffix, NULLIFIER, 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 Expand Up @@ -481,7 +564,12 @@ pub proc get_leaf_value(leaf_data_key: BeWord) -> DoubleWord
# => [LEAF_VALUE[8]]
end

#! Verify leaf and checks that it has not been claimed.
#! Verify leaf, check that it has not been claimed, and mark it as claimed.
#!
#! This mimics the Solidity `_verifyLeaf` function which calls `_setAndCheckClaimed`
#! internally, since the global index (containing leaf_index and source_bridge_network)
#! is already extracted during verification.
#! See: https://github.com/agglayer/agglayer-contracts/blob/60d06fc3224792ce55dc2690d66b6719a73398e7/contracts/v2/PolygonZkEVMBridgeV2.sol#L987
#!
#! Inputs:
#! Operand stack: [LEAF_VALUE[8], PROOF_DATA_KEY]
Expand All @@ -492,6 +580,7 @@ end
#! - the computed GER is invalid (never injected).
#! - the global index is invalid.
#! - the Merkle proof for the provided leaf-index tuple against the computed GER is invalid.
#! - the claim has already been spent.
#!
#! Invocation: exec
proc verify_leaf
Expand Down Expand Up @@ -536,6 +625,10 @@ proc verify_leaf
exec.process_global_index_mainnet
# => [leaf_index, LEAF_VALUE[8]]

# Save leaf_index to scratch memory for set_and_check_claimed after proof verification
dup mem_store.CLAIM_NULLIFIER_HASH_INPUT_PTR
# => [leaf_index, LEAF_VALUE[8]]

# verify single Merkle proof: leaf against mainnetExitRoot
push.MAINNET_EXIT_ROOT_PTR swap
push.SMT_PROOF_LOCAL_EXIT_ROOT_PTR
Expand All @@ -549,13 +642,28 @@ proc verify_leaf

assert.err=ERR_SMT_ROOT_VERIFICATION_FAILED
# => []

# For mainnet deposits, source_bridge_network = 0 (_MAINNET_NETWORK_ID)
# See Solidity: networkID == _MAINNET_NETWORK_ID && sourceBridgeNetwork == _ZKEVM_NETWORK_ID
# In this case globalIndex = uint256(leafIndex), so source_bridge_network = 0
push.0 mem_load.CLAIM_NULLIFIER_HASH_INPUT_PTR
# => [leaf_index, source_bridge_network]
else
# ==================== ROLLUP DEPOSIT ====================
# mainnet_flag = 0; extract rollup_index and leaf_index via helper,
# then do two-level verification
exec.process_global_index_rollup
# => [leaf_index, rollup_index, LEAF_VALUE[8]]

# Save leaf_index and rollup_index to scratch memory for set_and_check_claimed
# mem[CLAIM_NULLIFIER_HASH_INPUT_PTR] = leaf_index
# mem[CLAIM_NULLIFIER_HASH_INPUT_PTR + 1] = rollup_index
dup mem_store.CLAIM_NULLIFIER_HASH_INPUT_PTR
# => [leaf_index, rollup_index, LEAF_VALUE[8]]

dup.1 push.CLAIM_NULLIFIER_HASH_INPUT_PTR add.1 mem_store
# => [leaf_index, rollup_index, LEAF_VALUE[8]]

# Step 1: calculate_root(leafValue, smtProofLocalExitRoot, leafIndex) -> localExitRoot
# calculate_root expects: [LEAF_VALUE_LO, LEAF_VALUE_HI, merkle_path_ptr, leaf_index]
movdn.9 movdn.9
Expand All @@ -581,7 +689,25 @@ proc verify_leaf

assert.err=ERR_SMT_ROOT_VERIFICATION_FAILED
# => []

# For rollup deposits, source_bridge_network = rollup_index + 1
# See Solidity: sourceBridgeNetwork = indexRollup + 1
# https://github.com/agglayer/agglayer-contracts/blob/60d06fc3224792ce55dc2690d66b6719a73398e7/contracts/v2/PolygonZkEVMBridgeV2.sol#L961
# Reload saved values: leaf_index and rollup_index
# source_bridge_network = rollup_index + 1
push.CLAIM_NULLIFIER_HASH_INPUT_PTR add.1 mem_load u32wrapping_add.1
# => [source_bridge_network]

mem_load.CLAIM_NULLIFIER_HASH_INPUT_PTR
# => [leaf_index, source_bridge_network]
end

# 4. Check the claim has not been spent and mark it as spent.
# Both branches leave [leaf_index, source_bridge_network] on the stack.
# This mimics Solidity's _setAndCheckClaimed(leafIndex, sourceBridgeNetwork) called
# from within _verifyLeaf.
exec.set_and_check_claimed
# => []
end

# Inputs: [PROOF_DATA_KEY, LEAF_DATA_KEY]
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 @@ -94,6 +98,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 (RPO(leaf_index,
/// source_bridge_network) → \[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 @@ -165,6 +171,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 @@ -329,6 +340,7 @@ impl AggLayerBridge {
&*TOKEN_REGISTRY_SLOT_NAME,
&*BRIDGE_ADMIN_SLOT_NAME,
&*GER_MANAGER_SLOT_NAME,
&*CLAIM_NULLIFIERS_SLOT_NAME,
]
}
}
Expand Down Expand Up @@ -358,6 +370,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 @@ -21,6 +21,8 @@ pub const ERR_BRIDGE_NOT_MAINNET: MasmError = MasmError::from_static_str("mainne
/// Error Message: "mainnet flag must be 0 for a rollup deposit"
pub const ERR_BRIDGE_NOT_ROLLUP: MasmError = MasmError::from_static_str("mainnet flag must be 0 for a rollup deposit");

/// 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
Loading
Loading