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