Skip to content

feat(AggLayer claim e2e #2): byte packing from felts#2401

Merged
mmagician merged 18 commits intoagglayer-newfrom
mmagician-get-leaf-test-vec
Feb 14, 2026
Merged

feat(AggLayer claim e2e #2): byte packing from felts#2401
mmagician merged 18 commits intoagglayer-newfrom
mmagician-get-leaf-test-vec

Conversation

@mmagician
Copy link
Collaborator

@mmagician mmagician commented Feb 5, 2026

Tests for getLeafValue using real onchain data

  • we can compute DepositContractV2.getLeafValue on real onchain data, and use this expected value in our tests
  • to make the tests pass, a bunch of related changes needed to be accomplished, all listed below

Byte packing from Felts

It turns out that while the test that directly loaded packed bytes for get_leaf_value was ok, we were missing the step of packing these values in MASM before hashing.
What we had:

  • leafType (1 felt, "1 byte"): [a, b, c, d]
  • originNetwork (1 felt, "4 bytes"): [e, f, g, h]
  • originAddress (5 felts, "20 bytes"): [i, j, k, l, ...]

And for getLeafValue to match, we need to prepare the felts as such:

  • felt0: [d, e, f, g]
  • fetl1: [h, i, j, k]
  • etc.

So effectively we needed to shift the byte-representation of felts left by 3 bytes.

This also means I had to move padding to the end of LEAF_DATA.

MetadataHash

I also ended up creating a MetadataHash struct as a helper

EthAddressFormat using BE as the underlying representation

It's really useful if we can do the following:

  1. Read deposit data from a JSON like:
{
  "amount": "0x0000000000000000000000000000000000000000000000001bc16d674ec80000",
  "destination_address": "0xD9b20Fe633b609B01081aD0428e81f8Dd604F5C5",
  "destination_network": 7,
  "leaf_type": 0,
  "leaf_value": "0xb67e42971034605367b7e92d1ad1d4648c3ffe0bea9b08115cd9aa2e616b2f88",
  "metadata_hash": "0x6c7a91a5fb41dee8f0bc1c86b5587334583186f14acfa253e2f7c2833d1d6fdf",
  "origin_network": 0,
  "origin_token_address": "0xD9343a049D5DBd89CD19DC6BcA8c48fB3a0a42a7"
}

directly into a test vector.

  1. Convert this test vector directly to LeafData like so:
let leaf_data = LeafData {
    origin_network: vector.origin_network,
    origin_token_address: EthAddressFormat::from_hex(&vector.origin_token_address)
        .expect("valid origin token address hex"),
    destination_network: vector.destination_network,
    destination_address: EthAddressFormat::from_hex(&vector.destination_address)
        .expect("valid destination address hex"),
    amount: EthAmount::from_hex(&vector.amount).expect("valid amount hex"),
    metadata_hash: MetadataHash::new(
        hex_to_bytes(&vector.metadata_hash).expect("valid metadata hash hex"),
    ),
}
  1. Use leaf_data.to_elements() to prepare the note inputs & everything else works out of the box when we pack the bytes and hash them.

To achieve this, EthAddressFormat's to_elements() method must return felts with LE-byte order.
But to build the account ID from these bytes, we need to swap bytes LE->BE within each byte, because that's what Miden's AccountId expects.
For this, I added swap_u32_bytes, which is not very neat, but it does allow the user to basically take a Miden AccountId in hex, append some zeros, and it corresponds 1-1 to what will appear as part of claimAsset tx data on Ethereum.
For example:

  • on Ethereum: 0x00000000b0E79c68cafC54802726C6F102Cca300
  • Miden AccountId (in hex): 0xb0E79c68cafC54802726C6F102Cca3` (the trailing 8 bits of our ID are zero, and are cut off from the hex repr)

@mmagician mmagician force-pushed the mmagician-get-leaf-test-vec branch 7 times, most recently from 8094a1f to 0b1c93f Compare February 5, 2026 13:53
@mmagician mmagician added the agglayer PRs or issues related to AggLayer bridging integration label Feb 5, 2026
Base automatically changed from mmagician-fix-compute-ger to agglayer-new February 6, 2026 13:35
@mmagician mmagician changed the title feat(AggLayer): byte packing from felts feat(AggLayer claim e2e #2): byte packing from felts Feb 8, 2026
@mmagician mmagician force-pushed the mmagician-get-leaf-test-vec branch from 18439d5 to be2e67a Compare February 8, 2026 21:13
@mmagician mmagician changed the base branch from agglayer-new to mmagician-padding-end-of-claim February 8, 2026 21:14
@mmagician mmagician marked this pull request as ready for review February 8, 2026 21:32
@mmagician mmagician added the no changelog This PR does not require an entry in the `CHANGELOG.md` file label Feb 8, 2026
@mmagician mmagician added the pr-from-maintainers PRs that come from internal contributors or integration partners. They should be given priority label Feb 9, 2026
Copy link
Contributor

@partylikeits1983 partylikeits1983 left a comment

Choose a reason for hiding this comment

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

Looks great! I think I've wrapped my head around the new byte packing format you've added.

The minor concern I have is the removal of the methods from EthAmount type, as I liked those helper methods a lot and used them extensively in my u256 to Felt scale verification PR :)

Other than that this PR LGTM! If you do think it is essential to remove those methods from EthAmount type, lets merge this, will refactor the scaling PR to be more inline with the style of byte ordering in this PR.

Comment on lines 221 to 234
#! The Keccak precompile expects u32 values packed in little-endian byte order.
#! For each packed element, we drop the leading 3 bytes and rebuild the u32 so that
#! bytes [b0, b1, b2, b3] map to u32::from_le_bytes([b0, b1, b2, b3]).
#! With little-endian input limbs, the first byte comes from the MSB of `curr` and
#! the next three bytes come from the LSBs of `next`:
#! packed = ((curr >> 24) & 0xFF)
#! | (next & 0xFF) << 8
#! | ((next >> 8) & 0xFF) << 16
#! | ((next >> 16) & 0xFF) << 24
#!
#! Inputs: []
#! Outputs: []
#!
#! Invocation: exec
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: I think it would make a bit more sense if you outlined the state of memory before and after calling this procedure. Although it looks like the procedure does not take any inputs / outputs on the stack, it does but via memory. It would also make it more concrete what the before / after of memory looks like when calling this proc.

I think it is fine to use global memory like this because this procedure will only ever be called in the context of a network transaction, so it won't ever be used in a "user" context.

I think on all of my PRs where I had procedures like this which took global memory as an input, I think @bobbinth & @PhilippGackstatter thought it would be better to pass the data via the AdviceStack, that being said, I think its justified to use global memory like this because we are preparing the data before calling the Keccak precompile which requires the data to be in global memory.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yes good call, will add a paragraph on the layout

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

actually I cherry-picked chore: refactor into get_and compute_ leaf_value, so that this procedure actually becomes parametrized, and no longer implicitly relies on hardcoded memory poiners.

#! addr2 = bytes[ 8..11]
#! addr3 = bytes[ 4.. 7]
#! addr4 = bytes[ 0.. 3] (most-significant 4 bytes)
#! The Ethereum address format is represented as 5 u32 limbs (20 bytes total) in *big-endian limb order*
Copy link
Contributor

Choose a reason for hiding this comment

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

are we going to have to change to make this little endian anyways with the new VM version? 🥲

remappings = ["@agglayer/=lib/agglayer-contracts/contracts/"]
remappings = [
"@agglayer/=lib/agglayer-contracts/contracts/",
"@openzeppelin/contracts-upgradeable4/=lib/openzeppelin-contracts-upgradeable/contracts/",
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we need the OZ lib & submodule?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

see my reply here

Comment on lines 17 to +27
impl EthAmount {
/// Creates a new [`EthAmount`] from an array of 8 u32 values.
///
/// The values are stored in little-endian order where `values[0]` contains
/// the least significant 32 bits.
pub const fn new(values: [u32; 8]) -> Self {
Self(values)
}

/// Creates an [`EthAmount`] from a single u64 value.
///
/// This is useful for smaller amounts that fit in a u64. The value is
/// stored in the first two u32 slots with the remaining slots set to zero.
pub const fn from_u64(value: u64) -> Self {
let low = value as u32;
let high = (value >> 32) as u32;
Self([low, high, 0, 0, 0, 0, 0, 0])
}

/// Creates an [`EthAmount`] from a single u32 value.
///
/// This is useful for smaller amounts that fit in a u32. The value is
/// stored in the first u32 slot with the remaining slots set to zero.
pub const fn from_u32(value: u32) -> Self {
Self([value, 0, 0, 0, 0, 0, 0, 0])
}

/// Returns the raw array of 8 u32 values.
pub const fn as_array(&self) -> &[u32; 8] {
&self.0
}

/// Converts the amount into an array of 8 u32 values.
pub const fn into_array(self) -> [u32; 8] {
self.0
}

/// Returns true if the amount is zero.
pub fn is_zero(&self) -> bool {
self.0.iter().all(|&x| x == 0)
}

/// Attempts to convert the amount to a u64.
///
/// # Errors
/// Returns [`EthAmountError::Overflow`] if the amount doesn't fit in a u64
/// (i.e., if any of the upper 6 u32 values are non-zero).
pub fn try_to_u64(&self) -> Result<u64, EthAmountError> {
if self.0[2..].iter().any(|&x| x != 0) {
Err(EthAmountError::Overflow)
} else {
Ok((self.0[1] as u64) << 32 | self.0[0] as u64)
}
}

/// Attempts to convert the amount to a u32.
///
/// # Errors
/// Returns [`EthAmountError::Overflow`] if the amount doesn't fit in a u32
/// (i.e., if any of the upper 7 u32 values are non-zero).
pub fn try_to_u32(&self) -> Result<u32, EthAmountError> {
if self.0[1..].iter().any(|&x| x != 0) {
Err(EthAmountError::Overflow)
} else {
Ok(self.0[0])
}
/// Creates an [`EthAmount`] from a 32-byte array.
pub fn new(bytes: [u8; 32]) -> Self {
Self(bytes)
}

/// Converts the amount to a vector of field elements for note storage.
///
/// Each u32 value in the amount array is converted to a [`Felt`].
pub fn to_elements(&self) -> [Felt; 8] {
let mut result = [Felt::ZERO; 8];
for (i, &value) in self.0.iter().enumerate() {
result[i] = Felt::from(value);
}
result
pub fn to_elements(&self) -> Vec<Felt> {
bytes_to_packed_u32_felts(&self.0)
Copy link
Contributor

Choose a reason for hiding this comment

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

Its a bit unfortunate that so many methods from the EthAmount type are being removed, these methods are extremely useful in #2331

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think it's best to keep the public API surface to a minimum.
In practice, the EthAmount should only be instantiated from bytes (and/or from decimals string?)

If really required, we could have other constructors under a cfg(test), but I think we should try to use the intended API and even for tests construct EthAmounts from decimal strings.

let mut bytes = [0u8; 32];
for (i, limb) in limbs.iter().enumerate() {
/// Converts Felt u32 limbs to bytes using little-endian byte order.
/// TODO remove once we move to v0.21.0 which has `packed_u32_elements_to_bytes`
Copy link
Contributor

Choose a reason for hiding this comment

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

With VM version v0.21.0 won't we need to change the stack ordering from BE to LE as well?

Base automatically changed from mmagician-padding-end-of-claim to agglayer-new February 14, 2026 16:56
@mmagician mmagician force-pushed the mmagician-get-leaf-test-vec branch from 97abbe1 to b658ab6 Compare February 14, 2026 17:18
@mmagician mmagician force-pushed the mmagician-get-leaf-test-vec branch from b658ab6 to ea8d6d7 Compare February 14, 2026 17:21
@mmagician
Copy link
Collaborator Author

I rebased onto agglayer-new (git rebase --onto agglayer-new mmagician-padding-end-of-claim) hence the force push.

@mmagician mmagician merged commit 046b02b into agglayer-new Feb 14, 2026
15 checks passed
@mmagician mmagician deleted the mmagician-get-leaf-test-vec branch February 14, 2026 17:37
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 pr-from-maintainers PRs that come from internal contributors or integration partners. They should be given priority

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants