use alloc::string::{String, ToString};
use alloc::vec::Vec;
use core::fmt;
use core::str::FromStr;
use bech32::primitives::decode::CheckedHrpstring;
use bech32::{Bech32m, Hrp};
use serde::{Deserialize, Serialize};
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AddressError {
#[error("address is not lowercase (the canonical form is lowercase-only)")]
NotLowercase,
#[error("address is not valid bech32m: {reason}")]
InvalidBech32m {
reason: String,
},
#[error("address has human-readable part '{found}', expected '{expected}'")]
HrpMismatch {
found: String,
expected: &'static str,
},
#[error("address payload is {len} bytes, expected {expected}")]
PayloadLength {
len: usize,
expected: usize,
},
#[error("address version byte is {found:#04x}, expected {expected:#04x}")]
VersionMismatch {
found: u8,
expected: u8,
},
#[error("address is a non-canonical encoding of its payload")]
NonCanonical,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Address {
hash: [u8; Address::HASH_LEN],
}
impl Address {
pub const HRP_STR: &'static str = "off";
pub const VERSION: u8 = 0x01;
pub const HASH_LEN: usize = 20;
pub const PAYLOAD_LEN: usize = 1 + Self::HASH_LEN;
pub const ENCODED_LEN: usize = 44;
const HRP: Hrp = Hrp::parse_unchecked(Self::HRP_STR);
pub fn from_hash_bytes(hash: [u8; Self::HASH_LEN]) -> Self {
Self { hash }
}
pub fn from_payload(payload: &[u8]) -> Result<Self, AddressError> {
if payload.len() != Self::PAYLOAD_LEN {
return Err(AddressError::PayloadLength {
len: payload.len(),
expected: Self::PAYLOAD_LEN,
});
}
if payload[0] != Self::VERSION {
return Err(AddressError::VersionMismatch {
found: payload[0],
expected: Self::VERSION,
});
}
let mut hash = [0u8; Self::HASH_LEN];
hash.copy_from_slice(&payload[1..]);
Ok(Self { hash })
}
pub fn hash_bytes(&self) -> &[u8; Self::HASH_LEN] {
&self.hash
}
pub fn payload(&self) -> [u8; Self::PAYLOAD_LEN] {
let mut payload = [0u8; Self::PAYLOAD_LEN];
payload[0] = Self::VERSION;
payload[1..].copy_from_slice(&self.hash);
payload
}
pub fn version(&self) -> u8 {
Self::VERSION
}
}
impl fmt::Display for Address {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
bech32::encode_lower_to_fmt::<Bech32m, _>(f, Self::HRP, &self.payload())
.map_err(|_| fmt::Error)
}
}
impl FromStr for Address {
type Err = AddressError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.bytes().any(|b| b.is_ascii_uppercase()) {
return Err(AddressError::NotLowercase);
}
let checked =
CheckedHrpstring::new::<Bech32m>(s).map_err(|e| AddressError::InvalidBech32m {
reason: e.to_string(),
})?;
if checked.hrp() != Self::HRP {
return Err(AddressError::HrpMismatch {
found: checked.hrp().to_string(),
expected: Self::HRP_STR,
});
}
let payload: Vec<u8> = checked.byte_iter().collect();
let address = Self::from_payload(&payload)?;
if address.to_string() != s {
return Err(AddressError::NonCanonical);
}
Ok(address)
}
}
impl Serialize for Address {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for Address {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
s.parse().map_err(serde::de::Error::custom)
}
}
#[cfg(all(test, feature = "std"))]
mod tests {
use super::*;
const RFC8032_TV1_HASH: [u8; 20] = [
0x21, 0xfe, 0x31, 0xdf, 0xa1, 0x54, 0xa2, 0x61, 0x62, 0x6b, 0xf8, 0x54, 0x04, 0x6f, 0xd2,
0x27, 0x1b, 0x7b, 0xed, 0x4b,
];
const RFC8032_TV1_ADDR: &str = "off1qysluvwl5922yctzd0u9gpr06gn3k7ldfvgtwgvn";
const ZEROS_HASH: [u8; 20] = [0u8; 20];
const ZEROS_ADDR: &str = "off1qyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqn8antf";
const FF_HASH: [u8; 20] = [0xffu8; 20];
const FF_ADDR: &str = "off1q8llllllllllllllllllllllllllllllluvweh4p";
const RAMP_HASH: [u8; 20] = [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
];
const RAMP_ADDR: &str = "off1qyqqzqsrqszsvpcgpy9qkrqdpc83qygjzvpgkpr5";
fn vectors() -> [([u8; 20], &'static str); 4] {
[
(RFC8032_TV1_HASH, RFC8032_TV1_ADDR),
(ZEROS_HASH, ZEROS_ADDR),
(FF_HASH, FF_ADDR),
(RAMP_HASH, RAMP_ADDR),
]
}
#[test]
fn encodes_reference_vectors() {
for (hash, expected) in vectors() {
let rendered = Address::from_hash_bytes(hash).to_string();
assert_eq!(rendered, expected);
assert_eq!(rendered.len(), Address::ENCODED_LEN);
}
}
#[test]
fn decodes_reference_vectors() {
for (hash, encoded) in vectors() {
let address: Address = encoded.parse().expect("reference vector must parse");
assert_eq!(address.hash_bytes(), &hash);
assert_eq!(address.version(), Address::VERSION);
}
}
#[test]
fn reencoding_a_parsed_vector_is_byte_identical() {
for (_, encoded) in vectors() {
let address: Address = encoded.parse().expect("reference vector must parse");
assert_eq!(address.to_string(), encoded);
}
}
#[test]
fn roundtrips_arbitrary_hashes() {
let mut state: u64 = 0x2545_F491_4F6C_DD1D;
for _ in 0..256 {
let mut hash = [0u8; Address::HASH_LEN];
for byte in hash.iter_mut() {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
*byte = (state >> 33) as u8;
}
let address = Address::from_hash_bytes(hash);
let encoded = address.to_string();
assert_eq!(encoded.len(), Address::ENCODED_LEN);
assert_eq!(encoded.parse::<Address>().expect("roundtrip"), address);
}
}
#[test]
fn payload_roundtrips() {
let address = Address::from_hash_bytes(RAMP_HASH);
let payload = address.payload();
assert_eq!(payload[0], Address::VERSION);
assert_eq!(&payload[1..], &RAMP_HASH);
assert_eq!(Address::from_payload(&payload), Ok(address));
}
#[test]
fn hrp_const_matches_parsed() {
let parsed = Hrp::parse(Address::HRP_STR).expect("hrp must be valid");
assert_eq!(parsed, Address::HRP);
}
#[test]
fn serde_uses_the_canonical_string() {
let address = Address::from_hash_bytes(RFC8032_TV1_HASH);
let json = serde_json::to_string(&address).expect("serialize");
assert_eq!(json, format!("\"{RFC8032_TV1_ADDR}\""));
let back: Address = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, address);
}
#[test]
fn serde_rejects_non_canonical_forms() {
for bad in [
ZEROS_ADDR.to_uppercase(),
"off1qyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqra5g99k".to_string(),
] {
let json = format!("\"{bad}\"");
assert!(serde_json::from_str::<Address>(&json).is_err(), "{bad}");
}
}
#[test]
fn ord_follows_hash_bytes_not_the_rendered_string() {
let low = Address::from_hash_bytes(ZEROS_HASH);
let high = Address::from_hash_bytes(FF_HASH);
assert!(low < high);
assert_eq!(low.hash_bytes() < high.hash_bytes(), low < high);
let mut a = ZEROS_HASH;
a[0] = 0x20; let mut b = ZEROS_HASH;
b[0] = 0x28; let (a, b) = (Address::from_hash_bytes(a), Address::from_hash_bytes(b));
assert!(a < b, "byte order");
assert!(a.to_string() > b.to_string(), "string order inverts");
}
#[test]
fn rejects_uppercase_and_mixed_case() {
assert_eq!(
ZEROS_ADDR.to_uppercase().parse::<Address>(),
Err(AddressError::NotLowercase)
);
let mixed = format!("{}N8antf", &ZEROS_ADDR[..ZEROS_ADDR.len() - 6]);
assert_eq!(mixed.parse::<Address>(), Err(AddressError::NotLowercase));
}
#[test]
fn rejects_wrong_hrp() {
let other = "ofx1qyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq2e32t6";
assert_eq!(
other.parse::<Address>(),
Err(AddressError::HrpMismatch {
found: "ofx".to_string(),
expected: Address::HRP_STR,
})
);
}
#[test]
fn rejects_the_original_bech32_checksum() {
let bech32_variant = "off1qyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqxmdlwt";
assert!(matches!(
bech32_variant.parse::<Address>(),
Err(AddressError::InvalidBech32m { .. })
));
}
#[test]
fn rejects_corrupted_characters() {
let mut corrupted = ZEROS_ADDR.to_string();
corrupted.pop();
corrupted.push('p'); assert!(matches!(
corrupted.parse::<Address>(),
Err(AddressError::InvalidBech32m { .. })
));
for bad_char in ['b', 'i', 'o'] {
let mut off_charset = ZEROS_ADDR.to_string();
off_charset.pop();
off_charset.push(bad_char);
assert!(
matches!(
off_charset.parse::<Address>(),
Err(AddressError::InvalidBech32m { .. })
),
"{bad_char}"
);
}
}
#[test]
fn rejects_unsupported_version_bytes() {
for (encoded, expected_version) in [
("off1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqkynkkv", 0x00u8),
("off1qgqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqquz0u9x", 0x02),
("off1luqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqn347dq", 0xff),
] {
assert_eq!(
encoded.parse::<Address>(),
Err(AddressError::VersionMismatch {
found: expected_version,
expected: Address::VERSION,
})
);
}
}
#[test]
fn rejects_wrong_payload_lengths() {
for (encoded, len) in [
("off1qyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqxfx3pw", 20usize),
("off1qyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqkev2nx", 22),
] {
assert_eq!(
encoded.parse::<Address>(),
Err(AddressError::PayloadLength {
len,
expected: Address::PAYLOAD_LEN,
})
);
}
}
#[test]
fn rejects_non_zero_padding_bits() {
for mutant in [
"off1qyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqpw3fxkm",
"off1qyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzqzuscy",
"off1qyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqra5g99k",
] {
assert_eq!(
mutant.parse::<Address>(),
Err(AddressError::NonCanonical),
"{mutant}"
);
assert!(ZEROS_ADDR.parse::<Address>().is_ok());
}
}
#[test]
fn rejects_over_long_data_parts() {
for mutant in [
"off1qyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj7v8n5",
"off1qyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqp0gcjwx",
"off1qyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqlcjhrel",
] {
assert_eq!(
mutant.parse::<Address>(),
Err(AddressError::NonCanonical),
"{mutant}"
);
}
}
#[test]
fn rejects_structural_garbage() {
for bad in [
"",
"off",
"off1",
"off1qqq",
"1qyqqqq",
ZEROS_ADDR.trim_end_matches('f'),
] {
assert!(
matches!(
bad.parse::<Address>(),
Err(AddressError::InvalidBech32m { .. })
),
"{bad:?}"
);
}
}
#[test]
fn from_payload_rejects_bad_input() {
assert_eq!(
Address::from_payload(&[]),
Err(AddressError::PayloadLength {
len: 0,
expected: Address::PAYLOAD_LEN,
})
);
let mut payload = [0u8; Address::PAYLOAD_LEN];
payload[0] = 0x02;
assert_eq!(
Address::from_payload(&payload),
Err(AddressError::VersionMismatch {
found: 0x02,
expected: Address::VERSION,
})
);
}
}
#[cfg(all(test, feature = "std"))]
mod spec_vectors {
use super::*;
const VECTORS: &str = include_str!("../tests/data/address-v1.vectors.json");
fn vectors() -> serde_json::Value {
serde_json::from_str(VECTORS).expect("the vector file is JSON")
}
fn hash_from(hex: &str) -> [u8; Address::HASH_LEN] {
let bytes: Vec<u8> = (0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("hex digit"))
.collect();
bytes.try_into().expect("20 bytes")
}
#[test]
fn the_vector_file_is_the_size_it_was() {
let v = vectors();
assert_eq!(v["hrp"], "off");
assert_eq!(v["version_byte"], 1);
assert_eq!(v["hash_len"], Address::HASH_LEN);
assert_eq!(v["encoded_len"], Address::ENCODED_LEN);
assert_eq!(v["encode"].as_array().expect("encode").len(), 4);
assert_eq!(v["reject"].as_array().expect("reject").len(), 5);
assert!(
!v["ordering"].as_array().expect("ordering").is_empty(),
"the ordering cases are the reason this file exists"
);
}
#[test]
fn every_hash_renders_to_its_vector() {
for case in vectors()["encode"].as_array().expect("encode") {
let name = case["name"].as_str().expect("a name");
let hash = hash_from(case["hash_hex"].as_str().expect("a hash"));
assert_eq!(
Address::from_hash_bytes(hash).to_string(),
case["address"].as_str().expect("an address"),
"[{name}] rendered differently than the chapter specifies"
);
}
}
#[test]
fn every_vector_parses_back_into_its_hash() {
for case in vectors()["encode"].as_array().expect("encode") {
let name = case["name"].as_str().expect("a name");
let text = case["address"].as_str().expect("an address");
let parsed: Address = text.parse().unwrap_or_else(|e| panic!("[{name}]: {e:?}"));
assert_eq!(
parsed.hash_bytes(),
&hash_from(case["hash_hex"].as_str().expect("a hash")),
"[{name}] parsed to a different hash"
);
assert_eq!(
parsed.to_string(),
text,
"[{name}] did not re-encode to the string it came from"
);
}
}
#[test]
fn reject_vectors_are_refused() {
for case in vectors()["reject"].as_array().expect("reject") {
let name = case["name"].as_str().expect("a name");
let reason = case["reason"].as_str().expect("a reason");
let input = case["input"].as_str().expect("an input");
assert!(
input.parse::<Address>().is_err(),
"[{name}] was accepted, but the chapter refuses it: {reason}"
);
}
}
#[test]
fn the_two_orderings_disagree_where_the_vectors_say_they_do() {
for case in vectors()["ordering"].as_array().expect("ordering") {
let a: Address = case["a"].as_str().expect("a").parse().expect("a parses");
let b: Address = case["b"].as_str().expect("b").parse().expect("b parses");
let by_hash = if a <= b { a } else { b };
assert_eq!(
by_hash.to_string(),
case["first_by_hash_bytes"].as_str().expect("hash order"),
"the derived Ord no longer compares hash bytes"
);
let by_string = core::cmp::min(a.to_string(), b.to_string());
assert_eq!(
by_string,
case["first_by_rendered_string"]
.as_str()
.expect("string order"),
"string order moved"
);
assert_ne!(
by_hash.to_string(),
by_string,
"this pair no longer disagrees, so it can no longer catch a \
tiebreaker that swapped orders"
);
}
}
fn chapter() -> Option<String> {
let path =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/spec/identity.md");
std::fs::read_to_string(&path).ok().or_else(|| {
eprintln!("spec tree not present, skipping the identity chapter drift checks");
None
})
}
#[test]
fn the_chapter_states_the_constants_the_code_uses() {
let Some(text) = chapter() else { return };
assert!(
text.contains(&format!("truncated to {} bytes", Address::HASH_LEN)),
"the chapter does not state the {}-byte truncation",
Address::HASH_LEN
);
assert!(
text.contains(&format!("exactly {} characters", Address::ENCODED_LEN)),
"the chapter does not state the {}-character canonical rendering",
Address::ENCODED_LEN
);
assert!(
text.contains("bech32m"),
"the chapter no longer names bech32m, and bech32 is a different \
checksum constant that this parser refuses"
);
}
}