From 811eae6a4d6f0a0a7d6b0047f517c0d2fd26720b Mon Sep 17 00:00:00 2001 From: dhyabi2 Date: Sun, 11 Oct 2026 08:34:22 +0000 Subject: [PATCH] feat: add Nano address validation Add Chain::Nano to the bus vocabulary ("nano", alias "xno") and an address::nano module in tinywallet-crypto behind a default-on `nano` gate. A Nano address is `nano_` (or legacy `xrb_`) plus 60 base32 characters: 4 zero padding bits and a 32-byte ed25519 public key, then a byte-reversed 5-byte blake2b checksum. validate, decode and encode mirror the Solana module; address::validate dispatches to it. The asset catalog and chain client do not cover Nano, so the network-coverage test skips it explicitly. --- Cargo.lock | 10 + Cargo.toml | 6 +- README.md | 8 +- crates/tinywallet-bus/src/chain/mod.rs | 8 +- crates/tinywallet-bus/src/chain/mod_tests.rs | 13 ++ crates/tinywallet-crypto/Cargo.toml | 6 +- crates/tinywallet-crypto/src/address/mod.rs | 15 +- .../src/address/mod_tests.rs | 8 +- crates/tinywallet-crypto/src/address/nano.rs | 206 ++++++++++++++++++ .../src/address/nano/nano_tests.rs | 141 ++++++++++++ .../tinywallet-crypto/src/asset/mod_tests.rs | 4 +- crates/tinywallet-crypto/src/lib.rs | 1 + crates/tinywallet-module/Cargo.toml | 1 + examples/basic.rs | 4 + src/lib.rs | 3 +- 15 files changed, 421 insertions(+), 13 deletions(-) create mode 100644 crates/tinywallet-crypto/src/address/nano.rs create mode 100644 crates/tinywallet-crypto/src/address/nano/nano_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 86522504..3ecc5bfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -237,6 +237,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1995,6 +2004,7 @@ version = "0.19.0" dependencies = [ "async-trait", "bech32 0.12.0", + "blake2", "bs58", "hex", "serde", diff --git a/Cargo.toml b/Cargo.toml index 498de8a4..da8f8d75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -149,14 +149,14 @@ serde_json = "1" # the library itself still compile with the gates off. [[example]] name = "basic" -required-features = ["btc", "evm", "solana", "tron", "keccak"] +required-features = ["btc", "evm", "solana", "tron", "nano", "keccak"] [[test]] name = "public_api" required-features = ["btc", "evm", "solana", "tron", "keccak"] [features] -default = ["btc", "evm", "solana", "tron", "keccak", "net", "key", "asset", "client", "tx", "x402", "wire", "eip712", "abi"] +default = ["btc", "evm", "solana", "tron", "nano", "keccak", "net", "key", "asset", "client", "tx", "x402", "wire", "eip712", "abi"] # Every gate below that names a module this crate no longer owns forwards to the # crate that does. The names are unchanged on purpose: a consumer that asked for # `tinywallet/btc` before the split gets exactly what it got before, from a @@ -172,6 +172,8 @@ evm = ["tinywallet-crypto/evm"] solana = ["tinywallet-crypto/solana"] # Tron base58check address validation and hex conversion. tron = ["tinywallet-crypto/tron"] +# Nano address validation and encoding. +nano = ["tinywallet-crypto/nano"] # EIP-55 mixed-case checksum support for EVM addresses. `dep:sha3` is this # crate's own, for the addresses `key` derivation produces. keccak = ["evm", "dep:sha3", "tinywallet-crypto/keccak"] diff --git a/README.md b/README.md index 537bb2e5..d2218d8e 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ Agent-friendly multi-chain wallet primitives in Rust. `tinywallet` owns the parts of wallet handling that are pure: address formats, their validation, and the conversions between their encodings. Bitcoin, EVM -chains, Solana, and Tron each get a module, and `address::validate` dispatches -across them for chain-generic callers. +chains, Solana, Tron, and Nano each get a module, and `address::validate` +dispatches across them for chain-generic callers. ```rust use tinywallet::{address, Chain}; @@ -40,6 +40,7 @@ because it is easy to assume otherwise: | --- | --- | --- | | Bitcoin | yes (base58check / bech32) | caught | | Tron | yes (base58check) | caught | +| Nano | yes (blake2b, 40 bits) | caught | | EVM | optional (EIP-55, only if mixed-case) | usually **not** caught | | Solana | none | **not reliably** caught | @@ -75,6 +76,7 @@ not pay for the others' parsers. | `evm` | on | EVM addresses | — (dependency-free) | | `solana` | on | Solana addresses | `bs58` | | `tron` | on | Tron addresses | `bs58`, `hex` | +| `nano` | on | Nano addresses (`nano_` / legacy `xrb_`) | `blake2` | | `keccak` | on | EIP-55 checksums for EVM | `sha3` | With a chain's gate off, `address::validate` returns `Error::ChainNotCompiled` @@ -170,7 +172,7 @@ must error, never validate): ```sh cargo clippy --all-targets --no-default-features -- -D warnings cargo test --lib --no-default-features -for f in btc evm solana tron keccak; do +for f in btc evm solana tron nano keccak; do cargo check --lib --no-default-features --features "$f" cargo test --lib --no-default-features --features "$f" done diff --git a/crates/tinywallet-bus/src/chain/mod.rs b/crates/tinywallet-bus/src/chain/mod.rs index 9a554a00..8ccadbfa 100644 --- a/crates/tinywallet-bus/src/chain/mod.rs +++ b/crates/tinywallet-bus/src/chain/mod.rs @@ -38,6 +38,8 @@ pub enum Chain { Solana, /// Tron (mainnet). Tron, + /// Nano (mainnet). + Nano, } impl Chain { @@ -45,10 +47,10 @@ impl Chain { /// /// Useful for a host enumerating supported chains. This is the full set /// regardless of which feature gates are enabled — see the module docs. - pub const ALL: &'static [Self] = &[Self::Btc, Self::Evm, Self::Solana, Self::Tron]; + pub const ALL: &'static [Self] = &[Self::Btc, Self::Evm, Self::Solana, Self::Tron, Self::Nano]; /// The chain's lowercase machine-readable name (`"btc"`, `"evm"`, - /// `"solana"`, `"tron"`). + /// `"solana"`, `"tron"`, `"nano"`). /// /// This is the form [`Chain::from_str`] parses, so `chain.as_str()` always /// round-trips. @@ -59,6 +61,7 @@ impl Chain { Self::Evm => "evm", Self::Solana => "solana", Self::Tron => "tron", + Self::Nano => "nano", } } } @@ -95,6 +98,7 @@ impl FromStr for Chain { "evm" | "eth" | "ethereum" => Ok(Self::Evm), "solana" | "sol" => Ok(Self::Solana), "tron" | "trx" => Ok(Self::Tron), + "nano" | "xno" => Ok(Self::Nano), other => Err(UnknownChain(other.to_string())), } } diff --git a/crates/tinywallet-bus/src/chain/mod_tests.rs b/crates/tinywallet-bus/src/chain/mod_tests.rs index 7267b710..26177b94 100644 --- a/crates/tinywallet-bus/src/chain/mod_tests.rs +++ b/crates/tinywallet-bus/src/chain/mod_tests.rs @@ -55,3 +55,16 @@ fn an_unknown_name_is_reported_with_the_input() { UnknownChain("dogecoin".to_string()) ); } + +#[test] +fn nano_parses_with_its_aliases() { + for input in ["nano", "xno", "NANO"] { + assert_eq!( + Chain::from_str(input).unwrap(), + Chain::Nano, + "alias {input}" + ); + } + assert_eq!(Chain::Nano.as_str(), "nano"); + assert!(Chain::ALL.contains(&Chain::Nano)); +} diff --git a/crates/tinywallet-crypto/Cargo.toml b/crates/tinywallet-crypto/Cargo.toml index 9289102d..e7dfe48d 100644 --- a/crates/tinywallet-crypto/Cargo.toml +++ b/crates/tinywallet-crypto/Cargo.toml @@ -33,6 +33,8 @@ hex = { version = "0.4", optional = true } bech32 = { version = "0.12", optional = true } # SHA-256: Bitcoin's HASH160, Tron's base58check checksum, and the Tron txid. sha2 = { version = "0.11", optional = true } +# Blake2b-40, the checksum on a Nano address; see `src/address/nano.rs`. +blake2 = { version = "0.11", default-features = false, optional = true } # Keccak-256 for EIP-55 checksums on EVM addresses. # # Pinned to 0.12. The root crate's `coins-bip32` still depends on `sha3` 0.10, @@ -57,7 +59,7 @@ serde_json = "1" [features] # Every feature the bus exposed before the split, so `default` matches it. -default = ["btc", "evm", "solana", "tron", "keccak", "net", "asset", "tx-codec"] +default = ["btc", "evm", "solana", "tron", "nano", "keccak", "net", "asset", "tx-codec"] # Bitcoin address parsing and validation. Dependency-light on purpose: base58 # via `bs58` (already here for Solana and Tron) and segwit via `bech32`. btc = ["dep:bs58", "dep:bech32", "dep:sha2"] @@ -72,6 +74,8 @@ evm = [] solana = ["dep:bs58"] # Tron base58check address validation and hex conversion. tron = ["dep:bs58", "dep:hex"] +# Nano address validation and encoding. +nano = ["dep:blake2"] # EIP-55 mixed-case checksum support for EVM addresses. keccak = ["evm", "dep:sha3"] # The `Transport` network seam (`tinywallet_crypto::rpc`). The one module that diff --git a/crates/tinywallet-crypto/src/address/mod.rs b/crates/tinywallet-crypto/src/address/mod.rs index 0ef8478f..5a8acd5d 100644 --- a/crates/tinywallet-crypto/src/address/mod.rs +++ b/crates/tinywallet-crypto/src/address/mod.rs @@ -3,7 +3,7 @@ //! Each submodule owns one chain's address format and exposes the same core //! shape: a `validate` returning the trimmed address, plus whatever //! chain-specific conversions are genuinely useful (`solana::decode`, -//! `tron::to_hex`, `btc::validate_sender`). +//! `tron::to_hex`, `btc::validate_sender`, `nano::decode`). //! //! [`validate`] dispatches across all of them for chain-generic callers. //! @@ -20,6 +20,7 @@ //! | --- | --- | --- | //! | Bitcoin | yes (base58check / bech32) | caught | //! | Tron | yes (base58check) | caught | +//! | Nano | yes (blake2b, 40 bits) | caught | //! | EVM | optional (EIP-55, only if mixed-case) | usually *not* caught | //! | Solana | none | *not reliably* caught | //! @@ -34,6 +35,8 @@ use crate::chain::Chain; pub mod btc; #[cfg(feature = "evm")] pub mod evm; +#[cfg(feature = "nano")] +pub mod nano; #[cfg(feature = "solana")] pub mod solana; #[cfg(feature = "tron")] @@ -72,7 +75,13 @@ pub mod tron; // `address` goes unread. That build is legal (a host may depend on this crate // purely for `Chain`), so the unused binding is expected rather than a bug. #[cfg_attr( - not(any(feature = "btc", feature = "evm", feature = "solana", feature = "tron")), + not(any( + feature = "btc", + feature = "evm", + feature = "solana", + feature = "tron", + feature = "nano" + )), allow(unused_variables) )] pub fn validate(chain: Chain, address: &str) -> Result { @@ -85,6 +94,8 @@ pub fn validate(chain: Chain, address: &str) -> Result { Chain::Solana => Some(solana::validate), #[cfg(feature = "tron")] Chain::Tron => Some(tron::validate), + #[cfg(feature = "nano")] + Chain::Nano => Some(nano::validate), _ => None, }; let validator = validator.ok_or(crate::Error::ChainNotCompiled { chain })?; diff --git a/crates/tinywallet-crypto/src/address/mod_tests.rs b/crates/tinywallet-crypto/src/address/mod_tests.rs index acbb7087..726f1774 100644 --- a/crates/tinywallet-crypto/src/address/mod_tests.rs +++ b/crates/tinywallet-crypto/src/address/mod_tests.rs @@ -6,11 +6,15 @@ use super::validate; use crate::{Chain, Error}; /// One valid mainnet address per chain. -const FIXTURES: [(Chain, &str); 4] = [ +const FIXTURES: [(Chain, &str); 5] = [ (Chain::Btc, "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"), (Chain::Evm, "0x52908400098527886E0F7030069857D2E4169EE7"), (Chain::Solana, "11111111111111111111111111111111"), (Chain::Tron, "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"), + ( + Chain::Nano, + "nano_3t6k35gi95xu6tergt6p69ck76ogmitsa8mnijtpxm9fkcm736xtoncuohr3", + ), ]; /// Whether the feature gate behind `chain` is on in this build. @@ -28,6 +32,8 @@ const fn chain_enabled(chain: Chain) -> bool { Chain::Solana => true, #[cfg(feature = "tron")] Chain::Tron => true, + #[cfg(feature = "nano")] + Chain::Nano => true, _ => false, } } diff --git a/crates/tinywallet-crypto/src/address/nano.rs b/crates/tinywallet-crypto/src/address/nano.rs new file mode 100644 index 00000000..7ac1cc3d --- /dev/null +++ b/crates/tinywallet-crypto/src/address/nano.rs @@ -0,0 +1,206 @@ +//! Nano address validation. +//! +//! A Nano address is an ed25519 public key — 32 raw bytes — rendered in +//! Nano's own base32 alphabet behind a `nano_` prefix: +//! +//! - **52 characters** (260 bits): four zero padding bits, then the 256-bit +//! public key. +//! - **8 characters** (40 bits): a 5-byte blake2b digest of the public key, +//! byte-reversed. +//! +//! The legacy `xrb_` prefix names the same account and is still accepted. +//! Like Bitcoin and Tron, Nano addresses *are* checksummed, so a mistyped +//! address is reliably caught here rather than silently naming a different +//! account. + +use blake2::digest::consts::U5; +use blake2::{Blake2b, Digest}; + +use crate::chain::Chain; +use crate::{Error, Result}; + +/// The current address prefix, and the one [`encode`] produces. +pub const PREFIX: &str = "nano_"; + +/// The legacy address prefix. It names the same account as [`PREFIX`]. +pub const LEGACY_PREFIX: &str = "xrb_"; + +/// Length in bytes of a decoded Nano address (an ed25519 public key). +pub const ADDRESS_BYTES: usize = 32; + +/// Nano's base32 alphabet: digits and lowercase letters without `0`, `2`, +/// `l` and `v`, which are too easily confused with other characters. +const ALPHABET: &[u8; 32] = b"13456789abcdefghijkmnopqrstuwxyz"; + +/// Characters encoding the padded public key: 4 zero bits plus 256 key bits. +const KEY_CHARS: usize = 52; + +/// Characters encoding the 40-bit checksum. +const CHECKSUM_CHARS: usize = 8; + +/// Length in bytes of the blake2b checksum. +const CHECKSUM_BYTES: usize = 5; + +/// Validate a Nano address and return it trimmed. +/// +/// # Errors +/// +/// - [`Error::EmptyAddress`] if `address` is empty or all whitespace. +/// - [`Error::InvalidAddress`] if it lacks the `nano_` or `xrb_` prefix, is not +/// 60 characters after it, uses a character outside the Nano alphabet, sets a +/// padding bit, or fails its checksum. +/// +/// # Examples +/// +/// ``` +/// use tinywallet_crypto::address::nano; +/// +/// let genesis = "nano_3t6k35gi95xu6tergt6p69ck76ogmitsa8mnijtpxm9fkcm736xtoncuohr3"; +/// assert!(nano::validate(genesis).is_ok()); +/// +/// // One character changed: the checksum catches it. +/// let typo = "nano_3t6k35gi95xu6tergt6p69ck76ogmitsa8mnijtpxm9fkcm736xtoncuohr4"; +/// assert!(nano::validate(typo).is_err()); +/// ``` +pub fn validate(address: &str) -> Result { + decode(address).map(|_| address.trim().to_string()) +} + +/// Validate a Nano address and return its decoded 32-byte public key. +/// +/// The same check as [`validate`], for a caller that needs the key rather +/// than the string — building a block, say. +/// +/// # Errors +/// +/// Identical to [`validate`]. +/// +/// # Examples +/// +/// ``` +/// use tinywallet_crypto::address::nano; +/// +/// let key = nano::decode("nano_1111111111111111111111111111111111111111111111111111hifc8npp")?; +/// assert_eq!(key, [0u8; 32]); +/// # Ok::<(), tinywallet_crypto::Error>(()) +/// ``` +pub fn decode(address: &str) -> Result<[u8; ADDRESS_BYTES]> { + let trimmed = address.trim(); + if trimmed.is_empty() { + return Err(Error::EmptyAddress { chain: Chain::Nano }); + } + let invalid = |reason: String| Error::InvalidAddress { + chain: Chain::Nano, + address: trimmed.to_string(), + reason, + }; + + let body = trimmed + .strip_prefix(PREFIX) + .or_else(|| trimmed.strip_prefix(LEGACY_PREFIX)) + .ok_or_else(|| invalid(format!("expected a {PREFIX} or {LEGACY_PREFIX} prefix")))?; + + let digits = body + .bytes() + .map(|c| { + ALPHABET + .iter() + .position(|&a| a == c) + .and_then(|i| u8::try_from(i).ok()) + .ok_or_else(|| { + invalid(format!( + "{:?} is not in the nano base32 alphabet", + char::from(c) + )) + }) + }) + .collect::>>()?; + if digits.len() != KEY_CHARS + CHECKSUM_CHARS { + return Err(invalid(format!( + "expected {} characters after the prefix, got {}", + KEY_CHARS + CHECKSUM_CHARS, + digits.len() + ))); + } + + let (key_digits, checksum_digits) = digits.split_at(KEY_CHARS); + // 52 characters carry 260 bits for a 256-bit key: the top four bits of + // the first character are padding and must be zero. + if key_digits[0] > 1 { + return Err(invalid("padding bits are not zero".to_string())); + } + + let mut key = [0u8; ADDRESS_BYTES]; + unpack(key_digits, &mut key); + let mut checksum = [0u8; CHECKSUM_BYTES]; + unpack(checksum_digits, &mut checksum); + if checksum != checksum_of(&key) { + return Err(invalid("checksum mismatch".to_string())); + } + Ok(key) +} + +/// Render a 32-byte public key as a `nano_` address. +/// +/// The inverse of [`decode`]. Infallible: every 32-byte array is a +/// syntactically valid address. +/// +/// # Examples +/// +/// ``` +/// use tinywallet_crypto::address::nano; +/// +/// assert_eq!( +/// nano::encode(&[0u8; 32]), +/// "nano_1111111111111111111111111111111111111111111111111111hifc8npp" +/// ); +/// ``` +#[must_use] +pub fn encode(key: &[u8; ADDRESS_BYTES]) -> String { + let mut out = String::with_capacity(PREFIX.len() + KEY_CHARS + CHECKSUM_CHARS); + out.push_str(PREFIX); + pack(key, KEY_CHARS, &mut out); + pack(&checksum_of(key), CHECKSUM_CHARS, &mut out); + out +} + +/// The 5-byte blake2b digest of `key`, byte-reversed as the address carries it. +fn checksum_of(key: &[u8; ADDRESS_BYTES]) -> [u8; CHECKSUM_BYTES] { + let mut checksum: [u8; CHECKSUM_BYTES] = Blake2b::::digest(key).into(); + checksum.reverse(); + checksum +} + +/// Unpack 5-bit `digits` into `out`, most significant bit first, discarding +/// the leading bits that do not fit — the padding bits. +fn unpack(digits: &[u8], out: &mut [u8]) { + let padding = digits.len() * 5 - out.len() * 8; + let bits = digits + .iter() + .flat_map(|&d| (0..5).rev().map(move |shift| (d >> shift) & 1)) + .skip(padding); + for (i, bit) in bits.enumerate() { + out[i / 8] |= bit << (7 - i % 8); + } +} + +/// Pack `bytes` into `chars` base32 characters, most significant bit first, +/// with zero padding bits in front. +fn pack(bytes: &[u8], chars: usize, out: &mut String) { + let padding = chars * 5 - bytes.len() * 8; + let mut bits = std::iter::repeat_n(0u8, padding).chain( + bytes + .iter() + .flat_map(|&b| (0..8).rev().map(move |shift| (b >> shift) & 1)), + ); + for _ in 0..chars { + let digit = (0..5).fold(0usize, |acc, _| { + (acc << 1) | usize::from(bits.next().unwrap_or(0)) + }); + out.push(char::from(ALPHABET[digit])); + } +} + +#[cfg(test)] +#[path = "nano/nano_tests.rs"] +mod test; diff --git a/crates/tinywallet-crypto/src/address/nano/nano_tests.rs b/crates/tinywallet-crypto/src/address/nano/nano_tests.rs new file mode 100644 index 00000000..485f1a43 --- /dev/null +++ b/crates/tinywallet-crypto/src/address/nano/nano_tests.rs @@ -0,0 +1,141 @@ +//! Unit tests for Nano address validation. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::{ADDRESS_BYTES, decode, encode, validate}; +use crate::{Chain, Error}; + +/// A real mainnet account. +const ACCOUNT: &str = "nano_1434j1n4sin4cefs5njibag4tsmo596fmg3s6bdogtod3ndmdfez5yuebrh9"; +/// The genesis account, whose public key is published. +const GENESIS: &str = "nano_3t6k35gi95xu6tergt6p69ck76ogmitsa8mnijtpxm9fkcm736xtoncuohr3"; +/// The genesis public key, E89208DD…A65093BA. +const GENESIS_KEY: [u8; ADDRESS_BYTES] = [ + 0xe8, 0x92, 0x08, 0xdd, 0x03, 0x8f, 0xbb, 0x26, 0x99, 0x87, 0x68, 0x96, 0x21, 0xd5, 0x22, 0x92, + 0xae, 0x9c, 0x35, 0x94, 0x1a, 0x74, 0x84, 0x75, 0x6e, 0xcc, 0xed, 0x92, 0xa6, 0x50, 0x93, 0xba, +]; +/// The burn address: the all-zero public key. +const BURN: &str = "nano_1111111111111111111111111111111111111111111111111111hifc8npp"; + +#[test] +fn accepts_real_addresses() { + for addr in [ACCOUNT, GENESIS, BURN] { + assert_eq!(validate(addr).unwrap(), addr); + } +} + +#[test] +fn accepts_the_legacy_xrb_prefix() { + let legacy = GENESIS.replacen("nano_", "xrb_", 1); + assert_eq!(validate(&legacy).unwrap(), legacy); + assert_eq!(decode(&legacy).unwrap(), decode(GENESIS).unwrap()); +} + +#[test] +fn trims_surrounding_whitespace() { + assert_eq!(validate(&format!(" {GENESIS}\n")).unwrap(), GENESIS); +} + +#[test] +fn rejects_an_empty_address() { + assert_eq!( + validate(" ").unwrap_err(), + Error::EmptyAddress { chain: Chain::Nano } + ); +} + +#[test] +fn decode_returns_the_public_key() { + assert_eq!(decode(BURN).unwrap(), [0u8; ADDRESS_BYTES]); + assert_eq!(decode(GENESIS).unwrap(), GENESIS_KEY); +} + +#[test] +fn rejects_a_mistyped_address_via_its_checksum() { + // The last character sits in the checksum; changing it leaves the key + // intact, so only the checksum can catch it. + let typo = format!("{}8", &ACCOUNT[..ACCOUNT.len() - 1]); + assert_ne!(typo, ACCOUNT, "the fixture must actually differ"); + assert_invalid(&typo, "checksum"); +} + +#[test] +fn rejects_a_mistyped_public_key_via_its_checksum() { + let mut chars: Vec = GENESIS.chars().collect(); + chars[10] = if chars[10] == 'a' { 'b' } else { 'a' }; + let typo: String = chars.into_iter().collect(); + assert_invalid(&typo, "checksum"); +} + +#[test] +fn rejects_a_missing_or_foreign_prefix() { + for bad in [ + &ACCOUNT["nano_".len()..], + &ACCOUNT.replacen("nano_", "ban_", 1), + &ACCOUNT.replacen("nano_", "NANO_", 1), + ] { + assert_invalid(bad, "prefix"); + } +} + +#[test] +fn rejects_the_wrong_length() { + assert_invalid(&ACCOUNT[..ACCOUNT.len() - 1], "60"); + assert_invalid(&format!("{ACCOUNT}1"), "60"); +} + +#[test] +fn rejects_characters_outside_the_nano_alphabet() { + // `0`, `2`, `l` and `v` are excluded, and the alphabet is lowercase only. + for bad in ['0', '2', 'l', 'v', 'A'] { + let addr = format!("{}{bad}{}", &ACCOUNT[..20], &ACCOUNT[21..]); + assert_invalid(&addr, "alphabet"); + } +} + +#[test] +fn rejects_nonzero_padding_bits() { + // `4` differs from `1` only in the padding bits, so the key and checksum + // stay valid and the padding check is the only thing that can fail. + let padded = ACCOUNT.replacen("nano_1", "nano_4", 1); + assert_invalid(&padded, "padding"); +} + +#[test] +fn encode_and_decode_round_trip() { + assert_eq!(encode(&decode(ACCOUNT).unwrap()), ACCOUNT); + assert_eq!(encode(&decode(GENESIS).unwrap()), GENESIS); + let mut bytes = [0u8; ADDRESS_BYTES]; + for (i, b) in bytes.iter_mut().enumerate() { + *b = u8::try_from(i).unwrap().wrapping_mul(37); + } + assert_eq!(decode(&encode(&bytes)).unwrap(), bytes); + assert_eq!( + encode(&[0xff; ADDRESS_BYTES]), + "nano_3zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzc3yoon41" + ); +} + +#[test] +fn decode_rejects_exactly_what_validate_rejects() { + for input in ["", " ", "nano_", &ACCOUNT[..ACCOUNT.len() - 1], ACCOUNT] { + assert_eq!( + validate(input).is_err(), + decode(input).is_err(), + "validate and decode disagreed on {input:?}" + ); + } +} + +fn assert_invalid(address: &str, reason_part: &str) { + match validate(address).unwrap_err() { + Error::InvalidAddress { chain, reason, .. } => { + assert_eq!(chain, Chain::Nano); + assert!( + reason.contains(reason_part), + "reason for {address:?} was {reason:?}" + ); + } + other => panic!("expected InvalidAddress for {address:?}, got {other:?}"), + } +} diff --git a/crates/tinywallet-crypto/src/asset/mod_tests.rs b/crates/tinywallet-crypto/src/asset/mod_tests.rs index 9964e8dc..93d63063 100644 --- a/crates/tinywallet-crypto/src/asset/mod_tests.rs +++ b/crates/tinywallet-crypto/src/asset/mod_tests.rs @@ -293,7 +293,9 @@ fn networks_are_distinct_and_cover_every_chain() { seen.dedup(); assert_eq!(seen.len(), all.len(), "duplicate network"); - for chain in Chain::ALL { + // Nano has address support only: no catalog network and no client + // queries yet, so it is the one chain this table does not cover. + for chain in Chain::ALL.iter().filter(|c| **c != Chain::Nano) { assert!( all.iter().any(|n| n.chain() == *chain), "no network for {chain}" diff --git a/crates/tinywallet-crypto/src/lib.rs b/crates/tinywallet-crypto/src/lib.rs index 41ac5339..aea78221 100644 --- a/crates/tinywallet-crypto/src/lib.rs +++ b/crates/tinywallet-crypto/src/lib.rs @@ -27,6 +27,7 @@ //! | `evm` | EVM addresses | //! | `solana` | Solana addresses | //! | `tron` | Tron addresses, and the Tron parts of [`tx`] | +//! | `nano` | Nano addresses | //! | `keccak` | EIP-55 checksums for EVM addresses | //! | `net` | the [`rpc::Transport`] seam | //! | `asset` | network and token reference data | diff --git a/crates/tinywallet-module/Cargo.toml b/crates/tinywallet-module/Cargo.toml index 1b650c66..3cef7cb0 100644 --- a/crates/tinywallet-module/Cargo.toml +++ b/crates/tinywallet-module/Cargo.toml @@ -38,6 +38,7 @@ tinywallet = { path = "../..", default-features = false, features = [ "evm", "solana", "tron", + "nano", "keccak", "key", "tx", diff --git a/examples/basic.rs b/examples/basic.rs index 329bb5ff..8dea7f03 100644 --- a/examples/basic.rs +++ b/examples/basic.rs @@ -15,6 +15,10 @@ fn main() { (Chain::Evm, "0x52908400098527886E0F7030069857D2E4169EE7"), (Chain::Solana, "11111111111111111111111111111111"), (Chain::Tron, "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"), + ( + Chain::Nano, + "nano_3t6k35gi95xu6tergt6p69ck76ogmitsa8mnijtpxm9fkcm736xtoncuohr3", + ), ]; for (chain, candidate) in fixtures { diff --git a/src/lib.rs b/src/lib.rs index 01d753a0..19385180 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,7 @@ //! //! `tinywallet` owns the parts of wallet handling that are pure: address //! formats, their validation, and the conversions between their encodings. -//! Bitcoin, EVM chains, Solana, and Tron each get a module, and +//! Bitcoin, EVM chains, Solana, Tron, and Nano each get a module, and //! [`address::validate`] dispatches across them for chain-generic callers. //! //! # What this crate deliberately does not do @@ -45,6 +45,7 @@ //! | `evm` | on | EVM addresses (no dependencies) | //! | `solana` | on | Solana addresses (pulls `bs58`) | //! | `tron` | on | Tron addresses (pulls `bs58`, `hex`) | +//! | `nano` | on | Nano addresses (pulls `blake2`) | //! | `keccak` | on | EIP-55 checksums for EVM (pulls `sha3`) | //! | `net` | on | the `rpc::Transport` network seam (pulls `async-trait`) | //! | `key` | on | BIP-39/BIP-32/SLIP-0010 key derivation (`tinywallet::key`) |