address.rs (12718B)
1 use super::{PUBLIC_KEY_BYTES, decode_hex_array, hex_encode, validate_ed25519_public_key}; 2 use anyhow::{Context, Result, bail}; 3 4 const BECH32M_CONST: u32 = 0x2bc8_30a3; 5 const BECH32_CHARSET: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l"; 6 const MAX_BECH32_LENGTH: usize = 90; 7 8 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 9 pub enum AddressNetwork { 10 Mainnet, 11 Testnet, 12 } 13 14 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 15 #[repr(u8)] 16 pub enum AddressVersion { 17 Ed25519PublicKey = 0, 18 HybridKeyCommitment = 1, 19 } 20 21 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 22 pub struct VersionedAddress { 23 pub version: AddressVersion, 24 pub payload: [u8; PUBLIC_KEY_BYTES], 25 } 26 27 impl AddressVersion { 28 pub const fn wire_id(self) -> u8 { 29 self as u8 30 } 31 32 pub(crate) const fn from_wire_id(id: u8) -> Option<Self> { 33 match id { 34 0 => Some(Self::Ed25519PublicKey), 35 1 => Some(Self::HybridKeyCommitment), 36 _ => None, 37 } 38 } 39 } 40 41 impl AddressNetwork { 42 pub fn from_profile_id(profile_id: &str) -> Self { 43 if matches!(profile_id, "iuna-local-testnet-v1" | "iuna-local-e2e-5s-v1") { 44 Self::Testnet 45 } else { 46 Self::Mainnet 47 } 48 } 49 50 fn hrp(self) -> &'static str { 51 match self { 52 Self::Mainnet => "iuna", 53 Self::Testnet => "tiuna", 54 } 55 } 56 } 57 58 /// Encodes the internal canonical Ed25519 public-key representation for display. 59 pub fn encode_address(public_key_hex: &str, network: AddressNetwork) -> Result<String> { 60 let public_key = decode_hex_array::<PUBLIC_KEY_BYTES>(public_key_hex) 61 .context("address public key must be 32-byte hexadecimal")?; 62 validate_public_key(&public_key)?; 63 64 encode_versioned_address( 65 VersionedAddress { 66 version: AddressVersion::Ed25519PublicKey, 67 payload: public_key, 68 }, 69 network, 70 ) 71 } 72 73 pub fn encode_versioned_address( 74 address: VersionedAddress, 75 network: AddressNetwork, 76 ) -> Result<String> { 77 if address.version == AddressVersion::Ed25519PublicKey { 78 validate_public_key(&address.payload)?; 79 } 80 81 let mut data = vec![address.version.wire_id()]; 82 data.extend(convert_bits(&address.payload, 8, 5, true)?); 83 let checksum = create_checksum(network.hrp(), &data); 84 let mut encoded = String::with_capacity(network.hrp().len() + 1 + data.len() + 6); 85 encoded.push_str(network.hrp()); 86 encoded.push('1'); 87 for value in data.into_iter().chain(checksum) { 88 encoded.push(char::from(BECH32_CHARSET[usize::from(value)])); 89 } 90 Ok(encoded) 91 } 92 93 /// Decodes a user-facing Bech32m address to the internal canonical public-key hex. 94 /// 95 /// Legacy hexadecimal addresses are deliberately not accepted here. They remain 96 /// valid only inside existing consensus data and wallet files. 97 pub fn decode_address(address: &str, expected_network: AddressNetwork) -> Result<String> { 98 let decoded = decode_versioned_address(address, expected_network)?; 99 if decoded.version != AddressVersion::Ed25519PublicKey { 100 bail!("address version is recognized but not consensus-active"); 101 } 102 validate_public_key(&decoded.payload)?; 103 Ok(hex_encode(decoded.payload)) 104 } 105 106 /// Parses every reserved address version without making it consensus-active. 107 /// Callers must apply the activation rule before accepting the result. 108 pub fn decode_versioned_address( 109 address: &str, 110 expected_network: AddressNetwork, 111 ) -> Result<VersionedAddress> { 112 let address = address.trim(); 113 if address.is_empty() { 114 bail!("address is required"); 115 } 116 if address.len() > MAX_BECH32_LENGTH || !address.is_ascii() { 117 bail!("address is not valid Bech32m"); 118 } 119 let has_lower = address.bytes().any(|byte| byte.is_ascii_lowercase()); 120 let has_upper = address.bytes().any(|byte| byte.is_ascii_uppercase()); 121 if has_lower && has_upper { 122 bail!("Bech32m address must not mix uppercase and lowercase"); 123 } 124 let normalized = address.to_ascii_lowercase(); 125 let separator = normalized 126 .rfind('1') 127 .context("address is missing the Bech32m separator")?; 128 if separator == 0 || separator + 7 > normalized.len() { 129 bail!("address has an invalid Bech32m structure"); 130 } 131 let hrp = &normalized[..separator]; 132 if hrp != expected_network.hrp() { 133 bail!( 134 "address belongs to {} instead of {}", 135 network_label_for_hrp(hrp), 136 network_label(expected_network) 137 ); 138 } 139 let data = normalized[separator + 1..] 140 .bytes() 141 .map(decode_charset) 142 .collect::<Result<Vec<_>>>()?; 143 if !verify_checksum(hrp, &data) { 144 bail!("address checksum is invalid"); 145 } 146 let payload = &data[..data.len() - 6]; 147 let Some((&version, encoded_key)) = payload.split_first() else { 148 bail!("address payload is empty"); 149 }; 150 let version = AddressVersion::from_wire_id(version) 151 .with_context(|| format!("unsupported address version {version}"))?; 152 let public_key = convert_bits(encoded_key, 5, 8, false)?; 153 let public_key: [u8; PUBLIC_KEY_BYTES] = public_key.try_into().map_err(|bytes: Vec<u8>| { 154 anyhow::anyhow!("address public key has {} bytes", bytes.len()) 155 })?; 156 if version == AddressVersion::Ed25519PublicKey { 157 validate_public_key(&public_key)?; 158 } 159 Ok(VersionedAddress { 160 version, 161 payload: public_key, 162 }) 163 } 164 165 /// Converts a pre-mainnet hex address for one-time display/migration tooling. 166 pub fn migrate_legacy_address(address: &str, network: AddressNetwork) -> Result<String> { 167 encode_address(&address.to_ascii_lowercase(), network) 168 } 169 170 fn validate_public_key(public_key: &[u8; PUBLIC_KEY_BYTES]) -> Result<()> { 171 validate_ed25519_public_key(public_key) 172 } 173 174 fn network_label(network: AddressNetwork) -> &'static str { 175 match network { 176 AddressNetwork::Mainnet => "mainnet", 177 AddressNetwork::Testnet => "testnet", 178 } 179 } 180 181 fn network_label_for_hrp(hrp: &str) -> &'static str { 182 match hrp { 183 "iuna" => "mainnet", 184 "tiuna" => "testnet", 185 _ => "an unknown network", 186 } 187 } 188 189 fn decode_charset(byte: u8) -> Result<u8> { 190 BECH32_CHARSET 191 .iter() 192 .position(|candidate| *candidate == byte) 193 .map(|index| index as u8) 194 .context("address contains a character outside the Bech32 alphabet") 195 } 196 197 fn create_checksum(hrp: &str, data: &[u8]) -> [u8; 6] { 198 let mut values = hrp_expand(hrp); 199 values.extend_from_slice(data); 200 values.extend_from_slice(&[0; 6]); 201 let polymod = polymod(&values) ^ BECH32M_CONST; 202 std::array::from_fn(|index| ((polymod >> (5 * (5 - index))) & 31) as u8) 203 } 204 205 fn verify_checksum(hrp: &str, data: &[u8]) -> bool { 206 let mut values = hrp_expand(hrp); 207 values.extend_from_slice(data); 208 polymod(&values) == BECH32M_CONST 209 } 210 211 fn hrp_expand(hrp: &str) -> Vec<u8> { 212 let mut expanded = Vec::with_capacity(hrp.len() * 2 + 1); 213 expanded.extend(hrp.bytes().map(|byte| byte >> 5)); 214 expanded.push(0); 215 expanded.extend(hrp.bytes().map(|byte| byte & 31)); 216 expanded 217 } 218 219 fn polymod(values: &[u8]) -> u32 { 220 const GENERATORS: [u32; 5] = [ 221 0x3b6a_57b2, 222 0x2650_8e6d, 223 0x1ea1_19fa, 224 0x3d42_33dd, 225 0x2a14_62b3, 226 ]; 227 let mut checksum = 1_u32; 228 for value in values { 229 let top = checksum >> 25; 230 checksum = (checksum & 0x01ff_ffff) << 5 ^ u32::from(*value); 231 for (index, generator) in GENERATORS.iter().enumerate() { 232 if (top >> index) & 1 != 0 { 233 checksum ^= generator; 234 } 235 } 236 } 237 checksum 238 } 239 240 fn convert_bits(data: &[u8], from: u8, to: u8, pad: bool) -> Result<Vec<u8>> { 241 let mut accumulator = 0_u32; 242 let mut bit_count = 0_u8; 243 let max_value = (1_u32 << to) - 1; 244 let max_accumulator = (1_u32 << (from + to - 1)) - 1; 245 let mut converted = Vec::new(); 246 for value in data { 247 if u32::from(*value) >> from != 0 { 248 bail!("address payload contains an out-of-range value"); 249 } 250 accumulator = ((accumulator << from) | u32::from(*value)) & max_accumulator; 251 bit_count += from; 252 while bit_count >= to { 253 bit_count -= to; 254 converted.push(((accumulator >> bit_count) & max_value) as u8); 255 } 256 } 257 if pad { 258 if bit_count > 0 { 259 converted.push(((accumulator << (to - bit_count)) & max_value) as u8); 260 } 261 } else if bit_count >= from || ((accumulator << (to - bit_count)) & max_value) != 0 { 262 bail!("address payload has invalid padding"); 263 } 264 Ok(converted) 265 } 266 267 #[cfg(test)] 268 mod tests { 269 use super::{ 270 AddressNetwork, AddressVersion, VersionedAddress, decode_address, decode_charset, 271 decode_versioned_address, encode_address, encode_versioned_address, migrate_legacy_address, 272 verify_checksum, 273 }; 274 use crate::domain::Wallet; 275 276 fn wallet_key() -> String { 277 Wallet::from_seed("address-format-test") 278 .address() 279 .to_string() 280 } 281 282 #[test] 283 fn checksum_matches_bip350_bech32m_vectors() { 284 let valid = "lqfn3a" 285 .bytes() 286 .map(decode_charset) 287 .collect::<anyhow::Result<Vec<_>>>() 288 .unwrap(); 289 let old_bech32 = "g7sgd8" 290 .bytes() 291 .map(decode_charset) 292 .collect::<anyhow::Result<Vec<_>>>() 293 .unwrap(); 294 295 assert!(verify_checksum("a", &valid)); // BIP-350: A1LQFN3A 296 assert!(!verify_checksum("a", &old_bech32)); // Bech32, not Bech32m 297 } 298 299 #[test] 300 fn mainnet_and_testnet_addresses_roundtrip_to_the_same_key() { 301 let key = wallet_key(); 302 let mainnet = encode_address(&key, AddressNetwork::Mainnet).unwrap(); 303 let testnet = encode_address(&key, AddressNetwork::Testnet).unwrap(); 304 305 assert!(mainnet.starts_with("iuna1q")); 306 assert!(testnet.starts_with("tiuna1q")); 307 assert_eq!( 308 decode_address(&mainnet, AddressNetwork::Mainnet).unwrap(), 309 key 310 ); 311 assert_eq!( 312 decode_address(&testnet, AddressNetwork::Testnet).unwrap(), 313 key 314 ); 315 assert_ne!(mainnet, testnet); 316 } 317 318 #[test] 319 fn reserved_hybrid_addresses_parse_but_are_not_consensus_active() { 320 let expected = VersionedAddress { 321 version: AddressVersion::HybridKeyCommitment, 322 payload: [0x42; 32], 323 }; 324 let encoded = encode_versioned_address(expected, AddressNetwork::Mainnet).unwrap(); 325 326 assert_eq!( 327 decode_versioned_address(&encoded, AddressNetwork::Mainnet).unwrap(), 328 expected 329 ); 330 assert!( 331 decode_address(&encoded, AddressNetwork::Mainnet) 332 .unwrap_err() 333 .to_string() 334 .contains("not consensus-active") 335 ); 336 } 337 338 #[test] 339 fn checksum_typos_and_network_mixups_are_rejected() { 340 let key = wallet_key(); 341 let mainnet = encode_address(&key, AddressNetwork::Mainnet).unwrap(); 342 let mut typo = mainnet.clone(); 343 let replacement = if typo.ends_with('q') { 'p' } else { 'q' }; 344 typo.pop(); 345 typo.push(replacement); 346 347 assert!(decode_address(&typo, AddressNetwork::Mainnet).is_err()); 348 assert!( 349 decode_address(&mainnet, AddressNetwork::Testnet) 350 .unwrap_err() 351 .to_string() 352 .contains("instead of testnet") 353 ); 354 } 355 356 #[test] 357 fn production_reported_mainnet_address_is_valid() { 358 let address = "iuna1q7fj9u5pqpuq0gfl4a3a7afsqfmfm22x4lgz4w8aqmggs4e33aqfqkx5kyt"; 359 360 assert!(decode_address(address, AddressNetwork::Mainnet).is_ok()); 361 } 362 363 #[test] 364 fn uppercase_is_accepted_but_mixed_case_is_rejected() { 365 let key = wallet_key(); 366 let address = encode_address(&key, AddressNetwork::Mainnet).unwrap(); 367 368 assert_eq!( 369 decode_address(&address.to_ascii_uppercase(), AddressNetwork::Mainnet).unwrap(), 370 key 371 ); 372 let mut mixed = address; 373 mixed.replace_range(..1, "I"); 374 assert!(decode_address(&mixed, AddressNetwork::Mainnet).is_err()); 375 } 376 377 #[test] 378 fn malformed_keys_hex_and_legacy_user_input_are_rejected() { 379 let invalid_key = "00".repeat(32); 380 assert!(encode_address(&invalid_key, AddressNetwork::Mainnet).is_err()); 381 assert!(decode_address(&wallet_key(), AddressNetwork::Mainnet).is_err()); 382 assert!( 383 migrate_legacy_address(&wallet_key(), AddressNetwork::Mainnet) 384 .unwrap() 385 .starts_with("iuna1q") 386 ); 387 } 388 }