iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

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 }