use crate::{
ADDRESS_SIZE,
descriptor::Descriptor,
error::{QrllibError, Result},
};
const _: () = assert!(ADDRESS_SIZE <= 64);
pub fn unsafe_get_address(public_key: &[u8], descriptor: Descriptor) -> [u8; ADDRESS_SIZE] {
use sha3::digest::{ExtendableOutput, Update, XofReader};
let mut hasher = shake::Shake256::default();
hasher.update(descriptor.as_ref());
hasher.update(public_key);
let mut address = [0_u8; ADDRESS_SIZE];
let mut reader = hasher.finalize_xof();
reader.read(&mut address);
address
}
pub fn get_address(public_key: &[u8], descriptor: Descriptor) -> Result<[u8; ADDRESS_SIZE]> {
let descriptor = descriptor.validate()?;
let wallet_type = descriptor.wallet_type()?;
let expected_size = wallet_type.expected_public_key_size();
if public_key.len() != expected_size {
return Err(QrllibError::InvalidPublicKeySize {
wallet_type,
actual: public_key.len(),
expected: expected_size,
});
}
Ok(unsafe_get_address(public_key, descriptor))
}
pub fn format_address(address: &[u8; ADDRESS_SIZE]) -> String {
format!("Q{}", hex::encode(address))
}
const HEX_LEN: usize = ADDRESS_SIZE * 2;
fn checksummed_hex(lower_hex: &str) -> String {
use sha3::digest::{ExtendableOutput, Update, XofReader};
debug_assert_eq!(lower_hex.len(), HEX_LEN);
debug_assert!(lower_hex.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)));
let mut hasher = shake::Shake256::default();
hasher.update(lower_hex.as_bytes());
let mut hash = [0_u8; ADDRESS_SIZE];
let mut reader = hasher.finalize_xof();
reader.read(&mut hash);
let mut out = String::with_capacity(lower_hex.len());
for (i, ch) in lower_hex.bytes().enumerate() {
if ch.is_ascii_lowercase() {
let nibble = if i % 2 == 0 { hash[i / 2] >> 4 } else { hash[i / 2] & 0x0f };
if nibble >= 8 {
out.push((ch - (b'a' - b'A')) as char);
continue;
}
}
out.push(ch as char);
}
out
}
pub fn to_checksum_address(address: &[u8; ADDRESS_SIZE]) -> String {
let mut s = String::with_capacity(1 + HEX_LEN);
s.push('Q');
s.push_str(&checksummed_hex(&hex::encode(address)));
s
}
pub fn is_valid_address(address: &str) -> bool {
if address.len() != 1 + HEX_LEN {
return false;
}
let Some((prefix, body)) = address.split_at_checked(1) else {
return false;
};
if !matches!(prefix, "Q" | "q") {
return false;
}
if !body.bytes().all(|b| b.is_ascii_hexdigit()) {
return false;
}
let lower = body.to_ascii_lowercase();
if body == lower || body == body.to_ascii_uppercase() {
return true;
}
body == checksummed_hex(&lower)
}
pub fn is_valid_checksum_address(address: &str) -> bool {
if address.len() != 1 + HEX_LEN {
return false;
}
let Some((prefix, body)) = address.split_at_checked(1) else {
return false;
};
if prefix != "Q" {
return false;
}
if !body.bytes().all(|b| b.is_ascii_hexdigit()) {
return false;
}
body == checksummed_hex(&body.to_ascii_lowercase())
}
#[cfg(test)]
mod tests {
use super::{format_address, is_valid_address, is_valid_checksum_address, to_checksum_address};
use crate::ADDRESS_SIZE;
const PARITY_VECTORS: &[(&str, &str, &str)] = &[
(
"ML-DSA-87 wallet 1",
"Qd5812f6cf4a0f645aa620cd57319a0ed649dd8f5519a9dde7770ae5b0e49e547985f35eb972a2a07041561aa39c65a3991478f9b1e6749e05277dcf58a9a8b72",
"Qd5812F6Cf4a0f645aa620cd57319a0Ed649dd8f5519A9dde7770ae5b0E49e547985f35eB972A2a07041561aa39c65A3991478f9B1e6749e05277dcf58A9A8B72",
),
(
"ML-DSA-87 wallet 2",
"Qbe95a82d87a6cb9c7ff4c64e0c15bb1dff20b1d77e6b571b28ad4736f2a2a3e5857e8c225d6d61399b15beef3b196936e490ed6e234374c4887cbbe86c13b1ba",
"QBe95a82D87a6CB9c7Ff4C64e0C15BB1DFF20b1d77E6B571B28Ad4736f2a2A3E5857E8c225D6D61399B15BEeF3B196936E490ed6E234374C4887CBBe86C13b1BA",
),
(
"ML-DSA-87 wallet 3",
"Q31f654037d4d7bce04e9522e4d346ab47a90686ef20a6c19916e68d3c77950f54babb7725ad48a3201c0acb74271e790730f9f39f9ce2e9ba1be9e41a763caf9",
"Q31F654037D4d7BCE04E9522e4d346ab47a90686ef20A6c19916E68D3c77950f54bABB7725aD48A3201c0aCb74271E790730f9f39f9ce2e9Ba1BE9E41a763cAf9",
),
(
"ML-DSA-87 wallet 4",
"Qafae844fa3be904799ccdb74e6f8b55d92f350df0b48605d1eaf4ffd63170d6c74a8db5f9f58309bec4cd18d500a8c6835ba53b886df50f962ec7dc98ec4e503",
"QaFAE844Fa3bE904799cCdB74E6F8B55d92F350DF0B48605D1Eaf4ffd63170D6C74a8db5f9F58309bEc4cd18D500A8c6835BA53B886df50f962ec7DC98ec4e503",
),
(
"ML-DSA-87 wallet 5",
"Q09a4e13536ec5ac05a1080522898bae3210d473a0a9e9a900bdc98361d1a9e8c2cc0652344bd35b0590b537a527cc68fa2893bc6100c1da713e5431eebafb150",
"Q09A4E13536EC5aC05A1080522898bAE3210D473A0a9E9A900bDC98361D1A9e8C2cc0652344BD35B0590B537A527cc68fA2893bc6100c1dA713E5431EEbafb150",
),
(
"all-ff",
"Qffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"QFFFfFFFFffFfffFfffFfFFfFFFFfffFFffFFfFfFFfFfFFfffFfFFFfFfFffFfFffffFfFFffFFFfFFFfFfffffFFFfffFffFfFfFFFFFfFFFFFFfFfFffFFFfffFfFF",
),
];
fn decode_addr(lower: &str) -> [u8; ADDRESS_SIZE] {
let body = hex::decode(&lower[1..]).expect("parity vector must be valid hex");
let mut addr = [0_u8; ADDRESS_SIZE];
addr.copy_from_slice(&body);
addr
}
#[test]
fn is_valid_address_accepts_canonical_uppercase_prefix() {
let canonical = format_address(&[0x5a; ADDRESS_SIZE]);
assert!(canonical.starts_with('Q'));
assert!(is_valid_address(&canonical));
}
#[test]
fn is_valid_address_accepts_lowercase_q_prefix() {
let canonical = format_address(&[0x5a; ADDRESS_SIZE]);
let lowercased = format!("q{}", &canonical[1..]);
assert!(is_valid_address(&lowercased), "address with lowercase q prefix must validate");
}
#[test]
fn is_valid_address_accepts_all_uppercase_hex_body() {
let canonical = format_address(&[0xab; ADDRESS_SIZE]);
let upper = format!("Q{}", canonical[1..].to_ascii_uppercase());
assert!(is_valid_address(&upper), "all-uppercase hex body must validate");
}
#[test]
fn is_valid_address_rejects_mixed_case_without_valid_checksum() {
let lower = format_address(&[0xab; ADDRESS_SIZE]);
let mut chars: Vec<char> = lower[1..].chars().collect();
for i in (0..chars.len()).step_by(2) {
chars[i] = chars[i].to_ascii_uppercase();
}
let mixed: String = chars.into_iter().collect();
assert!(
!is_valid_address(&format!("Q{mixed}")),
"mixed-case hex body without valid checksum must be rejected",
);
}
#[test]
fn is_valid_address_rejects_wrong_prefix() {
let canonical = format_address(&[0xab; ADDRESS_SIZE]);
let wrong_prefix = format!("X{}", &canonical[1..]);
assert!(!is_valid_address(&wrong_prefix));
}
#[test]
fn is_valid_address_rejects_wrong_length() {
let canonical = format_address(&[0xab; ADDRESS_SIZE]);
assert!(!is_valid_address(&canonical[..canonical.len() - 1]));
assert!(!is_valid_address(&format!("{canonical}0")));
}
#[test]
fn is_valid_address_rejects_non_hex_body() {
let body = "z".repeat(ADDRESS_SIZE * 2);
assert!(!is_valid_address(&format!("Q{body}")));
}
#[test]
fn is_valid_address_rejects_multibyte_leading_char() {
let mut candidate = String::from("é");
candidate.push_str(&"0".repeat(1 + ADDRESS_SIZE * 2 - candidate.len()));
assert_eq!(candidate.len(), 1 + ADDRESS_SIZE * 2);
assert!(
!is_valid_address(&candidate),
"a correctly-sized string whose first char spans multiple bytes must not validate"
);
}
#[test]
fn to_checksum_address_matches_parity_vectors() {
for (name, lower, expected) in PARITY_VECTORS {
let addr = decode_addr(lower);
let got = to_checksum_address(&addr);
assert_eq!(&got, expected, "checksum mismatch for {name}");
}
}
#[test]
fn to_checksum_address_handles_all_zero() {
let zeros = [0_u8; ADDRESS_SIZE];
let expected: String = format!("Q{}", "0".repeat(ADDRESS_SIZE * 2));
assert_eq!(to_checksum_address(&zeros), expected);
}
#[test]
fn is_valid_checksum_address_accepts_canonical_form() {
for (name, _lower, checksummed) in PARITY_VECTORS {
assert!(
is_valid_checksum_address(checksummed),
"checksummed form should validate strictly: {name}",
);
}
}
#[test]
fn is_valid_checksum_address_rejects_uniform_case_with_letters() {
let v = PARITY_VECTORS[0];
let upper = format!("Q{}", v.1[1..].to_ascii_uppercase());
assert!(
!is_valid_checksum_address(v.1),
"all-lowercase with letters must fail strict check"
);
assert!(
!is_valid_checksum_address(&upper),
"all-uppercase with letters must fail strict check"
);
}
#[test]
fn is_valid_checksum_address_rejects_lowercase_q_prefix() {
let v = PARITY_VECTORS[0];
let lowered = format!("q{}", &v.2[1..]);
assert!(!is_valid_checksum_address(&lowered));
}
#[test]
fn is_valid_checksum_address_accepts_digit_only_hex() {
let digit_only: String = format!("Q{}{}", "0123456789".repeat(12), "01234567",);
assert_eq!(digit_only.len(), 1 + ADDRESS_SIZE * 2);
assert!(is_valid_checksum_address(&digit_only));
}
#[test]
fn is_valid_checksum_address_rejects_wrong_length() {
let short = format!("Q{}", "0".repeat(ADDRESS_SIZE * 2 - 1));
assert_eq!(short.len(), ADDRESS_SIZE * 2);
assert!(!is_valid_checksum_address(&short));
}
#[test]
fn is_valid_checksum_address_rejects_non_hex_body() {
let non_hex = format!("Q{}", "z".repeat(ADDRESS_SIZE * 2));
assert_eq!(non_hex.len(), 1 + ADDRESS_SIZE * 2);
assert!(!is_valid_checksum_address(&non_hex));
}
#[test]
fn is_valid_address_accepts_checksummed_and_uniform_forms() {
for (name, lower, checksummed) in PARITY_VECTORS {
assert!(is_valid_address(lower), "lowercase form rejected: {name}");
assert!(is_valid_address(checksummed), "checksummed form rejected: {name}");
let upper = format!("Q{}", lower[1..].to_ascii_uppercase());
assert!(is_valid_address(&upper), "uppercase form rejected: {name}");
}
}
#[test]
fn is_valid_address_rejects_single_case_flip_in_checksum() {
let v = PARITY_VECTORS[0];
let mut bytes = v.2.as_bytes().to_vec();
let flip_idx = bytes
.iter()
.skip(1)
.position(|b| b.is_ascii_alphabetic())
.expect("parity vector must contain a hex letter")
+ 1;
let c = bytes[flip_idx];
bytes[flip_idx] =
if c.is_ascii_lowercase() { c - (b'a' - b'A') } else { c + (b'a' - b'A') };
let corrupted = String::from_utf8(bytes).unwrap();
assert!(!is_valid_address(&corrupted), "case-flipped checksum must be rejected");
}
}