Skip to content

Commit 2cbfeb1

Browse files
partylikeits1983igamigommagicianbobbinth
authored
Solidity address <> Miden AccountId helper functions (#2238)
* feat: add Solidity<>Miden address type conversion functions * fix: formatting * refactor: rm unnecessary indirection * refactor: use crypto util functions * refactor: implement suggestions & refactor * refactor: update logic & comments to little endian * Update crates/miden-agglayer/src/utils.rs Co-authored-by: igamigo <ignacio.amigo@lambdaclass.com> * refactor: improve EthAddress representation clarity and MASM alignment * refactor: simplify ethereum_address_to_account_id proc * fix: clippy * fix: lint doc check * refactor: use u32assert2 * refactor: simplify from_account_id() & u32 check * revert: undo drop addr4 in ethereum_address_to_account_id * Update crates/miden-agglayer/src/eth_address.rs Co-authored-by: Marti <marti@miden.team> * refactor: rename to EthAddressFormat * refactor: rearrange EthAddressFormat * refactor: rename file to eth_address_format * fix: update script roots * refactor: update test & refactor * refactor: refactor .to_elements() method * refactor: rename to to_account_id * fix: lint check * refactor: rename to eth_address.rs & undo Felt::try_from() changes --------- Co-authored-by: igamigo <ignacio.amigo@lambdaclass.com> Co-authored-by: Marti <marti@miden.team> Co-authored-by: Bobbin Threadbare <43513081+bobbinth@users.noreply.github.com>
1 parent bb60d5a commit 2cbfeb1

12 files changed

Lines changed: 549 additions & 342 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/miden-agglayer/asm/bridge/bridge_out.masm

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ const LOCAL_EXIT_TREE_SLOT=word("miden::agglayer::let")
1212

1313
const BURN_NOTE_ROOT = [15615638671708113717, 1774623749760042586, 2028263167268363492, 12931944505143778072]
1414
const PUBLIC_NOTE=1
15-
const AUX=0
1615
const NUM_BURN_NOTE_INPUTS=0
1716
const BURN_ASSET_MEM_PTR=24
1817

crates/miden-agglayer/asm/bridge/crypto_utils.masm

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,3 @@ pub proc verify_claim_proof
8080
dropw dropw dropw dropw
8181
push.1
8282
end
83-
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
use miden::core::crypto::hashes::keccak256
2+
use miden::core::word
3+
4+
# CONSTANTS
5+
# =================================================================================================
6+
7+
const U32_MAX=4294967295
8+
const TWO_POW_32=4294967296
9+
10+
const ERR_NOT_U32="address limb is not u32"
11+
const ERR_ADDR4_NONZERO="most-significant 4 bytes (addr4) must be zero"
12+
const ERR_FELT_OUT_OF_FIELD="combined u64 doesn't fit in field"
13+
14+
15+
# ETHEREUM ADDRESS PROCEDURES
16+
# =================================================================================================
17+
18+
#! Builds a single felt from two u32 limbs (little-endian limb order).
19+
#! Conceptually, this is packing a 64-bit word (lo + (hi << 32)) into a field element.
20+
#! This proc additionally verifies that the packed value did *not* reduce mod p by round-tripping
21+
#! through u32split and comparing the limbs.
22+
#!
23+
#! Inputs: [lo, hi]
24+
#! Outputs: [felt]
25+
proc build_felt
26+
# --- validate u32 limbs ---
27+
u32assert2.err=ERR_NOT_U32
28+
# => [lo, hi]
29+
30+
# keep copies for the overflow check
31+
dup.1 dup.1
32+
# => [lo, hi, lo, hi]
33+
34+
# felt = (hi * 2^32) + lo
35+
swap
36+
push.TWO_POW_32 mul
37+
add
38+
# => [felt, lo, hi]
39+
40+
# ensure no reduction mod p happened:
41+
# split felt back into (hi, lo) and compare to inputs
42+
dup u32split
43+
# => [hi2, lo2, felt, lo, hi]
44+
45+
movup.4 assert_eq.err=ERR_FELT_OUT_OF_FIELD
46+
# => [lo2, felt, lo]
47+
48+
movup.2 assert_eq.err=ERR_FELT_OUT_OF_FIELD
49+
# => [felt]
50+
end
51+
52+
#! Converts an Ethereum address format (address[5] type) back into an AccountId [prefix, suffix] type.
53+
#!
54+
#! The Ethereum address format is represented as 5 u32 limbs (20 bytes total) in *little-endian limb order*:
55+
#! addr0 = bytes[16..19] (least-significant 4 bytes)
56+
#! addr1 = bytes[12..15]
57+
#! addr2 = bytes[ 8..11]
58+
#! addr3 = bytes[ 4.. 7]
59+
#! addr4 = bytes[ 0.. 3] (most-significant 4 bytes)
60+
#!
61+
#! The most-significant 4 bytes must be zero for a valid AccountId conversion (addr4 == 0).
62+
#! The remaining 16 bytes are treated as two 8-byte words (conceptual u64 values):
63+
#! prefix = (addr3 << 32) | addr2 # bytes[4..11]
64+
#! suffix = (addr1 << 32) | addr0 # bytes[12..19]
65+
#!
66+
#! These 8-byte words are represented as field elements by packing two u32 limbs into a felt.
67+
#! The packing is done via build_felt, which validates limbs are u32 and checks the packed value
68+
#! did not reduce mod p (i.e. the word fits in the field).
69+
#!
70+
#! Inputs: [addr0, addr1, addr2, addr3, addr4]
71+
#! Outputs: [prefix, suffix]
72+
#!
73+
#! Invocation: exec
74+
pub proc to_account_id
75+
# addr4 must be 0 (most-significant limb)
76+
movup.4
77+
eq.0 assert.err=ERR_ADDR4_NONZERO
78+
# => [addr0, addr1, addr2, addr3]
79+
80+
exec.build_felt
81+
# => [suffix, addr2, addr3]
82+
83+
movdn.2
84+
# => [addr2, addr3, suffix]
85+
86+
exec.build_felt
87+
# => [prefix, suffix]
88+
end

crates/miden-agglayer/src/errors/agglayer.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ use miden_protocol::errors::MasmError;
99
// AGGLAYER ERRORS
1010
// ================================================================================================
1111

12+
/// Error Message: "most-significant 4 bytes (addr4) must be zero"
13+
pub const ERR_ADDR4_NONZERO: MasmError = MasmError::from_static_str("most-significant 4 bytes (addr4) must be zero");
14+
1215
/// Error Message: "B2AGG script requires exactly 1 note asset"
1316
pub const ERR_B2AGG_WRONG_NUMBER_OF_ASSETS: MasmError = MasmError::from_static_str("B2AGG script requires exactly 1 note asset");
1417
/// Error Message: "B2AGG script expects exactly 6 note inputs"
@@ -17,8 +20,14 @@ pub const ERR_B2AGG_WRONG_NUMBER_OF_INPUTS: MasmError = MasmError::from_static_s
1720
/// Error Message: "CLAIM's target account address and transaction address do not match"
1821
pub const ERR_CLAIM_TARGET_ACCT_MISMATCH: MasmError = MasmError::from_static_str("CLAIM's target account address and transaction address do not match");
1922

23+
/// Error Message: "combined u64 doesn't fit in field"
24+
pub const ERR_FELT_OUT_OF_FIELD: MasmError = MasmError::from_static_str("combined u64 doesn't fit in field");
25+
2026
/// Error Message: "invalid claim proof"
2127
pub const ERR_INVALID_CLAIM_PROOF: MasmError = MasmError::from_static_str("invalid claim proof");
2228

29+
/// Error Message: "address limb is not u32"
30+
pub const ERR_NOT_U32: MasmError = MasmError::from_static_str("address limb is not u32");
31+
2332
/// Error Message: "maximum scaling factor is 18"
2433
pub const ERR_SCALE_AMOUNT_EXCEEDED_LIMIT: MasmError = MasmError::from_static_str("maximum scaling factor is 18");
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
use alloc::format;
2+
use alloc::string::{String, ToString};
3+
use core::fmt;
4+
5+
use miden_core::FieldElement;
6+
use miden_protocol::Felt;
7+
use miden_protocol::account::AccountId;
8+
use miden_protocol::utils::{HexParseError, bytes_to_hex_string, hex_to_bytes};
9+
10+
// ================================================================================================
11+
// ETHEREUM ADDRESS
12+
// ================================================================================================
13+
14+
/// Represents an Ethereum address format (20 bytes).
15+
///
16+
/// # Representations used in this module
17+
///
18+
/// - Raw bytes: `[u8; 20]` in the conventional Ethereum big-endian byte order (`bytes[0]` is the
19+
/// most-significant byte).
20+
/// - MASM "address\[5\]" limbs: 5 x u32 limbs in *little-endian limb order*:
21+
/// - addr0 = bytes[16..19] (least-significant 4 bytes)
22+
/// - addr1 = bytes[12..15]
23+
/// - addr2 = bytes[ 8..11]
24+
/// - addr3 = bytes[ 4.. 7]
25+
/// - addr4 = bytes[ 0.. 3] (most-significant 4 bytes)
26+
/// - Embedded AccountId format: `0x00000000 || prefix(8) || suffix(8)`, where:
27+
/// - prefix = (addr3 << 32) | addr2 = bytes[4..11] as a big-endian u64
28+
/// - suffix = (addr1 << 32) | addr0 = bytes[12..19] as a big-endian u64
29+
///
30+
/// Note: prefix/suffix are *conceptual* 64-bit words; when converting to [`Felt`], we must ensure
31+
/// `Felt::new(u64)` does not reduce mod p (checked explicitly in `to_account_id`).
32+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33+
pub struct EthAddressFormat([u8; 20]);
34+
35+
impl EthAddressFormat {
36+
// EXTERNAL API - For integrators (Gateway, claim managers, etc.)
37+
// --------------------------------------------------------------------------------------------
38+
39+
/// Creates a new [`EthAddressFormat`] from a 20-byte array.
40+
pub const fn new(bytes: [u8; 20]) -> Self {
41+
Self(bytes)
42+
}
43+
44+
/// Creates an [`EthAddressFormat`] from a hex string (with or without "0x" prefix).
45+
///
46+
/// # Errors
47+
///
48+
/// Returns an error if the hex string is invalid or the hex part is not exactly 40 characters.
49+
pub fn from_hex(hex_str: &str) -> Result<Self, AddressConversionError> {
50+
let hex_part = hex_str.strip_prefix("0x").unwrap_or(hex_str);
51+
if hex_part.len() != 40 {
52+
return Err(AddressConversionError::InvalidHexLength);
53+
}
54+
55+
let prefixed_hex = if hex_str.starts_with("0x") {
56+
hex_str.to_string()
57+
} else {
58+
format!("0x{}", hex_str)
59+
};
60+
61+
let bytes: [u8; 20] = hex_to_bytes(&prefixed_hex)?;
62+
Ok(Self(bytes))
63+
}
64+
65+
/// Creates an [`EthAddressFormat`] from an [`AccountId`].
66+
///
67+
/// **External API**: This function is used by integrators (Gateway, claim managers) to convert
68+
/// Miden AccountIds into the Ethereum address format for constructing CLAIM notes or
69+
/// interfacing when calling the Agglayer Bridge function bridgeAsset().
70+
///
71+
/// This conversion is infallible: an [`AccountId`] is two felts, and `as_int()` yields `u64`
72+
/// words which we embed as `0x00000000 || prefix(8) || suffix(8)` (big-endian words).
73+
///
74+
/// # Example
75+
/// ```ignore
76+
/// let destination_address = EthAddressFormat::from_account_id(destination_account_id).into_bytes();
77+
/// // then construct the CLAIM note with destination_address...
78+
/// ```
79+
pub fn from_account_id(account_id: AccountId) -> Self {
80+
let felts: [Felt; 2] = account_id.into();
81+
82+
let mut out = [0u8; 20];
83+
out[4..12].copy_from_slice(&felts[0].as_int().to_be_bytes());
84+
out[12..20].copy_from_slice(&felts[1].as_int().to_be_bytes());
85+
86+
Self(out)
87+
}
88+
89+
/// Returns the raw 20-byte array.
90+
pub const fn as_bytes(&self) -> &[u8; 20] {
91+
&self.0
92+
}
93+
94+
/// Converts the address into a 20-byte array.
95+
pub const fn into_bytes(self) -> [u8; 20] {
96+
self.0
97+
}
98+
99+
/// Converts the Ethereum address to a hex string (lowercase, 0x-prefixed).
100+
pub fn to_hex(&self) -> String {
101+
bytes_to_hex_string(self.0)
102+
}
103+
104+
// INTERNAL API - For CLAIM note processing
105+
// --------------------------------------------------------------------------------------------
106+
107+
/// Converts the Ethereum address format into an array of 5 [`Felt`] values for MASM processing.
108+
///
109+
/// **Internal API**: This function is used internally during CLAIM note processing to convert
110+
/// the address format into the MASM `address[5]` representation expected by the
111+
/// `to_account_id` procedure.
112+
///
113+
/// The returned order matches the MASM `address\[5\]` convention (*little-endian limb order*):
114+
/// - addr0 = bytes[16..19] (least-significant 4 bytes)
115+
/// - addr1 = bytes[12..15]
116+
/// - addr2 = bytes[ 8..11]
117+
/// - addr3 = bytes[ 4.. 7]
118+
/// - addr4 = bytes[ 0.. 3] (most-significant 4 bytes)
119+
///
120+
/// Each limb is interpreted as a big-endian `u32` and stored in a [`Felt`].
121+
pub fn to_elements(&self) -> [Felt; 5] {
122+
let mut result = [Felt::ZERO; 5];
123+
124+
// i=0 -> bytes[16..20], i=4 -> bytes[0..4]
125+
for (felt, chunk) in result.iter_mut().zip(self.0.chunks(4).skip(1).rev()) {
126+
let value = u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
127+
// u32 values always fit in Felt, so this conversion is safe
128+
*felt = Felt::try_from(value as u64).expect("u32 value should always fit in Felt");
129+
}
130+
131+
result
132+
}
133+
134+
/// Converts the Ethereum address format back to an [`AccountId`].
135+
///
136+
/// **Internal API**: This function is used internally during CLAIM note processing to extract
137+
/// the original AccountId from the Ethereum address format. It mirrors the functionality of
138+
/// the MASM `to_account_id` procedure.
139+
///
140+
/// # Errors
141+
///
142+
/// Returns an error if:
143+
/// - the first 4 bytes are not zero (not in the embedded AccountId format),
144+
/// - packing the 8-byte prefix/suffix into [`Felt`] would reduce mod p,
145+
/// - or the resulting felts do not form a valid [`AccountId`].
146+
pub fn to_account_id(&self) -> Result<AccountId, AddressConversionError> {
147+
let (prefix, suffix) = Self::bytes20_to_prefix_suffix(self.0)?;
148+
149+
// Use `Felt::try_from(u64)` to avoid potential truncating conversion
150+
let prefix_felt =
151+
Felt::try_from(prefix).map_err(|_| AddressConversionError::FeltOutOfField)?;
152+
153+
let suffix_felt =
154+
Felt::try_from(suffix).map_err(|_| AddressConversionError::FeltOutOfField)?;
155+
156+
AccountId::try_from([prefix_felt, suffix_felt])
157+
.map_err(|_| AddressConversionError::InvalidAccountId)
158+
}
159+
160+
// HELPER FUNCTIONS
161+
// --------------------------------------------------------------------------------------------
162+
163+
/// Convert `[u8; 20]` -> `(prefix, suffix)` by extracting the last 16 bytes.
164+
/// Requires the first 4 bytes be zero.
165+
/// Returns prefix and suffix values that match the MASM little-endian limb implementation:
166+
/// - prefix = bytes[4..12] as big-endian u64 = (addr3 << 32) | addr2
167+
/// - suffix = bytes[12..20] as big-endian u64 = (addr1 << 32) | addr0
168+
fn bytes20_to_prefix_suffix(bytes: [u8; 20]) -> Result<(u64, u64), AddressConversionError> {
169+
if bytes[0..4] != [0, 0, 0, 0] {
170+
return Err(AddressConversionError::NonZeroBytePrefix);
171+
}
172+
173+
let prefix = u64::from_be_bytes(bytes[4..12].try_into().unwrap());
174+
let suffix = u64::from_be_bytes(bytes[12..20].try_into().unwrap());
175+
176+
Ok((prefix, suffix))
177+
}
178+
}
179+
180+
impl fmt::Display for EthAddressFormat {
181+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182+
write!(f, "{}", self.to_hex())
183+
}
184+
}
185+
186+
impl From<[u8; 20]> for EthAddressFormat {
187+
fn from(bytes: [u8; 20]) -> Self {
188+
Self(bytes)
189+
}
190+
}
191+
192+
impl From<AccountId> for EthAddressFormat {
193+
fn from(account_id: AccountId) -> Self {
194+
EthAddressFormat::from_account_id(account_id)
195+
}
196+
}
197+
198+
impl From<EthAddressFormat> for [u8; 20] {
199+
fn from(addr: EthAddressFormat) -> Self {
200+
addr.0
201+
}
202+
}
203+
204+
// ================================================================================================
205+
// ADDRESS CONVERSION ERROR
206+
// ================================================================================================
207+
208+
#[derive(Debug, Clone, PartialEq, Eq)]
209+
pub enum AddressConversionError {
210+
NonZeroWordPadding,
211+
NonZeroBytePrefix,
212+
InvalidHexLength,
213+
InvalidHexChar(char),
214+
HexParseError,
215+
FeltOutOfField,
216+
InvalidAccountId,
217+
}
218+
219+
impl fmt::Display for AddressConversionError {
220+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221+
match self {
222+
AddressConversionError::NonZeroWordPadding => write!(f, "non-zero word padding"),
223+
AddressConversionError::NonZeroBytePrefix => {
224+
write!(f, "address has non-zero 4-byte prefix")
225+
},
226+
AddressConversionError::InvalidHexLength => {
227+
write!(f, "invalid hex length (expected 40 hex chars)")
228+
},
229+
AddressConversionError::InvalidHexChar(c) => write!(f, "invalid hex character: {}", c),
230+
AddressConversionError::HexParseError => write!(f, "hex parse error"),
231+
AddressConversionError::FeltOutOfField => {
232+
write!(f, "packed 64-bit word does not fit in the field")
233+
},
234+
AddressConversionError::InvalidAccountId => write!(f, "invalid AccountId"),
235+
}
236+
}
237+
}
238+
239+
impl From<HexParseError> for AddressConversionError {
240+
fn from(_err: HexParseError) -> Self {
241+
AddressConversionError::HexParseError
242+
}
243+
}

0 commit comments

Comments
 (0)