Skip to content

Add Agglayer CLAIM note & bridging in functionality#2188

Merged
bobbinth merged 57 commits intoagglayerfrom
ajl-claim-bridge-in
Jan 8, 2026
Merged

Add Agglayer CLAIM note & bridging in functionality#2188
bobbinth merged 57 commits intoagglayerfrom
ajl-claim-bridge-in

Conversation

@partylikeits1983
Copy link
Contributor

I closed #2119 as the name of the branch ajl-claim-to-mint-note was misleading and no longer matched the design we agreed upon.

Description

This PR adds a stubbed out version of the CLAIM note which enables minting of an asset from the Agglayer faucet contract.

High level overview of the flow:

User creates CLAIM network note, which is consumed by the agglayer faucet contract. The agglayer faucet contract does an FPI call to the agglayer bridge which checks the validity of the rollup exit root Merkle Proof (currently stubbed out). If the merkle proof is valid, the bridge returns a "true" boolean which allows the user to mint the specified output note & asset, if false, the agglayer faucet will panic.

High level flow:
CLAIM -> agglayer_faucet::claim -> FPI call to Bridge -> agglayer_faucet::distribute -> P2ID note

Currently the CLAIM note does not encode any merkle proof data as outlined here: #1910 (comment)


There are a couple questions:

  1. If note inputs is ~600 Felt values, how to pass these values to the agglayer_faucet via .call? Do I need to push the Felt values to the AdviceMap in the CLAIM note, and then in the agglayer_bridge during the FPI call, read these values?

  2. Does this initial iteration of the CLAIM note need to stub out adding these ~600 Felt values as NoteInputs?

@partylikeits1983 partylikeits1983 added no changelog This PR does not require an entry in the `CHANGELOG.md` file agglayer PRs or issues related to AggLayer bridging integration labels Dec 16, 2025
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces functionality for bridging assets into Miden from the AggLayer by implementing a CLAIM note mechanism. Users can create CLAIM notes that are consumed by an AggLayer faucet, which validates the claim through a Foreign Procedure Invocation (FPI) to the bridge account before minting assets.

Key Changes:

  • Added CLAIM note script and creation helper function
  • Implemented agglayer_faucet component that validates claims via FPI to bridge account
  • Added test coverage for the complete bridge-in flow from CLAIM note to P2ID note creation

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
crates/miden-testing/tests/agglayer/mod.rs Adds bridge_in module to test suite
crates/miden-testing/tests/agglayer/bridge_in.rs New integration test validating the complete CLAIM → P2ID flow
crates/miden-testing/src/mock_chain/chain.rs Makes get_foreign_account_inputs public for FPI testing
crates/miden-lib/src/errors/note_script_errors.rs Adds error constant for CLAIM note input validation
crates/miden-lib/src/agglayer/mod.rs Adds CLAIM script, agglayer_faucet component, and note creation helper
crates/miden-lib/asm/agglayer/note_scripts/CLAIM.masm CLAIM note script implementation
crates/miden-lib/asm/agglayer/account_components/local_exit_tree.masm Adds stubbed verify_claim_proof procedure
crates/miden-lib/asm/agglayer/account_components/bridge_in.masm Bridge-in component for claim proof validation
crates/miden-lib/asm/agglayer/account_components/agglayer_faucet.masm AggLayer faucet component with FPI validation and asset distribution

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@mmagician
Copy link
Collaborator

  1. If note inputs is ~600 Felt values, how to pass these values to the agglayer_faucet via .call? Do I need to push the Felt values to the AdviceMap in the CLAIM note, and then in the agglayer_bridge during the FPI call, read these values?

Did you mean "how to pass these values to the AggLayerBridge? (I assume so)
We don't actually need to perform an FPI with all these values:

(most of) These values will be used for merkle path verification. So let's say we have the 64 Words from the note inputs, and we fetched the relevant GER from the AggLayerBridge contract via FPI call (or gotten it some other way, I can't remember whether it's maybe part of the note inputs, too? doesn't matter).

So, inside the AggLayerFaucet's validate_claim_via_fpi we need to do something like:

const PATH_PTR = 0

proc validate_claim_via_fpi
    # get the index of the leaf
    # => [index]

    # TODO fpi call to the bridge to fetch the relevant GER / validate that this GER exists
    # => [[GER_top], [GER_bottom], index]

    push.PATH_PTR
    # => [path_ptr, [GER_top], [GER_bottom], index]
    # load the 64 words representing the double-merkle path from note inputs into memory at address `path_ptr`

    exec.verify_path_against_ger
end

#! Loads the merkle path of size 64 double words for a leaf at `index`, starting at `path_ptr`, and verifies it against the GER
#! Inputs:  [path_ptr, [GER_top], [GER_bottom], index]
#! Outputs: [is_ok]
proc verify_path_against_ger
  1. Does this initial iteration of the CLAIM note need to stub out adding these ~600 Felt values as NoteInputs?

I would either:

  • a) Make sure to add all note inputs and their format
  • b) Or not add any

But since figuring out (a) is going to take some time probably, let's just skip it for now - since anyway they would be mocked inside create_claim_note helper.

@partylikeits1983 partylikeits1983 marked this pull request as ready for review December 16, 2025 16:21
@bobbinth
Copy link
Contributor

bobbinth commented Jan 6, 2026

I did a decent amount of refactoring so that the CLAIM note inputs closely match the Agglayer claimAsset() inputs:

function claimAsset(
bytes32[_DEPOSIT_CONTRACT_TREE_DEPTH] calldata smtProofLocalExitRoot,    // 256 felts
bytes32[_DEPOSIT_CONTRACT_TREE_DEPTH] calldata smtProofRollupExitRoot,   // 256 felts  
uint256 globalIndex,                                                     // 8 felts
bytes32 mainnetExitRoot,                                                 // 8 felts
bytes32 rollupExitRoot,                                                  // 8 felts
uint32 originNetwork,                                                    // 1 felt
address originTokenAddress,                                              // 5 felts
uint32 destinationNetwork,                                               // 1 felt
address destinationAddress,                                              // 5 felts
uint256 amount,                                                          // 8 felts
bytes calldata metadata                                                  // 8 felts (hardcoded to 8 felts)
) external ifNotEmergencyState nonReentrant {...}

To represent all Agglayer claimAsset() inputs takes 564 Felts. However there are some additional CLAIM note inputs such as the agglayer_faucet_account_id & output_note_tag, bringing the total number of inputs for the CLAIM note to 567.

Thank you! A few questions/comments on this (some of which covered offline):

  1. What is globalIndex? I'm assuming it is the index of the leaf that we are trying to prove the existence of - but curious how exactly it is structured.
    a. Do we not also need the index of the GER in the L1InfoTree? Or is this somehow covered by the globalIndex.
  2. Is this for claiming asset for L1->L2 transfer, L2->L2 transfer, or both? My understanding that we'd need both smtProofLocalExitRoot and smtProofRollupExitRoot for L2->L2 transfers, and that for L1->L2 transfer we'd need something like smtProofMainnetExitRoot. But maybe I'm missing something? Also, cc @BrianSeong99 in case you know the answer.

Regarding proofs, I'm assuming the following:

  1. We can compute GER from mainnetExitRoot and rollupExitRoot - probably as something like GER = keccak256(rollupExitRoot || mainnetExitRoot).
  2. We can compute the bridge message hash from originNetwork, originTokenAddress, destinationNetwork, destinationAddress, amount, and metadata (also by hashing them).
  3. Once we have these, we can verify smtProofLocalExitRoot against the message hash, and get the local exit root (LET).
  4. Once we have the LET, we can verify smtProofRollupExitRoot against the LET, and get GER.
  5. Lastly, we can compare GER from step 4 with GER from step 1 to make sure the proof verifies.

Is this correct? And if so, this again seems like L2->L2 flow and L1->L2 flow would look slightly different.

@partylikeits1983
Copy link
Contributor Author

As per @bobbinth 's suggestion I updated the CLAIM note to insert its note inputs into three AdviceMap entries:

#! PROOF_DATA_KEY => [
#!   smtProofLocalExitRoot[256],      // SMT proof for local exit root (256 felts, bytes32[_DEPOSIT_CONTRACT_TREE_DEPTH])
#!   smtProofRollupExitRoot[256],     // SMT proof for rollup exit root (256 felts, bytes32[_DEPOSIT_CONTRACT_TREE_DEPTH])
#!   globalIndex[8],                  // Global index (8 felts, uint256 as 8 u32 felts)
#!   mainnetExitRoot[8],              // Mainnet exit root hash (8 felts, bytes32 as 8 u32 felts)
#!   rollupExitRoot[8],               // Rollup exit root hash (8 felts, bytes32 as 8 u32 felts)
#! ]
#!
#! LEAF_DATA_KEY => [
#!   originNetwork[1],                // Origin network identifier (1 felt, uint32)
#!   originTokenAddress[5],           // Origin token address (5 felts, address as 5 u32 felts)
#!   destinationNetwork[1],           // Destination network identifier (1 felt, uint32)
#!   destinationAddress[5],           // Destination address (5 felts, address as 5 u32 felts)
#!   amount[8],                       // Amount of tokens (8 felts, uint256 as 8 u32 felts)
#!   metadata[8],                     // ABI encoded metadata (8 felts, fixed size)
#!   EMPTY_WORD                       // padding
#! ]
#!
#! OUTPUT_NOTE_DATA_KEY => [
#!   output_p2id_serial_num[4],       // P2ID note serial number (4 felts, Word)
#!   agglayer_faucet_account_id[2],   // Agglayer faucet account ID (2 felts, prefix and suffix)
#!   output_note_tag[1],              // P2ID output note tag
#! ]

Then in the CLAIM note we call agg_faucet::claim where the claim procedure expects the stack to be:

# => [PROOF_DATA_KEY, LEAF_DATA_KEY, OUTPUT_NOTE_DATA_KEY]

Later on as a follow up, the OUTPUT_NOTE_DATA_KEY AdviceMap entry will be completely removed as we will compute the serial number of the output note as hash(GER, leafIndex) (or something along those lines), and will compute the output_note_tag from the destinationAddress.

When implementing the claim asset flow, I closely followed the logic of the Agglayer claimAsset Solidity function, and this test: https://github.com/agglayer/agglayer-contracts/blob/e468f9b0967334403069aa650d9f1164b1731ebb/test/contractsv2/BridgeL2GasTokensSovereignChains.test.ts#L858

@partylikeits1983
Copy link
Contributor Author

To answer your questions @bobbinth

Question 1

What is globalIndex?

It’s a packed “locator” for the leaf being claimed (not the GER). Layout is:

  • mainnetFlag (1 bit): 1 = leaf came from L1 (Mainnet Exit Tree), 0 = leaf came from an L2 rollup
  • rollupIndex (32 bits): which rollup (only used when mainnetFlag=0)
  • localRootIndex (32 bits): leaf index / depositCount in the origin chain’s Local Exit Tree
    Top 191 bits are ignored (not required to be zero), so indexers must decode it exactly like the contract does.

Source: https://github.com/agglayer/agglayer-contracts/blob/e468f9b0967334403069aa650d9f1164b1731ebb/docs/contracts/src/contracts/v2/previousVersions/pessimistic/PolygonZkEVMBridgeV2Pessimistic.sol/contract.PolygonZkEVMBridgeV2Pessimistic.md?plain=1#L344

Question 2

Is this for L1→L2, L2→L2, or both?

Both. claimAsset supports claims for any transfer type; the only difference is where the deposit leaf lives, which is selected by globalIndex.mainnetFlag:

  • If mainnetFlag = 1 (origin = L1): you prove the leaf directly against mainnetExitRoot using smtProofLocalExitRoot + localRootIndex. rollupIndex / smtProofRollupExitRoot aren’t used.
  • If mainnetFlag = 0 (origin = an L2 rollup): you prove the leaf against the rollup’s Local Exit Root (via smtProofLocalExitRoot + localRootIndex), and prove that Local Exit Root is included in rollupExitRoot at rollupIndex (via smtProofRollupExitRoot).

Source:
https://github.com/agglayer/agglayer-contracts/blob/e468f9b0967334403069aa650d9f1164b1731ebb/docs/contracts/src/contracts/v2/previousVersions/pessimistic/PolygonZkEVMBridgeV2Pessimistic.sol/contract.PolygonZkEVMBridgeV2Pessimistic.md?plain=1#L344

https://docs.agglayer.dev/agglayer/core-concepts/unified-bridge/data-structures/

Copy link
Collaborator

@mmagician mmagician left a comment

Choose a reason for hiding this comment

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

Not a full review yet, but the new structure looks very clean! Great work 💯

partylikeits1983 and others added 4 commits January 7, 2026 10:29
Co-authored-by: Marti <marti@miden.team>
Co-authored-by: Marti <marti@miden.team>
Co-authored-by: Marti <marti@miden.team>
Co-authored-by: Marti <marti@miden.team>
Copy link
Contributor

@bobbinth bobbinth left a comment

Choose a reason for hiding this comment

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

Looks good! Thank you! I left a bunch of comments inline - but many of them are for follow-ups. There are a few that could be good to address in this PR though. After this, we are good to merge.

Comment on lines 70 to 71
// AGGLAYER ACCOUNT COMPONENTS
// ================================================================================================
Copy link
Contributor

Choose a reason for hiding this comment

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

Not for this PR, but I still think we should have much fewer components. Specifically, I think we need just two account components: AggLayerBridge and AggLayerFaucet.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think the components are a good way to organize the code into smaller chunks. Especially parts like bridging-in vs. bridging-out, but also local exit tree, make a lot of sense to me.
Are there any issues with having multiple components that get merged into a single account code? (I think this was generally the idea of introducing AccountComponents)

Copy link
Contributor

Choose a reason for hiding this comment

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

I think the components are a good way to organize the code into smaller chunks.

I think we already get this by breaking the code up into multiple modules - i.e.., bridging out code is one module, Kecck MMR in another. Introducing a layer of components on top of this is an overkill IMO.

Are there any issues with having multiple components that get merged into a single account code? (I think this was generally the idea of introducing AccountComponents)

No specific issues aside from making the code more complicated, IMO. The general idea around components is that these are relatively self contained and we can add multiple self-contained components to a given account. In this case, we get a set of inter-dependent components which all need to be added to the account together to make it work correctly. In my mind, this means that all of these should be a single component.

Basically, in my mind, we should be able to deploy a component in an account regardless of other components being there. There, of course, will be exceptions to this, but most usually, that should be the case.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ok, makes sense, thank you for the explanation 👍🏼

///
/// # Panics
/// Panics if the input is not exactly 32 bytes
pub fn bytes32_to_felts(bytes32: &[u8; 32]) -> Vec<Felt> {
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think we need this function. Instead, we should be able to use bytes_to_packed_u32_elements() from miden_core::utils module.

Copy link
Contributor Author

@partylikeits1983 partylikeits1983 Jan 8, 2026

Choose a reason for hiding this comment

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

Afaik, miden_core::utils::packed_u32_felts_to_bytes is little endian, and for Ethereum/Agglayer compatibility we need to be big endian

Copy link
Contributor

@bobbinth bobbinth Jan 8, 2026

Choose a reason for hiding this comment

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

I thought Ethereum/AggLayer was little-endian as well. At least I remember we did the amount conversions in little-endian because of this (e.g., here). What Ethereum/AggLayer data is in big-endian form?

Copy link
Contributor Author

@partylikeits1983 partylikeits1983 Jan 8, 2026

Choose a reason for hiding this comment

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

Ah you’re right, I got confused here. Ethereum/AggLayer bytes32 is just 32 raw bytes (Solidity prints them in order).

So I agree we probably don’t need bytes32_to_felts; miden_core::utils::bytes_to_packed_u32_elements() should work.


To understand how this all works concretely I wrote an example that hashes a value in Solidity and in the Miden VM.

Rust test:

fn u32_words_to_solidity_bytes32_hex(words: &[u64]) -> String {
    assert_eq!(words.len(), 8, "expected 8 u32 words = 32 bytes");
    let mut out = [0u8; 32];

    for (i, &w) in words.iter().enumerate() {
        let le = (w as u32).to_le_bytes();
        out[i * 4..i * 4 + 4].copy_from_slice(&le);
    }

    let mut s = String::from("0x");
    for b in out {
        s.push_str(&format!("{:02x}", b));
    }
    s
}

#[test]
fn test_keccak_hash_bytes_test() {
    let mut input_u8: Vec<u8> = vec![0u8; 24];
    input_u8.extend_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);

    let len_bytes = input_u8.len();
    let preimage = KeccakPreimage::new(input_u8.to_vec());

    let input_felts = preimage.as_felts();
    let memory_stores_source = masm_store_felts(&input_felts, INPUT_MEMORY_ADDR);

    let source = format!(
        r#"
            use miden::core::sys
            use miden::core::crypto::hashes::keccak256

            begin
                # Store packed u32 values in memory
                {memory_stores_source}

                # Push wrapper inputs
                push.{len_bytes}.{INPUT_MEMORY_ADDR}
                # => [ptr, len_bytes]

                exec.keccak256::hash_bytes
                # => [DIGEST_U32[8]]

                exec.sys::truncate_stack
            end
            "#,
    );

    let test = build_debug_test!(source, &[]);
    let digest: Vec<u64> = preimage.digest().as_ref().iter().map(Felt::as_int).collect();
    test.expect_stack(&digest);
    let digest_words: Vec<u64> = preimage.digest().as_ref().iter().map(Felt::as_int).collect();
    let solidity_hex = u32_words_to_solidity_bytes32_hex(&digest_words);
    
    println!("solidity-style digest: {solidity_hex}");
    println!("digest: {:?}", digest);
}

Output:

solidity-style digest: 0x514148c05833ddeea0364a81300262a25ad938a9a4d00acb82fbc1f0a17b1bcf
digest: [3225960785, 4007474008, 2169124512, 2724332080, 2839075162, 3406483620, 4039244674, 3474684833]

Solidity example:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract KeccakU256 {
    function hashUint256(uint256 x) external pure returns (bytes32) {
        return keccak256(abi.encodePacked(x));
    }

    // Fixed test vector matching your Rust bytes:
    // 0x00..00 0102030405060708  (32 bytes total)
    function hashFixed() external pure returns (bytes32) {
        uint256 x = 0x0102030405060708;
        return keccak256(abi.encodePacked(x));
    }
}

Output when calling hashFixed():

bytes32: 0x514148c05833ddeea0364a81300262a25ad938a9a4d00acb82fbc1f0a17b1bcf

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Only thing is bytes_to_packed_u32_elements is not available in miden-vm v0.20.1?

///
/// # Errors
/// Returns an error if the vector doesn't contain exactly 8 felts
pub fn felts_to_bytes32(felts: &[Felt]) -> Result<[u8; 32], String> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Similar comment as above, but about packed_u32_elements_to_bytes() function from miden_core::utils.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I could not find the packed_u32_elements_to_bytes function in miden_core::utils

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah yes - both packed_u32_elements_to_bytes and bytes_to_packed_u32_elements are currently on next in miden-vm. Could we make an issue to replace these with the miden-vm versions once we move to the next VM?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added to the list of follow ups: #2237

@bobbinth bobbinth merged commit 2a09b42 into agglayer Jan 8, 2026
16 checks passed
@bobbinth bobbinth deleted the ajl-claim-bridge-in branch January 8, 2026 22:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agglayer PRs or issues related to AggLayer bridging integration no changelog This PR does not require an entry in the `CHANGELOG.md` file

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants