diff --git a/Cargo.lock b/Cargo.lock index 0ab6b7af3..1ec7360d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1753,11 +1753,13 @@ version = "0.13.0" dependencies = [ "anyhow", "assert_matches", + "hex", "itertools 0.14.0", "miden-agglayer", "miden-assembly", "miden-block-prover", "miden-core-lib", + "miden-crypto", "miden-processor", "miden-protocol", "miden-standards", diff --git a/crates/miden-agglayer/asm/bridge/bridge_out.masm b/crates/miden-agglayer/asm/bridge/bridge_out.masm index 43c6885b3..023677e51 100644 --- a/crates/miden-agglayer/asm/bridge/bridge_out.masm +++ b/crates/miden-agglayer/asm/bridge/bridge_out.masm @@ -12,7 +12,6 @@ const LOCAL_EXIT_TREE_SLOT=word("miden::agglayer::let") const BURN_NOTE_ROOT = [15615638671708113717, 1774623749760042586, 2028263167268363492, 12931944505143778072] const PUBLIC_NOTE=1 -const AUX=0 const NUM_BURN_NOTE_INPUTS=0 const BURN_ASSET_MEM_PTR=24 diff --git a/crates/miden-agglayer/asm/bridge/crypto_utils.masm b/crates/miden-agglayer/asm/bridge/crypto_utils.masm index f0020785d..7796c1f94 100644 --- a/crates/miden-agglayer/asm/bridge/crypto_utils.masm +++ b/crates/miden-agglayer/asm/bridge/crypto_utils.masm @@ -80,4 +80,3 @@ pub proc verify_claim_proof dropw dropw dropw dropw push.1 end - diff --git a/crates/miden-agglayer/asm/bridge/eth_address.masm b/crates/miden-agglayer/asm/bridge/eth_address.masm new file mode 100644 index 000000000..57a8e9f29 --- /dev/null +++ b/crates/miden-agglayer/asm/bridge/eth_address.masm @@ -0,0 +1,88 @@ +use miden::core::crypto::hashes::keccak256 +use miden::core::word + +# CONSTANTS +# ================================================================================================= + +const U32_MAX=4294967295 +const TWO_POW_32=4294967296 + +const ERR_NOT_U32="address limb is not u32" +const ERR_ADDR4_NONZERO="most-significant 4 bytes (addr4) must be zero" +const ERR_FELT_OUT_OF_FIELD="combined u64 doesn't fit in field" + + +# ETHEREUM ADDRESS PROCEDURES +# ================================================================================================= + +#! Builds a single felt from two u32 limbs (little-endian limb order). +#! Conceptually, this is packing a 64-bit word (lo + (hi << 32)) into a field element. +#! This proc additionally verifies that the packed value did *not* reduce mod p by round-tripping +#! through u32split and comparing the limbs. +#! +#! Inputs: [lo, hi] +#! Outputs: [felt] +proc build_felt + # --- validate u32 limbs --- + u32assert2.err=ERR_NOT_U32 + # => [lo, hi] + + # keep copies for the overflow check + dup.1 dup.1 + # => [lo, hi, lo, hi] + + # felt = (hi * 2^32) + lo + swap + push.TWO_POW_32 mul + add + # => [felt, lo, hi] + + # ensure no reduction mod p happened: + # split felt back into (hi, lo) and compare to inputs + dup u32split + # => [hi2, lo2, felt, lo, hi] + + movup.4 assert_eq.err=ERR_FELT_OUT_OF_FIELD + # => [lo2, felt, lo] + + movup.2 assert_eq.err=ERR_FELT_OUT_OF_FIELD + # => [felt] +end + +#! Converts an Ethereum address format (address[5] type) back into an AccountId [prefix, suffix] type. +#! +#! The Ethereum address format is represented as 5 u32 limbs (20 bytes total) in *little-endian limb order*: +#! addr0 = bytes[16..19] (least-significant 4 bytes) +#! addr1 = bytes[12..15] +#! addr2 = bytes[ 8..11] +#! addr3 = bytes[ 4.. 7] +#! addr4 = bytes[ 0.. 3] (most-significant 4 bytes) +#! +#! The most-significant 4 bytes must be zero for a valid AccountId conversion (addr4 == 0). +#! The remaining 16 bytes are treated as two 8-byte words (conceptual u64 values): +#! prefix = (addr3 << 32) | addr2 # bytes[4..11] +#! suffix = (addr1 << 32) | addr0 # bytes[12..19] +#! +#! These 8-byte words are represented as field elements by packing two u32 limbs into a felt. +#! The packing is done via build_felt, which validates limbs are u32 and checks the packed value +#! did not reduce mod p (i.e. the word fits in the field). +#! +#! Inputs: [addr0, addr1, addr2, addr3, addr4] +#! Outputs: [prefix, suffix] +#! +#! Invocation: exec +pub proc to_account_id + # addr4 must be 0 (most-significant limb) + movup.4 + eq.0 assert.err=ERR_ADDR4_NONZERO + # => [addr0, addr1, addr2, addr3] + + exec.build_felt + # => [suffix, addr2, addr3] + + movdn.2 + # => [addr2, addr3, suffix] + + exec.build_felt + # => [prefix, suffix] +end diff --git a/crates/miden-agglayer/src/errors/agglayer.rs b/crates/miden-agglayer/src/errors/agglayer.rs index 8f7057232..efa9275de 100644 --- a/crates/miden-agglayer/src/errors/agglayer.rs +++ b/crates/miden-agglayer/src/errors/agglayer.rs @@ -9,6 +9,9 @@ use miden_protocol::errors::MasmError; // AGGLAYER ERRORS // ================================================================================================ +/// Error Message: "most-significant 4 bytes (addr4) must be zero" +pub const ERR_ADDR4_NONZERO: MasmError = MasmError::from_static_str("most-significant 4 bytes (addr4) must be zero"); + /// Error Message: "B2AGG script requires exactly 1 note asset" pub const ERR_B2AGG_WRONG_NUMBER_OF_ASSETS: MasmError = MasmError::from_static_str("B2AGG script requires exactly 1 note asset"); /// 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 /// Error Message: "CLAIM's target account address and transaction address do not match" pub const ERR_CLAIM_TARGET_ACCT_MISMATCH: MasmError = MasmError::from_static_str("CLAIM's target account address and transaction address do not match"); +/// Error Message: "combined u64 doesn't fit in field" +pub const ERR_FELT_OUT_OF_FIELD: MasmError = MasmError::from_static_str("combined u64 doesn't fit in field"); + /// Error Message: "invalid claim proof" pub const ERR_INVALID_CLAIM_PROOF: MasmError = MasmError::from_static_str("invalid claim proof"); +/// Error Message: "address limb is not u32" +pub const ERR_NOT_U32: MasmError = MasmError::from_static_str("address limb is not u32"); + /// Error Message: "maximum scaling factor is 18" pub const ERR_SCALE_AMOUNT_EXCEEDED_LIMIT: MasmError = MasmError::from_static_str("maximum scaling factor is 18"); diff --git a/crates/miden-agglayer/src/eth_address.rs b/crates/miden-agglayer/src/eth_address.rs new file mode 100644 index 000000000..f2a94ed6d --- /dev/null +++ b/crates/miden-agglayer/src/eth_address.rs @@ -0,0 +1,243 @@ +use alloc::format; +use alloc::string::{String, ToString}; +use core::fmt; + +use miden_core::FieldElement; +use miden_protocol::Felt; +use miden_protocol::account::AccountId; +use miden_protocol::utils::{HexParseError, bytes_to_hex_string, hex_to_bytes}; + +// ================================================================================================ +// ETHEREUM ADDRESS +// ================================================================================================ + +/// Represents an Ethereum address format (20 bytes). +/// +/// # Representations used in this module +/// +/// - Raw bytes: `[u8; 20]` in the conventional Ethereum big-endian byte order (`bytes[0]` is the +/// most-significant byte). +/// - MASM "address\[5\]" limbs: 5 x u32 limbs in *little-endian limb order*: +/// - addr0 = bytes[16..19] (least-significant 4 bytes) +/// - addr1 = bytes[12..15] +/// - addr2 = bytes[ 8..11] +/// - addr3 = bytes[ 4.. 7] +/// - addr4 = bytes[ 0.. 3] (most-significant 4 bytes) +/// - Embedded AccountId format: `0x00000000 || prefix(8) || suffix(8)`, where: +/// - prefix = (addr3 << 32) | addr2 = bytes[4..11] as a big-endian u64 +/// - suffix = (addr1 << 32) | addr0 = bytes[12..19] as a big-endian u64 +/// +/// Note: prefix/suffix are *conceptual* 64-bit words; when converting to [`Felt`], we must ensure +/// `Felt::new(u64)` does not reduce mod p (checked explicitly in `to_account_id`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct EthAddressFormat([u8; 20]); + +impl EthAddressFormat { + // EXTERNAL API - For integrators (Gateway, claim managers, etc.) + // -------------------------------------------------------------------------------------------- + + /// Creates a new [`EthAddressFormat`] from a 20-byte array. + pub const fn new(bytes: [u8; 20]) -> Self { + Self(bytes) + } + + /// Creates an [`EthAddressFormat`] from a hex string (with or without "0x" prefix). + /// + /// # Errors + /// + /// Returns an error if the hex string is invalid or the hex part is not exactly 40 characters. + pub fn from_hex(hex_str: &str) -> Result { + let hex_part = hex_str.strip_prefix("0x").unwrap_or(hex_str); + if hex_part.len() != 40 { + return Err(AddressConversionError::InvalidHexLength); + } + + let prefixed_hex = if hex_str.starts_with("0x") { + hex_str.to_string() + } else { + format!("0x{}", hex_str) + }; + + let bytes: [u8; 20] = hex_to_bytes(&prefixed_hex)?; + Ok(Self(bytes)) + } + + /// Creates an [`EthAddressFormat`] from an [`AccountId`]. + /// + /// **External API**: This function is used by integrators (Gateway, claim managers) to convert + /// Miden AccountIds into the Ethereum address format for constructing CLAIM notes or + /// interfacing when calling the Agglayer Bridge function bridgeAsset(). + /// + /// This conversion is infallible: an [`AccountId`] is two felts, and `as_int()` yields `u64` + /// words which we embed as `0x00000000 || prefix(8) || suffix(8)` (big-endian words). + /// + /// # Example + /// ```ignore + /// let destination_address = EthAddressFormat::from_account_id(destination_account_id).into_bytes(); + /// // then construct the CLAIM note with destination_address... + /// ``` + pub fn from_account_id(account_id: AccountId) -> Self { + let felts: [Felt; 2] = account_id.into(); + + let mut out = [0u8; 20]; + out[4..12].copy_from_slice(&felts[0].as_int().to_be_bytes()); + out[12..20].copy_from_slice(&felts[1].as_int().to_be_bytes()); + + Self(out) + } + + /// Returns the raw 20-byte array. + pub const fn as_bytes(&self) -> &[u8; 20] { + &self.0 + } + + /// Converts the address into a 20-byte array. + pub const fn into_bytes(self) -> [u8; 20] { + self.0 + } + + /// Converts the Ethereum address to a hex string (lowercase, 0x-prefixed). + pub fn to_hex(&self) -> String { + bytes_to_hex_string(self.0) + } + + // INTERNAL API - For CLAIM note processing + // -------------------------------------------------------------------------------------------- + + /// Converts the Ethereum address format into an array of 5 [`Felt`] values for MASM processing. + /// + /// **Internal API**: This function is used internally during CLAIM note processing to convert + /// the address format into the MASM `address[5]` representation expected by the + /// `to_account_id` procedure. + /// + /// The returned order matches the MASM `address\[5\]` convention (*little-endian limb order*): + /// - addr0 = bytes[16..19] (least-significant 4 bytes) + /// - addr1 = bytes[12..15] + /// - addr2 = bytes[ 8..11] + /// - addr3 = bytes[ 4.. 7] + /// - addr4 = bytes[ 0.. 3] (most-significant 4 bytes) + /// + /// Each limb is interpreted as a big-endian `u32` and stored in a [`Felt`]. + pub fn to_elements(&self) -> [Felt; 5] { + let mut result = [Felt::ZERO; 5]; + + // i=0 -> bytes[16..20], i=4 -> bytes[0..4] + for (felt, chunk) in result.iter_mut().zip(self.0.chunks(4).skip(1).rev()) { + let value = u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + // u32 values always fit in Felt, so this conversion is safe + *felt = Felt::try_from(value as u64).expect("u32 value should always fit in Felt"); + } + + result + } + + /// Converts the Ethereum address format back to an [`AccountId`]. + /// + /// **Internal API**: This function is used internally during CLAIM note processing to extract + /// the original AccountId from the Ethereum address format. It mirrors the functionality of + /// the MASM `to_account_id` procedure. + /// + /// # Errors + /// + /// Returns an error if: + /// - the first 4 bytes are not zero (not in the embedded AccountId format), + /// - packing the 8-byte prefix/suffix into [`Felt`] would reduce mod p, + /// - or the resulting felts do not form a valid [`AccountId`]. + pub fn to_account_id(&self) -> Result { + let (prefix, suffix) = Self::bytes20_to_prefix_suffix(self.0)?; + + // Use `Felt::try_from(u64)` to avoid potential truncating conversion + let prefix_felt = + Felt::try_from(prefix).map_err(|_| AddressConversionError::FeltOutOfField)?; + + let suffix_felt = + Felt::try_from(suffix).map_err(|_| AddressConversionError::FeltOutOfField)?; + + AccountId::try_from([prefix_felt, suffix_felt]) + .map_err(|_| AddressConversionError::InvalidAccountId) + } + + // HELPER FUNCTIONS + // -------------------------------------------------------------------------------------------- + + /// Convert `[u8; 20]` -> `(prefix, suffix)` by extracting the last 16 bytes. + /// Requires the first 4 bytes be zero. + /// Returns prefix and suffix values that match the MASM little-endian limb implementation: + /// - prefix = bytes[4..12] as big-endian u64 = (addr3 << 32) | addr2 + /// - suffix = bytes[12..20] as big-endian u64 = (addr1 << 32) | addr0 + fn bytes20_to_prefix_suffix(bytes: [u8; 20]) -> Result<(u64, u64), AddressConversionError> { + if bytes[0..4] != [0, 0, 0, 0] { + return Err(AddressConversionError::NonZeroBytePrefix); + } + + let prefix = u64::from_be_bytes(bytes[4..12].try_into().unwrap()); + let suffix = u64::from_be_bytes(bytes[12..20].try_into().unwrap()); + + Ok((prefix, suffix)) + } +} + +impl fmt::Display for EthAddressFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.to_hex()) + } +} + +impl From<[u8; 20]> for EthAddressFormat { + fn from(bytes: [u8; 20]) -> Self { + Self(bytes) + } +} + +impl From for EthAddressFormat { + fn from(account_id: AccountId) -> Self { + EthAddressFormat::from_account_id(account_id) + } +} + +impl From for [u8; 20] { + fn from(addr: EthAddressFormat) -> Self { + addr.0 + } +} + +// ================================================================================================ +// ADDRESS CONVERSION ERROR +// ================================================================================================ + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AddressConversionError { + NonZeroWordPadding, + NonZeroBytePrefix, + InvalidHexLength, + InvalidHexChar(char), + HexParseError, + FeltOutOfField, + InvalidAccountId, +} + +impl fmt::Display for AddressConversionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AddressConversionError::NonZeroWordPadding => write!(f, "non-zero word padding"), + AddressConversionError::NonZeroBytePrefix => { + write!(f, "address has non-zero 4-byte prefix") + }, + AddressConversionError::InvalidHexLength => { + write!(f, "invalid hex length (expected 40 hex chars)") + }, + AddressConversionError::InvalidHexChar(c) => write!(f, "invalid hex character: {}", c), + AddressConversionError::HexParseError => write!(f, "hex parse error"), + AddressConversionError::FeltOutOfField => { + write!(f, "packed 64-bit word does not fit in the field") + }, + AddressConversionError::InvalidAccountId => write!(f, "invalid AccountId"), + } + } +} + +impl From for AddressConversionError { + fn from(_err: HexParseError) -> Self { + AddressConversionError::HexParseError + } +} diff --git a/crates/miden-agglayer/src/lib.rs b/crates/miden-agglayer/src/lib.rs index 3753f6e66..72d50ab7b 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -36,9 +36,11 @@ use miden_standards::account::faucets::NetworkFungibleFaucet; use miden_utils_sync::LazyLock; pub mod errors; +pub mod eth_address; pub mod utils; -use utils::{bytes32_to_felts, ethereum_address_to_felts}; +pub use eth_address::EthAddressFormat; +use utils::bytes32_to_felts; // AGGLAYER NOTE SCRIPTS // ================================================================================================ @@ -422,7 +424,8 @@ pub fn create_claim_note(params: ClaimNoteParams<'_, R>) -> Result(params: ClaimNoteParams<'_, R>) -> Result [u8; 20] { - let mut address = [0u8; 20]; - - // Convert prefix and suffix to u64, then to bytes (big-endian) - let prefix_u64 = account_id.prefix().as_felt().as_int(); - let suffix_u64 = account_id.suffix().as_int(); - - let prefix_bytes = prefix_u64.to_be_bytes(); - let suffix_bytes = suffix_u64.to_be_bytes(); - - // Copy last 4 bytes from prefix (u32 portion) - address[0..4].copy_from_slice(&prefix_bytes[4..8]); - // Copy last 4 bytes from suffix (u32 portion) - address[4..8].copy_from_slice(&suffix_bytes[4..8]); - // Remaining 12 bytes stay as zeros - - address -} - -/// Converts a bytes20 EVM address into an AccountId. -/// -/// The conversion extracts the first 8 bytes as prefix (u32) and suffix (u32), -/// with the remaining bytes ignored (treated as zeros). -pub fn evm_address_to_account_id(address: &[u8; 20]) -> AccountId { - // Extract first 8 bytes and convert to u32 values - let mut prefix_bytes = [0u8; 8]; - let mut suffix_bytes = [0u8; 8]; - - // Copy first 4 bytes to prefix (pad with zeros) - prefix_bytes[4..8].copy_from_slice(&address[0..4]); - // Copy next 4 bytes to suffix (pad with zeros) - suffix_bytes[4..8].copy_from_slice(&address[4..8]); - - let prefix = u64::from_be_bytes(prefix_bytes); - let suffix = u64::from_be_bytes(suffix_bytes); - - // Create AccountId from the extracted values - // Note: This creates a basic account ID - in practice you might want to use - // a specific account type and storage mode - AccountId::new_unchecked([Felt::new(prefix), Felt::new(suffix)]) -} - -/// Converts an AccountId to a bytes20 address that will produce [prefix, suffix, 0, 0, 0] -/// when processed by ethereum_address_to_felts(). -/// -/// This function creates a 20-byte address where: -/// - Bytes 0-3: AccountId prefix as u32 (big-endian) -/// - Bytes 4-7: AccountId suffix as u32 (big-endian) -/// - Bytes 8-19: Zero padding -/// -/// When ethereum_address_to_felts() processes this address, it will extract: -/// - u32\[0\] from bytes 0-3: prefix -/// - u32\[1\] from bytes 4-7: suffix -/// - u32\[2\] from bytes 8-11: zeros -/// - u32\[3\] from bytes 12-15: zeros -/// - u32\[4\] from bytes 16-19: zeros -/// -/// This results in [prefix, suffix, 0, 0, 0] as desired. -pub fn account_id_to_destination_bytes(account_id: AccountId) -> [u8; 20] { - let mut address = [0u8; 20]; - - // Get prefix and suffix as u64 values, then convert to u32 - let prefix = account_id.prefix().as_felt().as_int() as u32; - let suffix = account_id.suffix().as_int() as u32; - - // Pack prefix into first 4 bytes (u32, big-endian) - address[0..4].copy_from_slice(&prefix.to_be_bytes()); - - // Pack suffix into next 4 bytes (u32, big-endian) - address[4..8].copy_from_slice(&suffix.to_be_bytes()); - - // Remaining 12 bytes stay as zeros - // This will result in [prefix, suffix, 0, 0, 0] when processed by ethereum_address_to_felts() - - address -} - // TESTING HELPERS // ================================================================================================ @@ -658,7 +577,8 @@ pub fn claim_note_test_inputs( let destination_network = Felt::new(2); // Convert AccountId to destination address bytes - let destination_address = account_id_to_destination_bytes(destination_account_id); + let destination_address = + EthAddressFormat::from_account_id(destination_account_id).into_bytes(); // Convert amount Felt to u256 array for agglayer let amount_u256 = [ diff --git a/crates/miden-agglayer/src/utils.rs b/crates/miden-agglayer/src/utils.rs index 3b8cd683c..88850de58 100644 --- a/crates/miden-agglayer/src/utils.rs +++ b/crates/miden-agglayer/src/utils.rs @@ -1,259 +1,28 @@ -use alloc::string::String; -use alloc::vec::Vec; - +use miden_core::FieldElement; use miden_protocol::Felt; -/// Convert 8 Felt values (u32 limbs in little-endian order) to U256 bytes in little-endian format. +// UTILITY FUNCTIONS +// ================================================================================================ + +/// Converts a bytes32 value (32 bytes) into an array of 8 Felt values. /// -/// The input limbs are expected to be in little-endian order (least significant limb first). -/// This function converts them to a 32-byte array in little-endian format for compatibility -/// with Ethereum/EVM which expects U256 values as 32 bytes in little-endian format. -/// This ensures compatibility when bridging assets between Miden and Ethereum-based chains. +/// Note: These utility functions will eventually be replaced with similar functions from miden-vm. +pub fn bytes32_to_felts(bytes32: &[u8; 32]) -> [Felt; 8] { + let mut result = [Felt::ZERO; 8]; + for (i, chunk) in bytes32.chunks(4).enumerate() { + let value = u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + result[i] = Felt::from(value); + } + result +} + +/// Convert 8 Felt values (u32 limbs in little-endian order) to U256 bytes in little-endian format. pub fn felts_to_u256_bytes(limbs: [Felt; 8]) -> [u8; 32] { let mut bytes = [0u8; 32]; - for (i, limb) in limbs.iter().enumerate() { let u32_value = limb.as_int() as u32; let limb_bytes = u32_value.to_le_bytes(); bytes[i * 4..(i + 1) * 4].copy_from_slice(&limb_bytes); } - bytes } - -/// Converts an Ethereum address (20 bytes) into a vector of 5 Felt values. -/// -/// An Ethereum address is 20 bytes, which we split into 5 u32 values (4 bytes each). -/// The address bytes are distributed as follows: -/// - u32\[0\]: bytes 0-3 -/// - u32\[1\]: bytes 4-7 -/// - u32\[2\]: bytes 8-11 -/// - u32\[3\]: bytes 12-15 -/// - u32\[4\]: bytes 16-19 -/// -/// # Arguments -/// * `address` - A 20-byte Ethereum address -/// -/// # Returns -/// A vector of 5 Felt values representing the address -/// -/// # Panics -/// Panics if the address is not exactly 20 bytes -pub fn ethereum_address_to_felts(address: &[u8; 20]) -> Vec { - let mut result = Vec::with_capacity(5); - - // Convert each 4-byte chunk to a u32 (big-endian) - for i in 0..5 { - let start = i * 4; - let chunk = &address[start..start + 4]; - let value = u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); - result.push(Felt::new(value as u64)); - } - - result -} - -/// Converts a vector of 5 Felt values back into a 20-byte Ethereum address. -/// -/// # Arguments -/// * `felts` - A vector of 5 Felt values representing an Ethereum address -/// -/// # Returns -/// A Result containing a 20-byte Ethereum address array, or an error string -/// -/// # Errors -/// Returns an error if the vector doesn't contain exactly 5 felts -pub fn felts_to_ethereum_address(felts: &[Felt]) -> Result<[u8; 20], String> { - if felts.len() != 5 { - return Err(alloc::format!("Expected 5 felts for Ethereum address, got {}", felts.len())); - } - - let mut address = [0u8; 20]; - - for (i, felt) in felts.iter().enumerate() { - let value = felt.as_int() as u32; - let bytes = value.to_be_bytes(); - let start = i * 4; - address[start..start + 4].copy_from_slice(&bytes); - } - - Ok(address) -} - -/// Converts an Ethereum address string (with or without "0x" prefix) into a vector of 5 Felt -/// values. -/// -/// # Arguments -/// * `address_str` - A hex string representing an Ethereum address (40 hex chars, optionally -/// prefixed with "0x") -/// -/// # Returns -/// A Result containing a vector of 5 Felt values representing the address, or an error string -/// -/// # Errors -/// Returns an error if: -/// - The string is not a valid hex string -/// - The string does not represent exactly 20 bytes (40 hex characters) -pub fn ethereum_address_string_to_felts(address_str: &str) -> Result, String> { - // Remove "0x" prefix if present - let hex_str = address_str.strip_prefix("0x").unwrap_or(address_str); - - // Check length (should be 40 hex characters for 20 bytes) - if hex_str.len() != 40 { - return Err(alloc::format!( - "Invalid Ethereum address length: expected 40 hex characters, got {}", - hex_str.len() - )); - } - - // Parse hex string to bytes - let mut address_bytes = [0u8; 20]; - for (i, chunk) in hex_str.as_bytes().chunks(2).enumerate() { - let hex_byte = core::str::from_utf8(chunk) - .map_err(|_| String::from("Invalid UTF-8 in address string"))?; - address_bytes[i] = u8::from_str_radix(hex_byte, 16) - .map_err(|_| alloc::format!("Invalid hex character in address: {}", hex_byte))?; - } - - Ok(ethereum_address_to_felts(&address_bytes)) -} - -/// Converts a bytes32 value (32 bytes) into a vector of 8 Felt values. -/// -/// A bytes32 value is 32 bytes, which we split into 8 u32 values (4 bytes each). -/// The bytes are distributed as follows: -/// - u32\[0\]: bytes 0-3 -/// - u32\[1\]: bytes 4-7 -/// - u32\[2\]: bytes 8-11 -/// - u32\[3\]: bytes 12-15 -/// - u32\[4\]: bytes 16-19 -/// - u32\[5\]: bytes 20-23 -/// - u32\[6\]: bytes 24-27 -/// - u32\[7\]: bytes 28-31 -/// -/// # Arguments -/// * `bytes32` - A 32-byte value (e.g., hash, root) -/// -/// # Returns -/// A vector of 8 Felt values representing the bytes32 value -/// -/// # Panics -/// Panics if the input is not exactly 32 bytes -pub fn bytes32_to_felts(bytes32: &[u8; 32]) -> Vec { - let mut result = Vec::with_capacity(8); - - // Convert each 4-byte chunk to a u32 (big-endian) - for i in 0..8 { - let start = i * 4; - let chunk = &bytes32[start..start + 4]; - let value = u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); - result.push(Felt::new(value as u64)); - } - - result -} - -/// Converts a vector of 8 Felt values back into a 32-byte array. -/// -/// # Arguments -/// * `felts` - A vector of 8 Felt values representing a bytes32 value -/// -/// # Returns -/// A Result containing a 32-byte array, or an error string -/// -/// # Errors -/// Returns an error if the vector doesn't contain exactly 8 felts -pub fn felts_to_bytes32(felts: &[Felt]) -> Result<[u8; 32], String> { - if felts.len() != 8 { - return Err(alloc::format!("Expected 8 felts for bytes32, got {}", felts.len())); - } - - let mut bytes32 = [0u8; 32]; - - for (i, felt) in felts.iter().enumerate() { - let value = felt.as_int() as u32; - let bytes = value.to_be_bytes(); - let start = i * 4; - bytes32[start..start + 4].copy_from_slice(&bytes); - } - - Ok(bytes32) -} - -#[cfg(test)] -mod tests { - use alloc::vec; - - use super::*; - - #[test] - fn test_ethereum_address_round_trip() { - // Test that converting from string to felts and back gives the same result - let original_address = "0x1234567890abcdef1122334455667788990011aa"; - - // Convert string to felts - let felts = ethereum_address_string_to_felts(original_address).unwrap(); - - // Convert felts back to bytes - let recovered_bytes = felts_to_ethereum_address(&felts).unwrap(); - - // Convert original string to bytes for comparison - let original_hex = original_address.strip_prefix("0x").unwrap(); - let mut expected_bytes = [0u8; 20]; - for (i, chunk) in original_hex.as_bytes().chunks(2).enumerate() { - let hex_byte = core::str::from_utf8(chunk).unwrap(); - expected_bytes[i] = u8::from_str_radix(hex_byte, 16).unwrap(); - } - - // Assert they match - assert_eq!(recovered_bytes, expected_bytes); - } - - #[test] - fn test_ethereum_address_to_felts_basic() { - let address: [u8; 20] = [ - 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, - 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, - ]; - - let result = ethereum_address_to_felts(&address); - assert_eq!(result.len(), 5); - assert_eq!(result[0], Felt::new(0x12345678)); - assert_eq!(result[1], Felt::new(0x9abcdef0)); - } - - #[test] - fn test_felts_to_ethereum_address_invalid_length() { - let felts = vec![Felt::new(1), Felt::new(2)]; // Only 2 felts - let result = felts_to_ethereum_address(&felts); - assert!(result.is_err()); - } - - #[test] - fn test_ethereum_address_string_invalid_length() { - let address_str = "0x123456"; // Too short - let result = ethereum_address_string_to_felts(address_str); - assert!(result.is_err()); - } - - #[test] - fn test_bytes32_to_felts_basic() { - let bytes32: [u8; 32] = [ - 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, - 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, - 0x55, 0x66, 0x77, 0x88, - ]; - - let result = bytes32_to_felts(&bytes32); - assert_eq!(result.len(), 8); - assert_eq!(result[0], Felt::new(0x12345678)); - assert_eq!(result[1], Felt::new(0x9abcdef0)); - } - - #[test] - fn test_felts_to_bytes32_invalid_length() { - let felts = vec![Felt::new(1), Felt::new(2)]; // Only 2 felts - let result = felts_to_bytes32(&felts); - assert!(result.is_err()); - } -} diff --git a/crates/miden-testing/Cargo.toml b/crates/miden-testing/Cargo.toml index bbcbb0699..af565ab38 100644 --- a/crates/miden-testing/Cargo.toml +++ b/crates/miden-testing/Cargo.toml @@ -39,6 +39,8 @@ winterfell = { version = "0.13" } [dev-dependencies] anyhow = { features = ["backtrace", "std"], workspace = true } assert_matches = { workspace = true } +hex = { version = "0.4" } +miden-crypto = { workspace = true } miden-protocol = { features = ["std"], workspace = true } primitive-types = { workspace = true } rstest = { workspace = true } diff --git a/crates/miden-testing/tests/agglayer/bridge_out.rs b/crates/miden-testing/tests/agglayer/bridge_out.rs index dea33e04d..ab832bb50 100644 --- a/crates/miden-testing/tests/agglayer/bridge_out.rs +++ b/crates/miden-testing/tests/agglayer/bridge_out.rs @@ -1,7 +1,6 @@ extern crate alloc; -use miden_agglayer::utils::ethereum_address_string_to_felts; -use miden_agglayer::{b2agg_script, bridge_out_component}; +use miden_agglayer::{EthAddressFormat, b2agg_script, bridge_out_component}; use miden_protocol::account::{ Account, AccountId, @@ -80,8 +79,9 @@ async fn test_bridge_out_consumes_b2agg_note() -> anyhow::Result<()> { // destination_address: 20 bytes (Ethereum address) split into 5 u32 values let destination_network = Felt::new(1); // Example network ID let destination_address = "0x1234567890abcdef1122334455667788990011aa"; - let address_felts = - ethereum_address_string_to_felts(destination_address).expect("Valid Ethereum address"); + let eth_address = + EthAddressFormat::from_hex(destination_address).expect("Valid Ethereum address"); + let address_felts = eth_address.to_elements().to_vec(); // Combine network ID and address felts into note inputs (6 felts total) let mut input_felts = vec![destination_network]; @@ -235,8 +235,9 @@ async fn test_b2agg_note_reclaim_scenario() -> anyhow::Result<()> { // Create note inputs with destination network and address let destination_network = Felt::new(1); let destination_address = "0x1234567890abcdef1122334455667788990011aa"; - let address_felts = - ethereum_address_string_to_felts(destination_address).expect("Valid Ethereum address"); + let eth_address = + EthAddressFormat::from_hex(destination_address).expect("Valid Ethereum address"); + let address_felts = eth_address.to_elements().to_vec(); // Combine network ID and address felts into note inputs (6 felts total) let mut input_felts = vec![destination_network]; diff --git a/crates/miden-testing/tests/agglayer/mod.rs b/crates/miden-testing/tests/agglayer/mod.rs index b238560b8..2a6d344c6 100644 --- a/crates/miden-testing/tests/agglayer/mod.rs +++ b/crates/miden-testing/tests/agglayer/mod.rs @@ -1,3 +1,4 @@ pub mod asset_conversion; mod bridge_in; mod bridge_out; +mod solidity_miden_address_conversion; diff --git a/crates/miden-testing/tests/agglayer/solidity_miden_address_conversion.rs b/crates/miden-testing/tests/agglayer/solidity_miden_address_conversion.rs new file mode 100644 index 000000000..2083a9dd3 --- /dev/null +++ b/crates/miden-testing/tests/agglayer/solidity_miden_address_conversion.rs @@ -0,0 +1,174 @@ +extern crate alloc; + +use alloc::sync::Arc; + +use miden_agglayer::{EthAddressFormat, agglayer_library}; +use miden_assembly::{Assembler, DefaultSourceManager}; +use miden_core_lib::CoreLibrary; +use miden_processor::fast::{ExecutionOutput, FastProcessor}; +use miden_processor::{AdviceInputs, DefaultHost, ExecutionError, Program, StackInputs}; +use miden_protocol::Felt; +use miden_protocol::account::AccountId; +use miden_protocol::address::NetworkId; +use miden_protocol::testing::account_id::{ + ACCOUNT_ID_PRIVATE_SENDER, + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, + AccountIdBuilder, +}; +use miden_protocol::transaction::TransactionKernel; + +/// Execute a program with default host +async fn execute_program_with_default_host( + program: Program, +) -> Result { + let mut host = DefaultHost::default(); + + let test_lib = TransactionKernel::library(); + host.load_library(test_lib.mast_forest()).unwrap(); + + let std_lib = CoreLibrary::default(); + host.load_library(std_lib.mast_forest()).unwrap(); + + for (event_name, handler) in std_lib.handlers() { + host.register_handler(event_name, handler)?; + } + + let asset_conversion_lib = agglayer_library(); + host.load_library(asset_conversion_lib.mast_forest()).unwrap(); + + let stack_inputs = StackInputs::new(vec![]).unwrap(); + let advice_inputs = AdviceInputs::default(); + + let processor = FastProcessor::new_debug(stack_inputs.as_slice(), advice_inputs); + processor.execute(&program, &mut host).await +} + +#[test] +fn test_account_id_to_ethereum_roundtrip() { + let original_account_id = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).unwrap(); + let eth_address = EthAddressFormat::from_account_id(original_account_id); + let recovered_account_id = eth_address.to_account_id().unwrap(); + assert_eq!(original_account_id, recovered_account_id); +} + +#[test] +fn test_bech32_to_ethereum_roundtrip() { + let test_addresses = [ + "mtst1azcw08rget79fqp8ymr0zqkv5v5lj466", + "mtst1arxmxavamh7lqyp79mexktt4vgxv40mp", + "mtst1ar2phe0pa0ln75plsczxr8ryws4s8zyp", + ]; + + let evm_addresses = [ + "0x00000000b0e79c68cafc54802726c6f102cca300", + "0x00000000cdb3759dddfdf0103e2ef26b2d756200", + "0x00000000d41be5e1ebff3f503f8604619c647400", + ]; + + for (bech32, expected_evm) in test_addresses.iter().zip(evm_addresses.iter()) { + let (network_id, account_id) = AccountId::from_bech32(bech32).unwrap(); + + let eth = EthAddressFormat::from_account_id(account_id); + let recovered = eth.to_account_id().unwrap(); + let recovered_bech32 = recovered.to_bech32(network_id); + + assert_eq!(&account_id, &recovered); + assert_eq!(*expected_evm, eth.to_string()); + assert_eq!(*bech32, recovered_bech32); + } +} + +#[test] +fn test_random_bech32_to_ethereum_roundtrip() { + let mut rng = rand::rng(); + let network_id = NetworkId::Testnet; + + for _ in 0..3 { + let account_id = AccountIdBuilder::new().build_with_rng(&mut rng); + let bech32_address = account_id.to_bech32(network_id.clone()); + let eth_address = EthAddressFormat::from_account_id(account_id); + let recovered_account_id = eth_address.to_account_id().unwrap(); + let recovered_bech32 = recovered_account_id.to_bech32(network_id.clone()); + + assert_eq!(account_id, recovered_account_id); + assert_eq!(bech32_address, recovered_bech32); + } +} + +#[tokio::test] +async fn test_ethereum_address_to_account_id_in_masm() -> anyhow::Result<()> { + let test_account_ids = [ + AccountId::try_from(ACCOUNT_ID_PRIVATE_SENDER)?, + AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?, + AccountIdBuilder::new().build_with_rng(&mut rand::rng()), + AccountIdBuilder::new().build_with_rng(&mut rand::rng()), + AccountIdBuilder::new().build_with_rng(&mut rand::rng()), + ]; + + for (idx, original_account_id) in test_account_ids.iter().enumerate() { + let eth_address = EthAddressFormat::from_account_id(*original_account_id); + + let address_felts = eth_address.to_elements().to_vec(); + let le: Vec = address_felts + .iter() + .map(|f| { + let val = f.as_int(); + assert!(val <= u32::MAX as u64, "felt value {} exceeds u32::MAX", val); + val as u32 + }) + .collect(); + + assert_eq!(le[4], 0, "test {}: expected msw limb (le[4]) to be zero", idx); + + let addr0 = le[0]; + let addr1 = le[1]; + let addr2 = le[2]; + let addr3 = le[3]; + let addr4 = le[4]; + + let account_id_felts: [Felt; 2] = (*original_account_id).into(); + let expected_prefix = account_id_felts[0].as_int(); + let expected_suffix = account_id_felts[1].as_int(); + + let script_code = format!( + r#" + use miden::core::sys + use miden::agglayer::eth_address + + begin + push.{}.{}.{}.{}.{} + exec.eth_address::to_account_id + exec.sys::truncate_stack + end + "#, + addr4, addr3, addr2, addr1, addr0 + ); + + let program = Assembler::new(Arc::new(DefaultSourceManager::default())) + .with_dynamic_library(CoreLibrary::default()) + .unwrap() + .with_dynamic_library(agglayer_library()) + .unwrap() + .assemble_program(&script_code) + .unwrap(); + + let exec_output = execute_program_with_default_host(program).await?; + + let actual_prefix = exec_output.stack[0].as_int(); + let actual_suffix = exec_output.stack[1].as_int(); + + assert_eq!(actual_prefix, expected_prefix, "test {}: prefix mismatch", idx); + assert_eq!(actual_suffix, expected_suffix, "test {}: suffix mismatch", idx); + + let reconstructed_account_id = + AccountId::try_from([Felt::new(actual_prefix), Felt::new(actual_suffix)])?; + + assert_eq!( + reconstructed_account_id, *original_account_id, + "test {}: accountId roundtrip failed", + idx + ); + } + + Ok(()) +}