diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c5bbc8..41f09c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,94 @@ All notable changes to this project will be documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.1.0] — 2026-04-27 +## [1.1.0] — 2026-07-04 + +URN grammar 1.1, mirroring the go-mhda reference. The chain identity is now +the `(nt, ci)` pair; the SLIP-44 coin type is optional metadata. Existing +pre-1.1 URNs still parse (any component order is accepted on input), but +pre-1.1 chain keys are rejected loudly and must be regenerated. + +### Changed + +- **Chain identity is `(nt, ci)`.** `chain::str()` / `chain::key()` return + `nt::ci:` — the coin type never appears in the key — + and `chain::operator==` compares network and chain id only. The constructor + is now `chain(network_type, chain_id)`; the old three-argument form + (with a coin type) is gone. +- **`ct` is optional metadata.** `chain::coin()` returns + `std::optional` (`set_coin` / `clear_coin` manage it); + `address::set_coin_type("")` clears it. Parsers accept a URN/NSS without + `ct`; when present it must still be a valid uint32 (decimal or 0x-hex) and + is re-emitted in decimal. +- **Canonical NSS order** is now `nt:ci[:ct][:dt:dp][:aa][:af][:ap][:as] + [:wt][:wi]` — the chain key is a strict prefix of every NSS. Input order + remains free. +- **`chain::from_key` is canonical-only.** A chain key must BE the canonical + identity string `nt::ci:`: an input with `ct` throws the + new `error_code::coin_type_in_chain_key` (pre-1.1 keys fail loudly instead + of being silently reinterpreted); any other known non-identity component, + unknown tokens, reordering and non-canonical spelling throw the new + `error_code::invalid_chain_key`. Surrounding ASCII whitespace is trimmed + and tolerated. `chain::from_nss` stays lenient and still extracts `nt`, + `ci` and the optional `ct` from any NSS. +- **Strict `ct` grammar.** Coin-type values parse as plain decimal or + `0x`/`0X`-prefixed hex only: `0o`/`0b` prefixes, digit-group underscores, + signs and a bare `0x` are rejected, and a leading zero is plain decimal + (`060` == 60, never octal). +- **Printable-ASCII values.** Every NSS value must consist of printable + ASCII (0x21–0x7E) after ASCII trimming: control bytes, interior whitespace + and non-ASCII bytes (incl. Unicode spaces) throw + `parse_error(invalid_nss)` instead of being silently normalised. +- **Validated free-form setters.** `set_address_prefix` / `set_address_suffix` + / `set_wallet_type` / `set_wallet_id` reject values containing `:`, `?`, + `#` or anything outside printable ASCII with the new + `error_code::invalid_value` (empty still resets). The + `address(chain, path, aa, af, ap, as)` constructor routes its params + through the same setters, so invalid constructor input throws too. +- **Network-type values renamed** to the commonly accepted network names + (constant identifiers unchanged): `bitcoin` (was `btc`), `avalanche` + (was `avm`), `tron` (was `tvm`), `solana` (was `sol`), `xrpl` (was `xrp`), + `stellar` (was `xlm`), `aptos` (was `apt`), `cardano` (was `ada`), + `algorand` (was `algo`). `evm`, `cosmos`, `near`, `sui`, `ton` are + unchanged. There are no aliases: the old short names are invalid. +- `coins::atom` fixed to 118 (was 168, which SLIP-44 assigns to + Helleniccoin); 118 also matches the coin level of CIP-11 paths. + +### Added + +- **Wallet domain** on `address`: free-form `wt` (wallet type, e.g. `web3`, + `tonconnect`) and `wi` (wallet instance id) components, each independently + optional, emitted last in the canonical NSS and orthogonal to strict + validation. API: `wallet_type()` / `wallet_id()` accessors and + `set_wallet_type` / `set_wallet_id` setters (empty string resets). +- Coin-type registry extended with 34 SLIP-44 entries (etc, bch, eos, icp, + ckb, zil, luna, dot, ksm, kava, fil, cspr, egld, scrt, flow, vet, rune, + ftm, one, xtz, hype, hbar, move, stx, bera, xch, strk, mina, wax, kas, + osmo, sei, inj, mon); the list is ordered ascending by index. +- `error_code::invalid_value` — raised by the free-form component setters + (ap/as/wt/wi) and the address constructor on NSS-corrupting values. + +### Removed + +- `error_code::missing_coin_type` — `ct` is never required anymore. + +### Documentation / tests + +- SPEC.md and README brought in lockstep with the Go reference (grammar 1.1, + wallet domain, chain API, charset and value-validation rules, error table). +- Test corpus mirrors the Go fixtures: new wallet-domain suite, strict + chain-key suite, optional-ct semantics, updated hash reference vectors for + the new canonical form, and the post-review hardening suite (canonical-only + chain keys, ct spellings, printable-ASCII enforcement, setter validation, + case-preservation, coin-registry spot checks); 139 test cases total. + +## [1.0.0] — 2026-04-27 Initial public release. C++17 port of the [go-mhda](https://github.com/censync/go-mhda) reference implementation, mirroring its parser, validator, derivation-path support and hash surface. +Shipped as tag v1.0.0; the in-tree version markers of that tree still read +0.1.0. ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index 9743209..a413a50 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.14) project(mhda - VERSION 0.1.0 + VERSION 1.1.0 DESCRIPTION "MultiChain Hierarchical Deterministic Address (MHDA) — C++ port of go-mhda" LANGUAGES CXX ) diff --git a/README.md b/README.md index 3897227..5cdaea5 100644 --- a/README.md +++ b/README.md @@ -12,28 +12,37 @@ MHDA is a URN-based descriptor for blockchain HD addresses, with [RFC 8141](https://datatracker.ietf.org/doc/rfc8141/) compatibility. A single string captures everything needed to identify a derived address: -network, derivation scheme, path, signature curve, encoding format and any -prefix/suffix conventions. +network, derivation scheme, path, signature curve, encoding format, any +prefix/suffix conventions, and an optional wallet context. ``` -urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32m:ap:bc1p +urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32m:ap:bc1p ``` -Supported networks: Bitcoin, EVM, Avalanche, Tron, Cosmos, Solana, XRP, +Supported networks: Bitcoin, EVM, Avalanche, Tron, Cosmos, Solana, XRP Ledger, Stellar, NEAR, Aptos, Sui, Cardano, Algorand, TON. +The chain identity is the `(nt, ci)` pair; the SLIP-44 coin type is an +optional `ct` metadata component (for HD addresses the coin already lives in +the derivation path). The optional wallet domain (`wt`/`wi`) binds an address +to a client type and a wallet instance: + +``` +urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0:wt:web3:wi:5f2a8c31 +``` + ## Status -- Version: **1.0.0** +- Version: **1.1.0** - Standard: **C++17**, no external runtime dependencies -- Tests: **71** unit + fuzz-equivalent stress cases (≈11 000 randomised +- Tests: **139** unit + fuzz-equivalent stress cases (≈11 000 randomised iterations), passing under `-fsanitize=address,undefined,leak` - Compilers verified: GCC 11.4 (Ubuntu 22.04), Clang 14 (when libstdc++ is available); the CI matrix runs Linux + macOS, Release + Debug - Warning policy: clean under `-Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-conversion -Werror` -- API surface frozen at **0.1.0**; binary stability is not yet guaranteed - across pre-1.0 minor versions +- Binary stability is not yet guaranteed across minor versions; 1.1.0 + changes the URN grammar (see [CHANGELOG.md](./CHANGELOG.md)) ## Building @@ -69,7 +78,7 @@ target_link_libraries(my_app PRIVATE mhda::mhda) include(FetchContent) FetchContent_Declare(mhda GIT_REPOSITORY https://github.com/censync/mhda.git - GIT_TAG v1.0.0 + GIT_TAG v1.1.0 ) FetchContent_MakeAvailable(mhda) target_link_libraries(my_app PRIVATE mhda::mhda) @@ -85,7 +94,7 @@ int main() { using namespace mhda; // Lenient parsing: structural validation only. - auto addr = parse_urn("urn:mhda:nt:evm:ct:60:ci:1"); + auto addr = parse_urn("urn:mhda:nt:evm:ci:1"); std::cout << addr.get_chain().network().str() << "\n"; // evm std::cout << addr.resolved_algorithm().str() << "\n"; // secp256k1 std::cout << addr.resolved_format().str() << "\n"; // hex @@ -93,7 +102,7 @@ int main() { // Strict parsing also checks the (network, algorithm, format, derivation) // combination is in the known-good compatibility matrix. try { - parse_urn_strict("urn:mhda:nt:evm:ct:60:ci:1:aa:ed25519"); + parse_urn_strict("urn:mhda:nt:evm:ci:1:aa:ed25519"); } catch (const parse_error& e) { if (e.code() == error_code::incompatible) { std::cout << "evm + ed25519 rejected, as expected\n"; @@ -101,7 +110,7 @@ int main() { } // Type-agnostic level-by-level path inspection. - auto bip = parse_urn("urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0"); + auto bip = parse_urn("urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0"); for (const auto& lvl : bip.path()->levels()) { std::cout << " " << lvl.index << (lvl.is_hardened ? "'" : "") << "\n"; } @@ -109,10 +118,16 @@ int main() { // Hashing for content-addressing or deduplication. std::cout << bip.hash256() << "\n"; // SHA-256 hex - // The chain-domain triple (nt, ct, ci) is itself a parseable key. - auto key = bip.get_chain().key(); // "nt:evm:ct:60:ci:1" + // The chain identity (nt, ci) is itself a parseable key. Keys never carry + // the optional ct metadata; a pre-1.1 key with ct fails loudly with + // error_code::coin_type_in_chain_key. + auto key = bip.get_chain().key(); // "nt:evm:ci:1" auto parsed = chain::from_key(key); (void)parsed; + + // Optional wallet context: client type + wallet instance id. + auto wallet = parse_urn("urn:mhda:nt:evm:ci:1:wt:web3:wi:5f2a8c31"); + std::cout << wallet.wallet_type() << " " << wallet.wallet_id() << "\n"; } ``` @@ -122,16 +137,18 @@ A runnable version is in [`examples/basic.cpp`](./examples/basic.cpp). | Network | Example URN | |----------------|------------------------------------------------------------------------------------------------------| -| Ethereum | `urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0` | -| Bitcoin (BIP86)| `urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32m:ap:bc1p` | -| Solana | `urn:mhda:nt:sol:ct:501:ci:mainnet:dt:slip10:dp:m/44'/501'/0'/0'` | -| Stellar | `urn:mhda:nt:xlm:ct:148:ci:mainnet:dt:slip10:dp:m/44'/148'/0'` | -| Sui (ed25519) | `urn:mhda:nt:sui:ct:784:ci:mainnet:dt:slip10:dp:m/44'/784'/0'/0'/0'` | -| Cardano | `urn:mhda:nt:ada:ct:1815:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/0'/0/0` | -| Algorand | `urn:mhda:nt:algo:ct:283:ci:mainnet` (non-HD) | -| TON | `urn:mhda:nt:ton:ct:607:ci:mainnet` (non-HD, friendly base64url default) | -| Cosmos | `urn:mhda:nt:cosmos:ct:118:ci:cosmoshub:dt:cip11:dp:m/44'/118'/0'/0/0` | -| EVM short form | `urn:mhda:nt:evm:ct:60:ci:1` (defaults: bip44, secp256k1, hex) | +| Ethereum | `urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0` | +| Bitcoin (BIP86)| `urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32m:ap:bc1p` | +| Solana | `urn:mhda:nt:solana:ci:mainnet:dt:slip10:dp:m/44'/501'/0'/0'` | +| Stellar | `urn:mhda:nt:stellar:ci:mainnet:dt:slip10:dp:m/44'/148'/0'` | +| Sui (ed25519) | `urn:mhda:nt:sui:ci:mainnet:dt:slip10:dp:m/44'/784'/0'/0'/0'` | +| Cardano | `urn:mhda:nt:cardano:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/0'/0/0` | +| Algorand | `urn:mhda:nt:algorand:ci:mainnet` (non-HD) | +| TON | `urn:mhda:nt:ton:ci:mainnet` (non-HD, friendly base64url default) | +| Cosmos | `urn:mhda:nt:cosmos:ci:cosmoshub:dt:cip11:dp:m/44'/118'/0'/0/0` | +| EVM short form | `urn:mhda:nt:evm:ci:1` (defaults: secp256k1, hex) | +| With metadata | `urn:mhda:nt:evm:ci:1:ct:60` (optional SLIP-44 annotation) | +| Wallet-bound | `urn:mhda:nt:ton:ci:mainnet:wt:tonconnect:wi:c0a8f2d4-3b6e-4a51-9c7d-2f8e1a0b5c93` | ## API mapping (Go → C++) @@ -141,10 +158,14 @@ A runnable version is in [`examples/basic.cpp`](./examples/basic.cpp). | `mhda.ParseURNStrict` | `mhda::parse_urn_strict` | | `mhda.ParseNSS` | `mhda::parse_nss` | | `mhda.ChainFromKey` / `FromNSS` | `mhda::chain::from_key` / `from_nss` | -| `mhda.NewChain(...)` | `mhda::chain{...}` | +| `mhda.NewChain(nt, ci)` | `mhda::chain{nt, ci}` | +| `Chain.SetCoinType` / `ClearCoinType` | `chain::set_coin` / `chain::clear_coin` | +| `Chain.CoinType` + `HasCoinType` | `chain::coin` (`std::optional`) | | `mhda.ParseDerivationPath` | `mhda::derivation_path::parse` | | `mhda.NewDerivationPathFromLevels`| `mhda::derivation_path::from_levels` | | `Address.String()` / `NSS()` | `address::str` / `address::nss` | +| `Address.WalletType` / `WalletId` | `address::wallet_type` / `wallet_id` | +| `Address.SetWalletType` / `SetWalletId` | `address::set_wallet_type` / `set_wallet_id` | | `Address.MarshalText` | `address::marshal_text` | | `Address.UnmarshalText` | `address::unmarshal_text` | | `Address.Hash` / `Hash256` | `address::hash` / `hash256` | @@ -160,9 +181,11 @@ Sentinel constants: | `ErrInvalidNSS` | `error_code::invalid_nss` | | `ErrMissingNetworkType` | `error_code::missing_network_type` | | `ErrInvalidNetworkType` | `error_code::invalid_network_type` | -| `ErrMissingCoinType` | `error_code::missing_coin_type` | | `ErrInvalidCoinType` | `error_code::invalid_coin_type` | | `ErrMissingChainID` | `error_code::missing_chain_id` | +| `ErrCoinTypeInChainKey` | `error_code::coin_type_in_chain_key` | +| `ErrInvalidChainKey` | `error_code::invalid_chain_key` | +| `ErrInvalidValue` | `error_code::invalid_value` | | `ErrInvalidDerivationType` | `error_code::invalid_derivation_type` | | `ErrInvalidDerivationPath` | `error_code::invalid_derivation_path` | | `ErrInvalidAlgorithm` | `error_code::invalid_algorithm` | @@ -191,7 +214,7 @@ Mirrors the [SPEC §8](./SPEC.md#8-concurrency) contract. ## Testing & validation -- 71 unit + fuzz-equivalent test cases. +- 139 unit + fuzz-equivalent test cases. - Fuzz harness runs ≈11 000 randomised mutations of the historical Go-fuzz seed corpus per execution (URN, NSS and derivation-path entry points). Contracts verified: no exception other than `parse_error`/`std::invalid_argument`, diff --git a/SPEC.md b/SPEC.md index e0a1be0..a287470 100644 --- a/SPEC.md +++ b/SPEC.md @@ -22,38 +22,51 @@ produced by either implementation parses and round-trips through the other. ### 1.1 Syntax ``` -urn:mhda:nt::ct::ci::dt::dp::aa::af::ap::as: +urn:mhda:nt::ci::ct::dt::dp::aa::af::ap::as::wt::wi: ``` The `urn:` and the NID `mhda` are case-insensitive (RFC 8141 §5.1). Component keys (`nt`, `dt`, etc.) are lowercase by convention and are accepted only in -that form. The canonical serialised form is fully lowercase. +that form. Enum-valued components (`nt`, `dt`, `aa`, `af`) normalise to +lowercase in the canonical form; free-form values (`ci`, `ap`, `as`, `wt`, +`wi`) are case-preserving and round-trip verbatim. ### 1.2 Components | Key | Name | Required | Type | Default | |------|---------------------|----------|--------|--------------------------| | `nt` | Network type | yes | string | - | -| `ct` | Coin type (SLIP-44) | yes | uint32 | - | | `ci` | Chain ID | yes | string | - | +| `ct` | Coin type (SLIP-44) | no | uint32 | none (metadata) | | `dt` | Derivation type | no | string | `root` | | `dp` | Derivation path | no | string | empty (when `dt=root`) | | `aa` | Address algorithm | no | string | per-network default | | `af` | Address format | no | string | per-network default | | `ap` | Address prefix | no | string | none | | `as` | Address suffix | no | string | none | +| `wt` | Wallet type | no | string | none | +| `wi` | Wallet ID | no | string | none | Optional components are emitted in canonical output ONLY when explicitly set. A short input form round-trips back to the same short form; a long form round-trips back to the same long form. +The chain identity is the `(nt, ci)` pair. The `ct` component is OPTIONAL +SLIP-44 coin-type metadata: it never participates in the chain identity or in +chain keys (see §1.6 and §10). For HD addresses the coin type is already +carried by the derivation domain (`dt` + `dp`), so `ct` exists purely as an +annotation for consumers that need the registry value without parsing a path +(and for non-HD networks that have no path at all). + ### 1.3 Required ordering -Component order in the canonical form is chain-domain first -(`nt`, `ct`, `ci`), then derivation domain (`dt`, `dp`), then optional -address-format metadata (`aa`, `af`, `ap`, `as`). The chain-domain prefix -`nt::ct::ci:` is itself a valid NSS chain key -returned by `Chain.String()` and consumed by `ChainFromKey` / `ChainFromNSS`. +Component order in the canonical form is the chain identity first +(`nt`, `ci`) — so a chain key is always a strict prefix of the NSS — then the +optional coin-type metadata (`ct`), the derivation domain (`dt`, `dp`), +address-format metadata (`aa`, `af`, `ap`, `as`), and the wallet domain +(`wt`, `wi`) last. The chain-identity prefix `nt::ci:` is +itself a valid NSS chain key returned by `Chain.String()` and consumed by +`ChainFromKey` / `ChainFromNSS`. Parsers MUST accept any ordering on input, and the reference implementation does so. @@ -67,16 +80,38 @@ interpreted; they are stripped before parsing: - `?=` (query) - `#` (fragment) -Example: `urn:mhda:nt:evm:ct:60:ci:1?+resolver=example.com#sec` parses +Example: `urn:mhda:nt:evm:ci:1?+resolver=example.com#sec` parses identically to the bare URN; `String()` does not preserve these elements. ### 1.5 Charset NSS values must consist of ASCII characters allowed by RFC 8141 NSS production -(`pchar / "/"` per RFC 3986). The reference implementation does not currently -percent-encode and rejects unescaped whitespace inside values. If a value -needs to contain `:` (currently no in-tree value does), percent-encoding -support must be added. +(`pchar / "/"` per RFC 3986). The reference implementation enforces printable +ASCII (0x21–0x7E) for every value: control bytes, whitespace of any kind and +non-ASCII bytes are rejected. Only ASCII whitespace is trimmed around the URN +and around values — a Unicode space is malformed input, never decoration to +strip. Percent-encoding is not implemented; if a value needs to contain `:` +(currently no in-tree value does), percent-encoding support must be added. + +### 1.6 Wallet domain + +The wallet domain binds an address to a wallet context. Both components are +free-form strings under the §1.5 charset rule, independently optional (either +may appear without the other), and orthogonal to validation — they carry no +compatibility semantics. + +- `wt` (wallet type): the client or protocol the address is exposed through, + e.g. `web3`, `metamask`, `tonconnect`. +- `wi` (wallet id): an identifier binding the address to a concrete wallet + instance, e.g. a UUID or an HD root key fingerprint. + +When set, the wallet domain participates in the serialised NSS (and therefore +in the NSS hashes, §9); it is never part of the chain key. + +Because these values are typically client-supplied, the setters validate them +against NSS-corrupting characters: a value may not contain `:` (component +injection on re-parse), `?` or `#` (RFC 8141 r/q/f truncation), or +whitespace. The same rule applies to the free-form `ap` / `as` values. ## 2. Network Catalogue @@ -88,26 +123,33 @@ derivation type to be members of the corresponding set. ROOT (no derivation path) is permitted on every network and represents the non-HD form. -| `nt` | SLIP-44 | Algorithms | Formats | Derivations | Default `aa` | Default `af` | -|--------|---------|-------------------------------------------|-------------------------------------------------------|----------------------------------------|--------------|--------------| -| btc | 0 | secp256k1 | p2pkh, p2sh, p2wpkh, p2wsh, p2tr, bech32, bech32m | bip32, bip44, bip49, bip84, bip86 | secp256k1 | - | -| evm | 60 | secp256k1 | hex | bip32, bip44 | secp256k1 | hex | -| avm | 9000 | secp256k1 | hex, bech32 | bip44 | secp256k1 | - | -| tvm | 195 | secp256k1 | base58 | bip44 | secp256k1 | base58 | -| cosmos | 118 | secp256k1, ed25519 | bech32 | bip44, cip11 | secp256k1 | bech32 | -| sol | 501 | ed25519 | base58 | slip10 | ed25519 | base58 | -| xrp | 144 | secp256k1, ed25519 | base58 | bip44 | secp256k1 | base58 | -| xlm | 148 | ed25519 | strkey | slip10 | ed25519 | strkey | -| near | 397 | ed25519, secp256k1 | hex | slip10, bip44 | ed25519 | hex | -| apt | 637 | ed25519, secp256k1 | hex | slip10, bip44 | ed25519 | hex | -| sui | 784 | ed25519, secp256k1, secp256r1 | hex | slip10, bip54, bip74 | ed25519 | hex | -| ada | 1815 | ed25519 | bech32, base58 | cip1852 | ed25519 | bech32 | -| algo | 283 | ed25519 | base32 | slip10 | ed25519 | base32 | -| ton | 607 | ed25519 | base64url, hex | slip10 | ed25519 | base64url | +The SLIP-44 column documents the registry value conventionally carried by the +optional `ct` metadata; it is informative, not part of the chain identity. + +| `nt` | SLIP-44 | Algorithms | Formats | Derivations | Default `aa` | Default `af` | +|-----------|---------|-------------------------------------------|-------------------------------------------------------|----------------------------------------|--------------|--------------| +| bitcoin | 0 | secp256k1 | p2pkh, p2sh, p2wpkh, p2wsh, p2tr, bech32, bech32m | bip32, bip44, bip49, bip84, bip86 | secp256k1 | - | +| evm | 60 | secp256k1 | hex | bip32, bip44 | secp256k1 | hex | +| avalanche | 9000 | secp256k1 | hex, bech32 | bip44 | secp256k1 | - | +| tron | 195 | secp256k1 | base58 | bip44 | secp256k1 | base58 | +| cosmos | 118 | secp256k1, ed25519 | bech32 | bip44, cip11 | secp256k1 | bech32 | +| solana | 501 | ed25519 | base58 | slip10 | ed25519 | base58 | +| xrpl | 144 | secp256k1, ed25519 | base58 | bip44 | secp256k1 | base58 | +| stellar | 148 | ed25519 | strkey | slip10 | ed25519 | strkey | +| near | 397 | ed25519, secp256k1 | hex | slip10, bip44 | ed25519 | hex | +| aptos | 637 | ed25519, secp256k1 | hex | slip10, bip44 | ed25519 | hex | +| sui | 784 | ed25519, secp256k1, secp256r1 | hex | slip10, bip54, bip74 | ed25519 | hex | +| cardano | 1815 | ed25519 | bech32, base58 | cip1852 | ed25519 | bech32 | +| algorand | 283 | ed25519 | base32 | slip10 | ed25519 | base32 | +| ton | 607 | ed25519 | base64url, hex | slip10 | ed25519 | base64url | + +Network-type values are the commonly accepted network names, lowercase. +`evm` is the exception by design: it covers many independent networks, so it +keeps the family name rather than any flagship chain's name. ### 2.1 Per-network notes -#### Bitcoin (`btc`) +#### Bitcoin (`bitcoin`) Multiple legitimate scripts; format must be specified explicitly under strict validation. `ap` is conventionally `1` (P2PKH), `3` (P2SH), `bc1q` (bech32), `bc1p` (bech32m / Taproot). @@ -116,7 +158,7 @@ validation. `ap` is conventionally `1` (P2PKH), `3` (P2SH), `bc1q` (bech32), Single canonical format (hex). Chain ID typically a numeric chain ID such as `1`, `0xa86a`, `0x10`. -#### Avalanche (`avm`) +#### Avalanche (`avalanche`) C-Chain uses hex (EVM-compatible); X/P-Chain use bech32 with HRPs `X-avax`, `P-avax`. No default format because both are legitimate. @@ -124,15 +166,15 @@ C-Chain uses hex (EVM-compatible); X/P-Chain use bech32 with HRPs `X-avax`, Cosmos chains traditionally use BIP-44 with coin type 118; some deployments register their own SLIP-44 entries. CIP-11 is `m/44'/118'/account'/charge_extra/address`. -#### Solana (`sol`) +#### Solana (`solana`) SLIP-10 ed25519 only. Common path forms: `m/44'/501'`, `m/44'/501'/account'`, `m/44'/501'/account'/0'` (Phantom / Solflare convention). -#### XRP Ledger (`xrp`) +#### XRP Ledger (`xrpl`) secp256k1 is the historical default; ed25519 is supported by newer wallets. Addresses use base58 with XRPL's custom alphabet (start with `r`). -#### Stellar (`xlm`) +#### Stellar (`stellar`) SEP-0005 mandates SLIP-10 ed25519 with `m/44'/148'/account'` (3 levels, all hardened). StrKey is base32 of `version_byte || payload || CRC16-XMODEM` per SEP-0023. Common version bytes: `G` (account), `S` (seed), `M` (muxed), @@ -143,7 +185,7 @@ ed25519-implicit accounts use raw 64-char hex of pubkey. ETH-implicit accounts use `0x` + 40 hex from secp256k1 + keccak256. Named accounts (`alice.near`) are not HD-derived and not represented by MHDA. -#### Aptos (`apt`) +#### Aptos (`aptos`) ed25519 path `m/44'/637'/account'/change'/index'` (all 5 hardened, enforced by aptos-ts-sdk). secp256k1 path `m/44'/637'/account'/change/index` (BIP-44). @@ -159,14 +201,14 @@ secp256r1: m/74'/784'/account'/change/index (BIP-32, purpose=74') Address = Blake2b-256(flag || pubkey), 0x + 64 hex chars. Flag bytes: 0x00 ed25519, 0x01 secp256k1, 0x02 secp256r1. -#### Cardano (`ada`) +#### Cardano (`cardano`) BIP32-Ed25519 (extended keys with soft-derivation, distinct from SLIP-10 ed25519). Canonical HD path is CIP-1852: `m/1852'/1815'/account'/role/index`. Roles per CIP-1852: 0=external, 1=internal, 2=staking, 3=DRep, 4=cc-cold, 5=cc-hot. Shelley addresses use bech32 (no BIP-173 length cap, per CIP-19); Byron-era addresses use base58 and remain on-chain. -#### Algorand (`algo`) +#### Algorand (`algorand`) Native scheme is non-HD: a 25-word BIP-39-style mnemonic encodes the 32-byte ed25519 seed directly. Canonical URNs use `dt:root`. Some third-party wallets layer SLIP-10 at `m/44'/283'/account'/0'/0'`; this is accepted but is @@ -262,7 +304,10 @@ Performs structural validation only: - All component values non-empty. - No duplicate component keys. - Network type is one of the registered values. -- Coin type is a valid 32-bit unsigned integer (decimal or `0x`-prefixed hex). +- Coin type, if present, is a valid 32-bit unsigned integer, spelled as + plain decimal or `0x`-prefixed hex only (no other integer-literal forms). +- Values contain no whitespace (interior whitespace is rejected; surrounding + whitespace around the whole URN is trimmed). - Chain ID is non-empty. - Derivation type, if present, is one of the registered constants. - Derivation path, if present, matches the regex of its derivation type. @@ -305,9 +350,11 @@ text. | `ErrInvalidNSS` | Malformed namespace-specific string | | `ErrMissingNetworkType` | `nt` absent | | `ErrInvalidNetworkType` | `nt` value not registered | -| `ErrMissingCoinType` | `ct` absent | -| `ErrInvalidCoinType` | `ct` not a uint32 | +| `ErrInvalidCoinType` | `ct` present but not a uint32 | | `ErrMissingChainID` | `ci` absent | +| `ErrCoinTypeInChainKey` | `ChainFromKey` input carries `ct` (pre-1.1 key format) | +| `ErrInvalidChainKey` | `ChainFromKey` input is not the canonical identity form | +| `ErrInvalidValue` | Free-form setter value with `:`/`?`/`#`/whitespace | | `ErrInvalidDerivationType` | `dt` value not registered | | `ErrInvalidDerivationPath` | `dp` does not match the regex of `dt` | | `ErrInvalidAlgorithm` | `aa` value not registered | @@ -344,26 +391,44 @@ Two pairs of hash methods are exposed on `Address`: | `Hash256` | SHA-256 | hex (64 chars) | | `NSSHash256` | SHA-256 | hex (64 chars) | -`Hash` and `NSSHash` are retained for backward compatibility with existing -identifiers. SHA-1 is no longer collision-resistant; new uses should prefer -`Hash256` / `NSSHash256`. +`Hash` and `NSSHash` are kept only so identifiers generated by this version +of the format stay stable. SHA-1 is no longer collision-resistant; new uses +should prefer `Hash256` / `NSSHash256`. ## 10. Chain API -The chain-domain triple `(nt, ct, ci)` is exposed as a standalone `Chain` -type with its own factory functions: +The chain identity pair `(nt, ci)` is exposed as a standalone `Chain` type +with its own factory functions: | Constructor | Purpose | |----------------------------|----------------------------------------------------------| -| `NewChain(nt, ct, ci)` | Programmatic construction. | -| `ChainFromNSS(s string)` | Parse a chain-only NSS form `nt:X:ct:Y:ci:Z`. | +| `NewChain(nt, ci)` | Programmatic construction. | +| `ChainFromNSS(s string)` | Extract the chain domain from any NSS (lenient). | | `ChainFromKey(key)` | Parse a `ChainKey` (alias of string) produced by `Key()`.| `Chain.String()` and `Chain.Key()` both return the canonical chain key -`nt::ct::ci:`, suitable for use as a map key, cache -key or content hash input. The format is a strict prefix of any full URN -NSS, so callers can extract a chain key from a `*Address` via -`addr.Chain().Key()`. +`nt::ci:`, suitable for use as a map key, cache key or +content hash input. The format is a strict prefix of any full URN NSS, so +callers can extract a chain key from a `*Address` via `addr.Chain().Key()`. + +The two parsing paths differ in strictness: + +- `ChainFromNSS` accepts any NSS (including a full address form) and extracts + `nt`, `ci` and the optional `ct` metadata, ignoring everything else. +- `ChainFromKey` accepts the canonical identity form only. An input + carrying `ct` is rejected with `ErrCoinTypeInChainKey` — a chain key in + the pre-1.1 format must fail loudly, never be silently reinterpreted. + Anything else that is not byte-identical to the canonical + `nt::ci:` rendering (other known components, unknown + tokens, reordering, non-canonical case) is rejected with + `ErrInvalidChainKey`: keys compare as plain strings, so every accepted + input must be the canonical string. Surrounding whitespace is trimmed. + +The optional SLIP-44 metadata is attached programmatically via +`Chain.SetCoinType` / cleared via `Chain.ClearCoinType`, and read via +`Chain.CoinType()` / `Chain.HasCoinType()`. It never affects the key. +(C++: `chain::set_coin` / `chain::clear_coin`; `chain::coin()` returns a +`std::optional` that covers both reads.) ## 11. Codec Integration @@ -380,6 +445,9 @@ valid input (lenient mode). A `database/sql` adapter is not currently provided; callers can wrap `MarshalText`/`UnmarshalText` in their own `driver.Valuer` / `sql.Scanner`. +The C++ port exposes the same pair as `address::marshal_text` / +`address::unmarshal_text` for integration with any text-based codec. + ## 12. Known Limitations - **Polkadot / Substrate** is not registered as a network type. The native diff --git a/examples/basic.cpp b/examples/basic.cpp index 924e663..439e4f8 100644 --- a/examples/basic.cpp +++ b/examples/basic.cpp @@ -8,14 +8,14 @@ int main() { using namespace mhda; // Lenient parsing: structural validation only. - auto addr = parse_urn("urn:mhda:nt:evm:ct:60:ci:1"); + auto addr = parse_urn("urn:mhda:nt:evm:ci:1"); std::cout << "network : " << addr.get_chain().network().str() << "\n"; std::cout << "algorithm : " << addr.resolved_algorithm().str() << "\n"; std::cout << "format : " << addr.resolved_format().str() << "\n"; // Strict parsing rejects nonsensical (network, algorithm, format) combos. try { - parse_urn_strict("urn:mhda:nt:evm:ct:60:ci:1:aa:ed25519"); + parse_urn_strict("urn:mhda:nt:evm:ci:1:aa:ed25519"); } catch (const parse_error& e) { if (e.code() == error_code::incompatible) { std::cout << "evm + ed25519 rejected, as expected\n"; @@ -23,7 +23,7 @@ int main() { } // Type-agnostic level-by-level path inspection. - auto bip = parse_urn("urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0"); + auto bip = parse_urn("urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0"); int i = 0; for (const auto& lvl : bip.path()->levels()) { std::cout << "level[" << i++ << "] = " << lvl.index @@ -33,10 +33,17 @@ int main() { // Hashing for content-addressing or deduplication. std::cout << "sha256 : " << bip.hash256() << "\n"; - // The chain-domain triple is itself a parseable key. + // The chain identity (nt, ci) is itself a parseable key. Keys never carry + // the optional ct metadata; a pre-1.1 key with ct fails loudly with + // error_code::coin_type_in_chain_key. auto key = bip.get_chain().key(); auto parsed = chain::from_key(key); std::cout << "chain key : " << parsed.str() << "\n"; + // Optional wallet context: client type + wallet instance id. + auto wallet = parse_urn("urn:mhda:nt:evm:ci:1:wt:web3:wi:5f2a8c31"); + std::cout << "wallet : " << wallet.wallet_type() + << " " << wallet.wallet_id() << "\n"; + return 0; } diff --git a/examples/by_key.cpp b/examples/by_key.cpp index cfd93f7..2808e10 100644 --- a/examples/by_key.cpp +++ b/examples/by_key.cpp @@ -1,15 +1,16 @@ // by_key — a small command-line tool that takes an MHDA URN as its argument // (or reads one per line from stdin) and prints a structured breakdown of -// the descriptor: chain triple, derivation path with level-by-level view, -// resolved algorithm/format defaults, prefix/suffix metadata and the four -// hash flavours. It also runs strict validation and reports the result. +// the descriptor: chain identity plus coin-type metadata, derivation path +// with level-by-level view, resolved algorithm/format defaults, prefix/suffix +// and wallet-domain metadata and the four hash flavours. It also runs strict +// validation and reports the result. // // Build: // cmake --build build --target mhda_by_key // // Examples: -// ./build/examples/mhda_by_key 'urn:mhda:nt:evm:ct:60:ci:1' -// echo 'urn:mhda:nt:sol:ct:501:ci:mainnet' | ./build/examples/mhda_by_key +// ./build/examples/mhda_by_key 'urn:mhda:nt:evm:ci:1' +// echo 'urn:mhda:nt:solana:ci:mainnet' | ./build/examples/mhda_by_key #include #include @@ -46,7 +47,13 @@ int describe(std::ostream& out, const std::string& urn) { << "nss : " << addr.nss() << "\n" << "------- chain --------\n" << " network : " << addr.get_chain().network().str() << "\n" - << " coin (slip): " << addr.get_chain().coin() << "\n" + << " coin (slip): "; + if (addr.get_chain().coin()) { + out << *addr.get_chain().coin(); + } else { + out << "(unset)"; + } + out << "\n" << " chain id : " << addr.get_chain().id() << "\n" << " key : " << addr.get_chain().key() << "\n"; @@ -85,6 +92,12 @@ int describe(std::ostream& out, const std::string& urn) { if (!addr.prefix().empty()) out << " prefix : " << addr.prefix() << "\n"; if (!addr.suffix().empty()) out << " suffix : " << addr.suffix() << "\n"; + if (!addr.wallet_type().empty() || !addr.wallet_id().empty()) { + out << "------- wallet ------\n"; + if (!addr.wallet_type().empty()) out << " type : " << addr.wallet_type() << "\n"; + if (!addr.wallet_id().empty()) out << " id : " << addr.wallet_id() << "\n"; + } + out << "------- hashes ------\n" << " sha1(urn) : " << addr.hash() << "\n" << " sha1(nss) : " << addr.nss_hash() << "\n" diff --git a/include/mhda/address.hpp b/include/mhda/address.hpp index 0724adb..83ad07d 100644 --- a/include/mhda/address.hpp +++ b/include/mhda/address.hpp @@ -13,8 +13,9 @@ namespace mhda { -// address is the full MHDA address descriptor: a chain triple plus an optional -// derivation path plus optional address-format metadata. +// address is the full MHDA address descriptor: a chain identity plus an +// optional derivation path plus optional address-format metadata and an +// optional wallet domain. // // All Set* methods modify the receiver in place and are NOT safe for concurrent // use; callers must synchronise externally if mutating from multiple threads. @@ -25,6 +26,11 @@ class address { address() = default; address(chain c, std::optional path) : chain_(std::move(c)), path_(std::move(path)) {} + // The string params (aa, af, ap, as) are routed through the corresponding + // setters, so they are validated exactly like parsed input: the + // constructor throws parse_error on an invalid value (invalid_algorithm / + // invalid_format for aa/af, invalid_value for an NSS-corrupting ap/as). + // Empty strings are treated as unset. address(chain c, std::optional path, std::string algorithm, @@ -55,6 +61,14 @@ class address { const std::string& prefix() const noexcept { return prefix_; } const std::string& suffix() const noexcept { return suffix_; } + // wallet_type returns the optional wallet-domain type ("wt"), e.g. "web3", + // "metamask", "tonconnect". Empty when unset. + const std::string& wallet_type() const noexcept { return wallet_type_; } + + // wallet_id returns the optional wallet-domain instance id ("wi"), e.g. a + // UUID or an HD root key fingerprint. Empty when unset. + const std::string& wallet_id() const noexcept { return wallet_id_; } + // set_derivation_type sets the path's derivation type; an empty string // resolves to ROOT. Allocates a fresh derivation_path if none was attached. void set_derivation_type(std::string_view dt); @@ -63,17 +77,34 @@ class address { // derivation type is ROOT this is a silent no-op. Throws parse_error. void set_derivation_path(std::string_view dp); - // set_coin_type sets the chain's coin type from a decimal or "0x"-prefixed - // hex string. Throws parse_error on invalid input. + // set_coin_type sets the chain's optional coin-type metadata from a + // decimal or "0x"-prefixed hex string. An empty string clears the + // metadata. Throws parse_error on invalid input. void set_coin_type(std::string_view ct); // set_address_algorithm / _format / _prefix / _suffix mirror the Go API: // an empty string resets the field; non-empty values are validated. + // Free-form ap/as values must be printable ASCII (0x21–0x7E) with none of + // ':', '?', '#' — anything else throws parse_error(invalid_value) without + // mutating the field. void set_address_algorithm(std::string_view aa); void set_address_format(std::string_view af); void set_address_prefix(std::string_view ap); void set_address_suffix(std::string_view as); + // set_wallet_type sets the optional wallet-domain type ("wt"), a free-form + // string such as "web3", "metamask" or "tonconnect". An empty string + // resets it. The wallet domain carries no compatibility semantics. + // The wallet domain carries client-supplied identifiers, so values are + // validated against NSS-corrupting characters (printable ASCII only, no + // ':', '?', '#'); an invalid value throws parse_error(invalid_value). + void set_wallet_type(std::string_view wt); + + // set_wallet_id sets the optional wallet-domain instance id ("wi"), a + // free-form string such as a UUID or an HD root key fingerprint. An empty + // string resets it. Validated like set_wallet_type. + void set_wallet_id(std::string_view wi); + // str returns the canonical "urn:mhda:..." form of this address. std::string str() const; @@ -111,6 +142,8 @@ class address { format format_; std::string prefix_; std::string suffix_; + std::string wallet_type_; + std::string wallet_id_; }; } // namespace mhda diff --git a/include/mhda/chain.hpp b/include/mhda/chain.hpp index b507148..51a27c8 100644 --- a/include/mhda/chain.hpp +++ b/include/mhda/chain.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -13,51 +14,80 @@ namespace mhda { using chain_id = std::string; // chain_key is the canonical NSS-style chain identifier -// "nt::ct::ci:" returned by chain::key() and consumed -// by chain::from_key() / chain::from_nss(). +// "nt::ci:" returned by chain::key() and consumed by +// chain::from_key() / chain::from_nss(). using chain_key = std::string; -// chain represents the chain-domain triple (nt, ct, ci) of an MHDA address. -// All Set* methods modify the receiver in place and are NOT safe for concurrent -// use; callers must synchronise externally if mutating from multiple threads. +// chain describes a network: the network type plus the chain id. The pair +// (nt, ci) is the chain identity; key() and str() serialize exactly that pair, +// and chain keys compare as plain strings. +// +// An optional SLIP-44 coin type may be attached as metadata (set_coin). It is +// carried by the full URN form (the "ct" component) but is NOT part of the +// chain identity: it never appears in key()/str() and never participates in +// equality. +// +// All set_* methods modify the receiver in place and are NOT safe for +// concurrent use; callers must synchronise externally if mutating from +// multiple threads. class chain { public: chain() = default; - chain(network_type nt, coin_type ct, chain_id ci) - : network_(std::move(nt)), coin_(ct), chain_id_(std::move(ci)) {} + chain(network_type nt, chain_id ci) + : network_(std::move(nt)), chain_id_(std::move(ci)) {} - // from_key parses a chain key produced by chain::key(). + // from_key parses a chain key produced by chain::key(). A chain key is + // the canonical identity form "nt::ci:" and nothing + // else. "ct" (the pre-1.1 key format) is rejected with the dedicated + // error_code::coin_type_in_chain_key so that legacy keys fail loudly + // instead of being silently reinterpreted; any other known component and + // any residue (unknown tokens, reordering, non-canonical spelling) is + // rejected with error_code::invalid_chain_key — keys compare as plain + // strings, so every accepted input must BE the canonical string. + // Surrounding ASCII whitespace is trimmed and tolerated. static chain from_key(std::string_view key); - // from_nss parses just the chain-domain components ("nt", "ct", "ci") - // from an NSS string. Other components are tolerated and ignored. + // from_nss parses the chain-domain components ("nt", "ci" and the + // optional "ct" metadata) from the given NSS string. Other components are + // tolerated and ignored, so a full address NSS is valid input. static chain from_nss(std::string_view nss); void set_network(network_type nt) { network_ = std::move(nt); } - void set_coin(coin_type ct) { coin_ = ct; } void set_chain_id(chain_id ci) { chain_id_ = std::move(ci); } + // set_coin attaches the optional SLIP-44 coin-type metadata. + void set_coin(coin_type ct) { coin_ = ct; } + + // clear_coin removes the optional SLIP-44 coin-type metadata. + void clear_coin() { coin_.reset(); } + const network_type& network() const noexcept { return network_; } - coin_type coin() const noexcept { return coin_; } - const chain_id& id() const noexcept { return chain_id_; } + const chain_id& id() const noexcept { return chain_id_; } + + // coin returns the optional SLIP-44 coin-type metadata, or an empty + // optional when unset. An explicit 0 (Bitcoin) is distinct from "not set". + const std::optional& coin() const noexcept { return coin_; } - // key returns the canonical NSS-style chain key - // "nt::ct::ci:". Suitable as a map key, cache key - // or content-hash input. + // key returns the canonical NSS-style chain key "nt::ci:". + // Suitable as a map key, cache key or content-hash input. chain_key key() const { return str(); } - // str returns the canonical chain key form (alias of key()). + // str returns the canonical chain key form (alias of key()). The optional + // coin-type metadata is deliberately excluded: the chain identity is the + // (network type, chain id) pair. std::string str() const; + // Equality compares the chain identity (network type, chain id) only; the + // optional coin-type metadata never participates. bool operator==(const chain& other) const noexcept { - return network_ == other.network_ && coin_ == other.coin_ && chain_id_ == other.chain_id_; + return network_ == other.network_ && chain_id_ == other.chain_id_; } bool operator!=(const chain& other) const noexcept { return !(*this == other); } private: - network_type network_; - coin_type coin_ = 0; - chain_id chain_id_; + network_type network_; + chain_id chain_id_; + std::optional coin_; }; } // namespace mhda diff --git a/include/mhda/coin_type.hpp b/include/mhda/coin_type.hpp index ed894d2..5db5ae1 100644 --- a/include/mhda/coin_type.hpp +++ b/include/mhda/coin_type.hpp @@ -7,7 +7,9 @@ namespace mhda { // CoinType is a SLIP-44 coin type (32-bit unsigned integer). using coin_type = std::uint32_t; -// Pre-registered SLIP-44 constants matching go-mhda/coin_type.go. +// SLIP-44 registered coin types, ascending by index +// (https://github.com/satoshilabs/slips/blob/master/slip-0044.md). +// Matches go-mhda/coin_type.go. namespace coins { constexpr coin_type btc = 0; @@ -15,25 +17,61 @@ constexpr coin_type ltc = 2; constexpr coin_type doge = 3; constexpr coin_type dash = 5; constexpr coin_type eth = 60; +constexpr coin_type etc = 61; +// Cosmos Hub. The registry assigns 118 to ATOM (168 belongs to +// Helleniccoin); 118 also matches the coin level of CIP-11 paths. +constexpr coin_type atom = 118; constexpr coin_type xmr = 128; constexpr coin_type zec = 133; constexpr coin_type xrp = 144; +constexpr coin_type bch = 145; constexpr coin_type xlm = 148; -constexpr coin_type atom = 168; +constexpr coin_type eos = 194; constexpr coin_type trx = 195; +constexpr coin_type icp = 223; constexpr coin_type algo = 283; +constexpr coin_type ckb = 309; +constexpr coin_type zil = 313; +constexpr coin_type luna = 330; +constexpr coin_type dot = 354; constexpr coin_type near = 397; +constexpr coin_type ksm = 434; +constexpr coin_type kava = 459; +constexpr coin_type fil = 461; constexpr coin_type sol = 501; +constexpr coin_type cspr = 506; +constexpr coin_type egld = 508; +constexpr coin_type scrt = 529; +constexpr coin_type flow = 539; constexpr coin_type ton = 607; constexpr coin_type apt = 637; -constexpr coin_type bnb = 714; -constexpr coin_type matic = 966; +constexpr coin_type bnb = 714; // BNB Beacon Chain (old style); BSC uses 9006 constexpr coin_type sui = 784; -constexpr coin_type ada = 1815; +constexpr coin_type vet = 818; +constexpr coin_type rune = 931; +constexpr coin_type matic = 966; +constexpr coin_type ftm = 1007; +constexpr coin_type one = 1023; constexpr coin_type glmr = 1284; -constexpr coin_type bsc = 9006; +constexpr coin_type xtz = 1729; +constexpr coin_type ada = 1815; +constexpr coin_type hype = 2457; +constexpr coin_type hbar = 3030; +constexpr coin_type move = 3073; +constexpr coin_type stx = 5757; +constexpr coin_type bera = 8008; +constexpr coin_type xch = 8444; // https://support.avax.network/en/articles/7004986-what-derivation-paths-does-avalanche-use constexpr coin_type avax = 9000; +constexpr coin_type strk = 9004; +constexpr coin_type bsc = 9006; +constexpr coin_type mina = 12586; +constexpr coin_type wax = 14001; +constexpr coin_type kas = 111111; +constexpr coin_type osmo = 10000118; +constexpr coin_type sei = 19000118; +constexpr coin_type inj = 22000119; +constexpr coin_type mon = 268435779; } // namespace coins diff --git a/include/mhda/error.hpp b/include/mhda/error.hpp index 7c7b339..d11d21a 100644 --- a/include/mhda/error.hpp +++ b/include/mhda/error.hpp @@ -13,7 +13,6 @@ enum class error_code { invalid_nss, missing_network_type, invalid_network_type, - missing_coin_type, invalid_coin_type, missing_chain_id, invalid_derivation_type, @@ -22,6 +21,20 @@ enum class error_code { invalid_format, incompatible, uninitialized_address, + // invalid_value is raised by the free-form component setters (ap/as/wt/wi) + // when a value contains characters that would corrupt the serialised NSS: + // the ':' separator, the RFC 8141 r/q/f delimiters ('?', '#') or anything + // outside printable ASCII (whitespace of any kind, control bytes). + invalid_value, + // coin_type_in_chain_key is raised by chain::from_key when the input + // carries a "ct" component. Chain keys are the bare identity + // "nt::ci:"; a key with "ct" is the pre-1.1 format and must + // be regenerated. + coin_type_in_chain_key, + // invalid_chain_key is raised by chain::from_key when the input is not + // the canonical identity form "nt::ci:": any other known + // component, unknown tokens, reordering or non-canonical spelling. + invalid_chain_key, }; const char* error_message(error_code c) noexcept; diff --git a/include/mhda/mhda.hpp b/include/mhda/mhda.hpp index 1c12d5d..d8f7c0f 100644 --- a/include/mhda/mhda.hpp +++ b/include/mhda/mhda.hpp @@ -10,12 +10,13 @@ // // Format: // -// urn:mhda:nt::ct::ci::dt::dp::aa::af::ap::as: +// urn:mhda:nt::ci::ct::dt::dp::aa::af::ap::as::wt::wi: // -// Only nt, ct and ci are required. Optional fields aa/af/ap/as are emitted on -// str() only when explicitly set, preserving short-form round-trip. The -// chain-domain prefix nt:X:ct:Y:ci:Z is itself a valid chain key returned by -// chain::key() and consumed by chain::from_key / chain::from_nss. +// Only nt and ci are required — the (nt, ci) pair is the chain identity. The +// SLIP-44 coin type (ct) is optional metadata; ct/dt/dp/aa/af/ap/as/wt/wi are +// emitted on str() only when explicitly set, preserving short-form +// round-trip. The chain-identity prefix nt:X:ci:Y is itself a valid chain key +// returned by chain::key() and consumed by chain::from_key / chain::from_nss. #include "mhda/address.hpp" #include "mhda/algorithm.hpp" diff --git a/include/mhda/network_type.hpp b/include/mhda/network_type.hpp index c35e419..6b01b89 100644 --- a/include/mhda/network_type.hpp +++ b/include/mhda/network_type.hpp @@ -6,9 +6,9 @@ namespace mhda { -// network_type wraps a registered MHDA network identifier (e.g. "evm", "btc", -// "sol"). Comparison and hashing use the canonical lowercase string. Defaults -// to an empty (invalid) value. +// network_type wraps a registered MHDA network identifier (e.g. "evm", +// "bitcoin", "solana"). Comparison and hashing use the canonical lowercase +// string. Defaults to an empty (invalid) value. class network_type { public: network_type() = default; diff --git a/src/address.cpp b/src/address.cpp index e91f4d1..08dfc44 100644 --- a/src/address.cpp +++ b/src/address.cpp @@ -22,6 +22,24 @@ void append_uint32(std::string& out, std::uint32_t v) { for (int i = n - 1; i >= 0; --i) out.push_back(tmp[i]); } +// validate_free_form_value guards the case-preserving free-form components +// (ap/as/wt/wi) against characters that would corrupt the serialised NSS: +// the ':' component separator would inject foreign components on re-parse, +// '?' / '#' would truncate the URN at the RFC 8141 r/q/f delimiters, and +// anything outside printable ASCII (whitespace of any kind, control bytes, +// Unicode) cannot appear in a conforming NSS at all. Mirrors the Go +// reference's validateFreeFormValue. +void validate_free_form_value(std::string_view component, std::string_view v) { + for (char c : v) { + const auto b = static_cast(c); + if (b < 0x21 || b > 0x7e || c == ':' || c == '?' || c == '#') { + throw parse_error(error_code::invalid_value, + std::string{"\""} + std::string{v} + "\" for \"" + + std::string{component} + "\""); + } + } +} + } // namespace address::address(chain c, @@ -82,7 +100,8 @@ void address::set_derivation_path(std::string_view dp) { void address::set_coin_type(std::string_view ct) { auto trimmed = detail::trim(ct); if (trimmed.empty()) { - throw parse_error(error_code::missing_coin_type); + chain_.clear_coin(); + return; } std::uint32_t v = 0; if (!detail::parse_uint32(trimmed, v)) { @@ -123,25 +142,51 @@ void address::set_address_format(std::string_view af) { } void address::set_address_prefix(std::string_view ap) { - prefix_ = std::string{detail::trim(ap)}; + auto trimmed = detail::trim(ap); + validate_free_form_value(detail::comp_address_prefix, trimmed); + prefix_ = std::string{trimmed}; } void address::set_address_suffix(std::string_view as) { - suffix_ = std::string{detail::trim(as)}; + auto trimmed = detail::trim(as); + validate_free_form_value(detail::comp_address_suffix, trimmed); + suffix_ = std::string{trimmed}; +} + +void address::set_wallet_type(std::string_view wt) { + auto trimmed = detail::trim(wt); + validate_free_form_value(detail::comp_wallet_type, trimmed); + wallet_type_ = std::string{trimmed}; } +void address::set_wallet_id(std::string_view wi) { + auto trimmed = detail::trim(wi); + validate_free_form_value(detail::comp_wallet_id, trimmed); + wallet_id_ = std::string{trimmed}; +} + +// nss returns the URN namespace-specific string in canonical form. The +// emission order is the chain identity (nt/ci) first — so the chain key is a +// strict prefix of the NSS — then the optional coin-type metadata (ct), the +// derivation domain (dt/dp), address-format metadata (aa/af/ap/as), and the +// wallet domain (wt/wi) last. Optional components are emitted only when +// explicitly set, preserving the round-trip with short input forms. std::string address::nss() const { std::string out; out.reserve(64); - // Chain domain — always present. + // Chain identity — always present. out += "nt:"; out += chain_.network().str(); - out += ":ct:"; - append_uint32(out, chain_.coin()); out += ":ci:"; out += chain_.id(); + // Coin-type metadata — emitted only when explicitly set. + if (chain_.coin()) { + out += ":ct:"; + append_uint32(out, *chain_.coin()); + } + // Derivation domain — present when not ROOT and a non-empty type is set. if (path_ && !path_->type().empty() && path_->type() != derivation_type::root) { out += ":dt:"; @@ -167,6 +212,16 @@ std::string address::nss() const { out += ":as:"; out += suffix_; } + + // Wallet domain — emitted only when explicitly set. + if (!wallet_type_.empty()) { + out += ":wt:"; + out += wallet_type_; + } + if (!wallet_id_.empty()) { + out += ":wi:"; + out += wallet_id_; + } return out; } diff --git a/src/chain.cpp b/src/chain.cpp index 4ed7cd5..45091af 100644 --- a/src/chain.cpp +++ b/src/chain.cpp @@ -10,8 +10,9 @@ namespace mhda { namespace { -// build_chain extracts the (nt, ct, ci) triple from a parsed NSS component -// map. Shared by chain::from_nss and the address parser. +// build_chain extracts the chain identity (nt, ci) and the optional coin-type +// metadata (ct) from a parsed NSS component map. Shared by chain::from_nss, +// chain::from_key and the address parser. chain build_chain(const std::unordered_map& m) { auto nt_it = m.find(std::string{detail::comp_network_type}); if (nt_it == m.end()) { @@ -27,20 +28,6 @@ chain build_chain(const std::unordered_map& m) { std::string{"\""} + nt_norm + "\""); } - auto ct_it = m.find(std::string{detail::comp_coin_type}); - if (ct_it == m.end()) { - throw parse_error(error_code::missing_coin_type); - } - const auto ct_trim = detail::trim(ct_it->second); - if (ct_trim.empty()) { - throw parse_error(error_code::missing_coin_type); - } - coin_type ct = 0; - if (!detail::parse_uint32(ct_trim, ct)) { - throw parse_error(error_code::invalid_coin_type, - std::string{"\""} + std::string{ct_trim} + "\""); - } - auto ci_it = m.find(std::string{detail::comp_chain_id}); if (ci_it == m.end()) { throw parse_error(error_code::missing_chain_id); @@ -50,7 +37,20 @@ chain build_chain(const std::unordered_map& m) { throw parse_error(error_code::missing_chain_id); } - return chain{std::move(nt), ct, std::string{ci_trim}}; + chain out{std::move(nt), std::string{ci_trim}}; + + auto ct_it = m.find(std::string{detail::comp_coin_type}); + if (ct_it != m.end()) { + const auto ct_trim = detail::trim(ct_it->second); + coin_type ct = 0; + if (!detail::parse_uint32(ct_trim, ct)) { + throw parse_error(error_code::invalid_coin_type, + std::string{"\""} + std::string{ct_trim} + "\""); + } + out.set_coin(ct); + } + + return out; } } // namespace @@ -70,26 +70,35 @@ chain chain::from_nss(std::string_view nss) { } chain chain::from_key(std::string_view key) { - return from_nss(key); -} - -namespace { - -void append_uint32(std::string& out, std::uint32_t v) { - if (v == 0) { out.push_back('0'); return; } - char tmp[16]; - int n = 0; - while (v) { tmp[n++] = char('0' + (v % 10)); v /= 10; } - for (int i = n - 1; i >= 0; --i) out.push_back(tmp[i]); + const auto trimmed = detail::trim(key); + auto components = detail::parse_nss_map(trimmed); + if (components.find(std::string{detail::comp_coin_type}) != components.end()) { + throw parse_error(error_code::coin_type_in_chain_key); + } + for (const auto& kv : components) { + if (kv.first != detail::comp_network_type && kv.first != detail::comp_chain_id) { + throw parse_error(error_code::invalid_chain_key, + std::string{"unexpected component \""} + kv.first + "\""); + } + } + if (components.find(std::string{detail::comp_network_type}) == components.end()) { + throw parse_error(error_code::missing_network_type); + } + chain out = build_chain(components); + // Keys compare as plain strings, so every accepted input must BE the + // canonical string: unknown tokens, reordering and non-canonical spelling + // are rejected, not silently normalised. + if (out.str() != trimmed) { + throw parse_error(error_code::invalid_chain_key, + std::string{"not in canonical form: \""} + + std::string{trimmed} + "\""); + } + return out; } -} // namespace - std::string chain::str() const { std::string out = "nt:"; out += network_.str(); - out += ":ct:"; - append_uint32(out, coin_); out += ":ci:"; out += chain_id_; return out; diff --git a/src/detail.hpp b/src/detail.hpp index c663db1..792b29a 100644 --- a/src/detail.hpp +++ b/src/detail.hpp @@ -1,6 +1,5 @@ #pragma once -#include #include #include #include @@ -13,12 +12,22 @@ inline char ascii_lower(char c) noexcept { return (c >= 'A' && c <= 'Z') ? char(c + 32) : c; } -// trim removes leading and trailing ASCII whitespace. +// is_ascii_space matches exactly the six ASCII whitespace characters +// (" \t\n\v\f\r"), mirroring Go's asciiTrim cutset. Deliberately not +// std::isspace: that is locale-sensitive, and Unicode spaces (NBSP, +// ideographic space, ...) must NOT count as whitespace — an NSS is ASCII by +// definition (RFC 8141), so a Unicode space is not decoration to strip; it +// stays in place and the value charset check rejects it loudly. +inline bool is_ascii_space(char c) noexcept { + return c == ' ' || c == '\t' || c == '\n' || c == '\v' || c == '\f' || c == '\r'; +} + +// trim removes leading and trailing ASCII whitespace only (see is_ascii_space). inline std::string_view trim(std::string_view s) noexcept { std::size_t i = 0; - while (i < s.size() && std::isspace(static_cast(s[i]))) ++i; + while (i < s.size() && is_ascii_space(s[i])) ++i; std::size_t j = s.size(); - while (j > i && std::isspace(static_cast(s[j - 1]))) --j; + while (j > i && is_ascii_space(s[j - 1])) --j; return s.substr(i, j - i); } @@ -73,9 +82,14 @@ inline std::vector split(std::string_view s, char sep) { return out; } -// parse_uint32 parses a decimal or "0x"-prefixed hex string into a uint32_t. -// Returns false on invalid input or 32-bit overflow. Mirrors Go's -// strconv.ParseUint(s, 0, 32). +// parse_uint32 parses the two documented "ct" spellings — plain decimal or +// "0x"/"0X"-prefixed hex — into a uint32_t. Returns false on invalid input or +// 32-bit overflow. Mirrors Go's parseCoinType: Go integer-literal extras +// (0o/0b prefixes, digit-group underscores, signs) are deliberately rejected, +// a leading zero is plain decimal ("060" == 60, never octal), and a bare "0x" +// with no digits is invalid. Allowing several spellings of one value would +// defeat duplicate detection and the canonical-form guarantees. Used only by +// the coin-type ("ct") parsing paths. bool parse_uint32(std::string_view s, std::uint32_t& out) noexcept; // parse_uint32_dec parses a strictly decimal uint32_t. Used by per-level diff --git a/src/error.cpp b/src/error.cpp index 5a68754..e121aa9 100644 --- a/src/error.cpp +++ b/src/error.cpp @@ -8,7 +8,6 @@ const char* error_message(error_code c) noexcept { case error_code::invalid_nss: return "mhda: cannot parse nss"; case error_code::missing_network_type: return "mhda: \"nt\" is required"; case error_code::invalid_network_type: return "mhda: invalid \"nt\""; - case error_code::missing_coin_type: return "mhda: \"ct\" is required"; case error_code::invalid_coin_type: return "mhda: invalid \"ct\""; case error_code::missing_chain_id: return "mhda: \"ci\" is required"; case error_code::invalid_derivation_type: return "mhda: invalid \"dt\""; @@ -17,6 +16,9 @@ const char* error_message(error_code c) noexcept { case error_code::invalid_format: return "mhda: invalid \"af\""; case error_code::incompatible: return "mhda: incompatible network/algorithm/format"; case error_code::uninitialized_address: return "mhda: address is not initialized"; + case error_code::invalid_value: return "mhda: invalid component value"; + case error_code::coin_type_in_chain_key: return "mhda: \"ct\" is not allowed in a chain key"; + case error_code::invalid_chain_key: return "mhda: not a valid chain key"; } return "mhda: unknown error"; } diff --git a/src/network_type.cpp b/src/network_type.cpp index 96cd093..a72eb90 100644 --- a/src/network_type.cpp +++ b/src/network_type.cpp @@ -6,39 +6,42 @@ namespace mhda { -const network_type network_type::bitcoin {"btc"}; +// Network types use the commonly accepted network names, lowercase. Family +// types that cover a single ecosystem carry that ecosystem's name (tron, +// avalanche); "evm" stays as-is because it covers many independent networks. +const network_type network_type::bitcoin {"bitcoin"}; const network_type network_type::ethereum_vm {"evm"}; -const network_type network_type::avalanche_vm {"avm"}; -const network_type network_type::tron_vm {"tvm"}; +const network_type network_type::avalanche_vm {"avalanche"}; +const network_type network_type::tron_vm {"tron"}; const network_type network_type::cosmos {"cosmos"}; -const network_type network_type::solana {"sol"}; -const network_type network_type::xrp_ledger {"xrp"}; -const network_type network_type::stellar {"xlm"}; +const network_type network_type::solana {"solana"}; +const network_type network_type::xrp_ledger {"xrpl"}; +const network_type network_type::stellar {"stellar"}; const network_type network_type::near_protocol {"near"}; -const network_type network_type::aptos {"apt"}; +const network_type network_type::aptos {"aptos"}; const network_type network_type::sui {"sui"}; -const network_type network_type::cardano {"ada"}; -const network_type network_type::algorand {"algo"}; +const network_type network_type::cardano {"cardano"}; +const network_type network_type::algorand {"algorand"}; const network_type network_type::toncoin {"ton"}; namespace { const std::unordered_map& nt_index() { static const std::unordered_map index = { - {"btc", network_type::bitcoin}, - {"evm", network_type::ethereum_vm}, - {"avm", network_type::avalanche_vm}, - {"tvm", network_type::tron_vm}, - {"cosmos", network_type::cosmos}, - {"sol", network_type::solana}, - {"xrp", network_type::xrp_ledger}, - {"xlm", network_type::stellar}, - {"near", network_type::near_protocol}, - {"apt", network_type::aptos}, - {"sui", network_type::sui}, - {"ada", network_type::cardano}, - {"algo", network_type::algorand}, - {"ton", network_type::toncoin}, + {"bitcoin", network_type::bitcoin}, + {"evm", network_type::ethereum_vm}, + {"avalanche", network_type::avalanche_vm}, + {"tron", network_type::tron_vm}, + {"cosmos", network_type::cosmos}, + {"solana", network_type::solana}, + {"xrpl", network_type::xrp_ledger}, + {"stellar", network_type::stellar}, + {"near", network_type::near_protocol}, + {"aptos", network_type::aptos}, + {"sui", network_type::sui}, + {"cardano", network_type::cardano}, + {"algorand", network_type::algorand}, + {"ton", network_type::toncoin}, }; return index; } diff --git a/src/nss_parser.cpp b/src/nss_parser.cpp index 2bd3d4c..714a01b 100644 --- a/src/nss_parser.cpp +++ b/src/nss_parser.cpp @@ -12,10 +12,11 @@ namespace { const std::unordered_set& known_components() { static const std::unordered_set set = { - comp_network_type, comp_coin_type, comp_chain_id, + comp_network_type, comp_chain_id, comp_coin_type, comp_derivation_type, comp_derivation_path, comp_address_algorithm, comp_address_format, comp_address_prefix, comp_address_suffix, + comp_wallet_type, comp_wallet_id, }; return set; } @@ -41,14 +42,26 @@ std::unordered_map parse_nss_map(std::string_view nss) throw parse_error(error_code::invalid_nss, std::string{"missing value for \""} + std::string{key} + "\""); } - // RFC 8141 NSS does not permit unescaped whitespace; trim it so any - // trailing space (e.g. from "ci:0 #frag" where strip_rqf leaves the - // space) does not leak into the canonical form and break round-trip. + // RFC 8141 NSS does not permit unescaped whitespace; trim ASCII + // whitespace so any trailing space (e.g. from "ci:0 #frag" where + // strip_rqf leaves the space) does not leak into the canonical form + // and break round-trip. auto value = trim(parts[i + 1]); if (value.empty()) { throw parse_error(error_code::invalid_nss, std::string{"empty value for \""} + std::string{key} + "\""); } + // Everything that survives the trim must be printable ASCII (SPEC + // §1.5) — interior whitespace, control bytes and Unicode spaces are + // all malformed input, never silently normalised. + for (char c : value) { + const auto b = static_cast(c); + if (b < 0x21 || b > 0x7e) { + throw parse_error(error_code::invalid_nss, + std::string{"non-ASCII or control byte in value for \""} + + std::string{key} + "\""); + } + } std::string key_str{key}; if (out.count(key_str)) { throw parse_error(error_code::invalid_nss, diff --git a/src/nss_parser.hpp b/src/nss_parser.hpp index 2b02e1f..d2ccc6c 100644 --- a/src/nss_parser.hpp +++ b/src/nss_parser.hpp @@ -11,21 +11,32 @@ namespace detail { // by the Go reference implementation. constexpr std::string_view prefix_mhda = "urn:mhda:"; +// Chain domain. The chain identity is the (nt, ci) pair; ct is the OPTIONAL +// SLIP-44 coin-type metadata (never part of the identity). constexpr std::string_view comp_network_type = "nt"; -constexpr std::string_view comp_coin_type = "ct"; constexpr std::string_view comp_chain_id = "ci"; +constexpr std::string_view comp_coin_type = "ct"; +// Derivation path domain. constexpr std::string_view comp_derivation_type = "dt"; constexpr std::string_view comp_derivation_path = "dp"; +// Address format domain. constexpr std::string_view comp_address_algorithm = "aa"; constexpr std::string_view comp_address_format = "af"; constexpr std::string_view comp_address_prefix = "ap"; constexpr std::string_view comp_address_suffix = "as"; +// Wallet domain (optional). comp_wallet_type is a free-form wallet/client +// type, e.g. "web3", "metamask", "tonconnect"; comp_wallet_id is a free-form +// wallet instance identifier, e.g. a UUID or an HD root key fingerprint. +constexpr std::string_view comp_wallet_type = "wt"; +constexpr std::string_view comp_wallet_id = "wi"; bool is_known_component(std::string_view key) noexcept; // parse_nss splits an NSS string into a component map. Unknown keys are -// silently skipped (forward-compat with future URN extensions); duplicate keys -// and empty values throw parse_error(invalid_nss). +// silently skipped (forward-compat with future URN extensions); duplicate +// keys, empty values, and values containing anything outside printable ASCII +// (0x21–0x7E: control bytes, whitespace of any kind, non-ASCII bytes) throw +// parse_error(invalid_nss). Values are ASCII-trimmed before the checks. // // Form: a sequence of `key:value` pairs joined by `:` separators. Values may // not contain ':'; this holds for every component currently defined. diff --git a/src/parser.cpp b/src/parser.cpp index 84c7928..6498732 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -28,6 +28,8 @@ address parse_address_from_components(const std::unordered_map::max()); - EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ct:4294967295:ci:1"}); + auto a = parse_urn("urn:mhda:nt:evm:ci:1:ct:4294967295"); + EXPECT_TRUE(a.get_chain().coin().has_value()); + EXPECT_EQ(*a.get_chain().coin(), std::numeric_limits::max()); + EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1:ct:4294967295"}); } TEST_CASE("coin_type accepts uint32 max (hex)") { - auto a = parse_urn("urn:mhda:nt:evm:ct:0xFFFFFFFF:ci:1"); - EXPECT_EQ(a.get_chain().coin(), std::numeric_limits::max()); + auto a = parse_urn("urn:mhda:nt:evm:ci:1:ct:0xFFFFFFFF"); + EXPECT_TRUE(a.get_chain().coin().has_value()); + EXPECT_EQ(*a.get_chain().coin(), std::numeric_limits::max()); // Canonical str() emits decimal. - EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ct:4294967295:ci:1"}); + EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1:ct:4294967295"}); } TEST_CASE("coin_type rejects uint32 max + 1 (decimal)") { - EXPECT_THROW_CODE(parse_urn("urn:mhda:nt:evm:ct:4294967296:ci:1"), + EXPECT_THROW_CODE(parse_urn("urn:mhda:nt:evm:ci:1:ct:4294967296"), error_code::invalid_coin_type); } TEST_CASE("coin_type rejects uint32 max + 1 (hex)") { - EXPECT_THROW_CODE(parse_urn("urn:mhda:nt:evm:ct:0x100000000:ci:1"), + EXPECT_THROW_CODE(parse_urn("urn:mhda:nt:evm:ci:1:ct:0x100000000"), error_code::invalid_coin_type); } TEST_CASE("coin_type rejects bare 0x with no digits") { - EXPECT_THROW_CODE(parse_urn("urn:mhda:nt:evm:ct:0x:ci:1"), + EXPECT_THROW_CODE(parse_urn("urn:mhda:nt:evm:ci:1:ct:0x"), error_code::invalid_coin_type); } TEST_CASE("coin_type rejects negative sign") { - EXPECT_THROW_CODE(parse_urn("urn:mhda:nt:evm:ct:-1:ci:1"), + EXPECT_THROW_CODE(parse_urn("urn:mhda:nt:evm:ci:1:ct:-1"), error_code::invalid_coin_type); } TEST_CASE("coin_type rejects leading plus") { - EXPECT_THROW_CODE(parse_urn("urn:mhda:nt:evm:ct:+1:ci:1"), + EXPECT_THROW_CODE(parse_urn("urn:mhda:nt:evm:ci:1:ct:+1"), error_code::invalid_coin_type); } TEST_CASE("derivation_path leaf accepts uint32 max") { auto a = parse_urn( - "urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44'/60'/0'/0/4294967295"); + "urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/0'/0/4294967295"); EXPECT_EQ(a.path()->index().index, std::numeric_limits::max()); EXPECT_EQ(a.str(), - std::string{"urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44'/60'/0'/0/4294967295"}); + std::string{"urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/0'/0/4294967295"}); } TEST_CASE("derivation_path leaf rejects uint32 max + 1") { EXPECT_THROW_CODE(parse_urn( - "urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44'/60'/0'/0/4294967296"), + "urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/0'/0/4294967296"), error_code::invalid_derivation_path); } TEST_CASE("BIP-44 charge field rejects values outside {0,1}") { EXPECT_THROW_CODE(parse_urn( - "urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44'/60'/0'/2/0"), + "urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/0'/2/0"), error_code::invalid_derivation_path); } TEST_CASE("CIP-11 charge accepts non-{0,1} (Cosmos charge_extra)") { auto a = parse_urn( - "urn:mhda:nt:cosmos:ct:118:ci:cosmoshub:dt:cip11:dp:m/44'/118'/0'/9/0"); + "urn:mhda:nt:cosmos:ci:cosmoshub:ct:118:dt:cip11:dp:m/44'/118'/0'/9/0"); EXPECT_EQ(a.path()->charge(), charge_type{9}); } TEST_CASE("BIP-44 wrong purpose level rejected") { EXPECT_THROW_CODE(parse_urn( - "urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/45'/60'/0'/0/0"), + "urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/45'/60'/0'/0/0"), error_code::invalid_derivation_path); } @@ -150,7 +152,7 @@ TEST_CASE("very long SLIP-10 path (256 levels) round-trips") { TEST_CASE("very long chain_id (4 KiB) round-trips") { std::string ci(4096, 'x'); - std::string urn = "urn:mhda:nt:evm:ct:60:ci:" + ci; + std::string urn = "urn:mhda:nt:evm:ci:" + ci + ":ct:60"; auto a = parse_urn(urn); EXPECT_EQ(a.get_chain().id().size(), 4096u); EXPECT_EQ(a.str(), urn); @@ -159,7 +161,7 @@ TEST_CASE("very long chain_id (4 KiB) round-trips") { TEST_CASE("very long prefix and suffix round-trip") { std::string ap(512, 'p'); std::string as(512, 's'); - std::string urn = "urn:mhda:nt:evm:ct:60:ci:1:ap:" + ap + ":as:" + as; + std::string urn = "urn:mhda:nt:evm:ci:1:ct:60:ap:" + ap + ":as:" + as; auto a = parse_urn(urn); EXPECT_EQ(a.prefix().size(), 512u); EXPECT_EQ(a.suffix().size(), 512u); @@ -170,7 +172,7 @@ TEST_CASE("megabyte URN is parsed without crashing") { // 1 MiB chain_id — guards against accidental quadratic copies in the // canonical-form rebuild path. std::string ci(1u << 20, 'A'); - std::string urn = "urn:mhda:nt:evm:ct:60:ci:" + ci; + std::string urn = "urn:mhda:nt:evm:ci:" + ci; auto a = parse_urn(urn); EXPECT_EQ(a.get_chain().id().size(), std::size_t{1} << 20); // Round-tripping a megabyte string is wasteful in tests; just confirm @@ -183,12 +185,10 @@ TEST_CASE("megabyte URN is parsed without crashing") { // NUL / non-printable bytes // --------------------------------------------------------------------------- -TEST_CASE("embedded NUL byte in chain_id is preserved (no truncation)") { - std::string urn = "urn:mhda:nt:evm:ct:60:ci:abc"; - urn[urn.size() - 2] = '\0'; // "ab\0c" inside ci - auto a = parse_urn(urn); - EXPECT_EQ(a.get_chain().id().size(), 3u); - EXPECT_EQ(a.get_chain().id()[1], '\0'); +TEST_CASE("embedded NUL byte in chain_id is rejected (printable ASCII only)") { + std::string urn = "urn:mhda:nt:evm:ci:abc"; + urn[urn.size() - 2] = '\0'; // "a\0c" inside ci + EXPECT_THROW_CODE(parse_urn(urn), error_code::invalid_nss); } TEST_CASE("input with only the URN prefix is rejected") { @@ -211,21 +211,21 @@ TEST_CASE("single-character noise inputs reject without crash") { // --------------------------------------------------------------------------- TEST_CASE("duplicate components are rejected with invalid_nss") { - EXPECT_THROW_CODE(parse_urn("urn:mhda:nt:evm:nt:btc:ct:60:ci:1"), + EXPECT_THROW_CODE(parse_urn("urn:mhda:nt:evm:nt:bitcoin:ci:1:ct:60"), error_code::invalid_nss); - EXPECT_THROW_CODE(parse_urn("urn:mhda:nt:evm:ct:60:ct:0:ci:1"), + EXPECT_THROW_CODE(parse_urn("urn:mhda:nt:evm:ci:1:ct:60:ct:0"), error_code::invalid_nss); } TEST_CASE("unknown component keys are silently skipped") { // Forward-compat: a future "xx" component must not break parsing of the // surrounding components. - auto a = parse_urn("urn:mhda:nt:evm:ct:60:ci:1:xx:future"); - EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ct:60:ci:1"}); + auto a = parse_urn("urn:mhda:nt:evm:ci:1:ct:60:xx:future"); + EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1:ct:60"}); } TEST_CASE("string_view trim of fully-whitespace value rejects") { - EXPECT_THROW_CODE(parse_urn("urn:mhda:nt: :ct:60:ci:1"), + EXPECT_THROW_CODE(parse_urn("urn:mhda:nt: :ci:1:ct:60"), error_code::invalid_nss); } @@ -270,7 +270,7 @@ TEST_CASE("default-constructed derivation_path serialises empty") { TEST_CASE("default-constructed chain serialises with empty network slot") { chain c; - EXPECT_EQ(c.str(), std::string{"nt::ct:0:ci:"}); + EXPECT_EQ(c.str(), std::string{"nt::ci:"}); } // --------------------------------------------------------------------------- @@ -278,7 +278,7 @@ TEST_CASE("default-constructed chain serialises with empty network slot") { // --------------------------------------------------------------------------- TEST_CASE("set_address_algorithm rejects unknown without mutating") { - auto a = parse_urn("urn:mhda:nt:evm:ct:60:ci:1:aa:secp256k1"); + auto a = parse_urn("urn:mhda:nt:evm:ci:1:ct:60:aa:secp256k1"); bool threw = false; try { a.set_address_algorithm("rsa"); } catch (const parse_error&) { threw = true; } EXPECT_TRUE(threw); @@ -286,7 +286,7 @@ TEST_CASE("set_address_algorithm rejects unknown without mutating") { } TEST_CASE("set_address_format rejects unknown without mutating") { - auto a = parse_urn("urn:mhda:nt:evm:ct:60:ci:1:af:hex"); + auto a = parse_urn("urn:mhda:nt:evm:ci:1:ct:60:af:hex"); bool threw = false; try { a.set_address_format("zzz"); } catch (const parse_error&) { threw = true; } EXPECT_TRUE(threw); @@ -294,15 +294,16 @@ TEST_CASE("set_address_format rejects unknown without mutating") { } TEST_CASE("set_coin_type rejects bad input without mutating") { - auto a = parse_urn("urn:mhda:nt:evm:ct:60:ci:1"); + auto a = parse_urn("urn:mhda:nt:evm:ci:1:ct:60"); bool threw = false; try { a.set_coin_type("not_a_number"); } catch (const parse_error&) { threw = true; } EXPECT_TRUE(threw); - EXPECT_EQ(a.get_chain().coin(), 60u); + EXPECT_TRUE(a.get_chain().coin().has_value()); + EXPECT_EQ(*a.get_chain().coin(), 60u); } TEST_CASE("self-assignment via reference leaves address unchanged") { - auto a = parse_urn("urn:mhda:nt:evm:ct:60:ci:1"); + auto a = parse_urn("urn:mhda:nt:evm:ci:1:ct:60"); const std::string before = a.str(); address& ref = a; a = ref; // routed through a reference to silence -Wself-assign-overloaded @@ -343,13 +344,19 @@ TEST_CASE("chain::from_nss rejects empty input") { } TEST_CASE("chain::from_nss rejects unknown network") { - EXPECT_THROW_CODE(chain::from_nss("nt:xyz:ct:0:ci:any"), + EXPECT_THROW_CODE(chain::from_nss("nt:xyz:ci:any:ct:0"), error_code::invalid_network_type); } -TEST_CASE("chain::from_key with extra components ignores them") { - auto c = chain::from_key("nt:evm:ct:60:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0"); +TEST_CASE("chain::from_key is strict about extra components") { + // The lenient extractor tolerates a full address NSS... + auto c = chain::from_nss("nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/0'/0/0"); EXPECT_EQ(c.network(), network_type::ethereum_vm); EXPECT_EQ(c.id(), std::string{"1"}); - EXPECT_EQ(c.str(), std::string{"nt:evm:ct:60:ci:1"}); + EXPECT_EQ(c.str(), std::string{"nt:evm:ci:1"}); + // ...while the strict key parser rejects anything beyond the identity. + EXPECT_THROW_CODE(chain::from_key("nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0"), + error_code::invalid_chain_key); + EXPECT_THROW_CODE(chain::from_key("nt:evm:ci:1:ct:60"), + error_code::coin_type_in_chain_key); } diff --git a/tests/test_chain.cpp b/tests/test_chain.cpp index 7bb5151..dc78bde 100644 --- a/tests/test_chain.cpp +++ b/tests/test_chain.cpp @@ -1,42 +1,122 @@ +#include +#include + #include "mhda/mhda.hpp" #include "ostream_helpers.hpp" #include "test_framework.hpp" using namespace mhda; +namespace { + +// Mirrors nssChainKey from the Go reference chain tests. +const std::vector kChainKeys = { + "nt:bitcoin:ci:bitcoin", // Bitcoin + "nt:tron:ci:mainnet", // Tron + "nt:evm:ci:0x1", // Ethereum + "nt:evm:ci:0xa86a", // Avalanche +}; + +} // namespace + TEST_CASE("chain.from_nss round-trip") { - const char* keys[] = { - "nt:btc:ct:0:ci:bitcoin", - "nt:tvm:ct:195:ci:mainnet", - "nt:evm:ct:60:ci:0x1", - "nt:evm:ct:60:ci:0xa86a", - }; - for (auto* k : keys) { + for (const auto& k : kChainKeys) { auto c = chain::from_nss(k); - EXPECT_EQ(c.str(), std::string{k}); + EXPECT_EQ(c.str(), k); + } +} + +// The strict chain-key parser: a chain key is the bare identity +// "nt::ci:" and round-trips through key(). +TEST_CASE("chain.from_key round-trip") { + for (const auto& k : kChainKeys) { + auto c = chain::from_key(k); + EXPECT_EQ(c.key(), k); + EXPECT_FALSE(c.coin().has_value()); // keys never carry coin-type metadata + } +} + +// The pre-1.1 key format (with an embedded "ct" component) must fail loudly +// with the dedicated sentinel instead of being silently reinterpreted. +TEST_CASE("chain.from_key rejects coin type") { + const std::vector keys = { + "nt:evm:ct:60:ci:1", // pre-1.1 canonical order + "nt:evm:ci:1:ct:60", // ct trailing + "nt:bitcoin:ct:0:ci:bitcoin", + }; + for (const auto& k : keys) { + EXPECT_THROW_CODE(chain::from_key(k), error_code::coin_type_in_chain_key); + } +} + +// A chain key may not carry derivation, address-format or wallet components. +TEST_CASE("chain.from_key rejects extra components") { + const std::vector keys = { + "nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0", + "nt:evm:ci:1:aa:secp256k1", + "nt:evm:ci:1:wt:web3", + "nt:evm:ci:1:wi:5f2a8c31", + }; + for (const auto& k : keys) { + EXPECT_THROW_CODE(chain::from_key(k), error_code::invalid_chain_key); + } +} + +// The lenient NSS extractor accepts the optional ct metadata (in any position) +// and keeps it off the key. +TEST_CASE("chain.from_nss accepts optional coin type") { + const std::vector inputs = { + "nt:evm:ci:1:ct:60", + "nt:evm:ct:60:ci:1", // pre-1.1 component order still parses as NSS + "nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/0'/0/0", + }; + for (const auto& nss : inputs) { + auto c = chain::from_nss(nss); + EXPECT_TRUE(c.coin().has_value()); + if (c.coin()) EXPECT_EQ(*c.coin(), 60u); + EXPECT_EQ(c.str(), std::string{"nt:evm:ci:1"}); } } TEST_CASE("chain getters chainable from address") { - auto addr = parse_urn("urn:mhda:nt:evm:ct:60:ci:1"); + auto addr = parse_urn("urn:mhda:nt:evm:ci:1:ct:60"); EXPECT_EQ(addr.get_chain().network(), network_type::ethereum_vm); - EXPECT_EQ(addr.get_chain().coin(), coins::eth); + EXPECT_TRUE(addr.get_chain().coin().has_value()); + EXPECT_EQ(*addr.get_chain().coin(), coins::eth); EXPECT_EQ(addr.get_chain().id(), std::string{"1"}); - EXPECT_EQ(addr.get_chain().key(), std::string{"nt:evm:ct:60:ci:1"}); + // The coin-type metadata is excluded from the chain key. + EXPECT_EQ(addr.get_chain().key(), std::string{"nt:evm:ci:1"}); } TEST_CASE("chain setters mutate in place") { - chain c{network_type::ethereum_vm, coins::eth, "1"}; + chain c{network_type::ethereum_vm, "1"}; c.set_network(network_type::bitcoin); c.set_coin(coins::btc); c.set_chain_id("bitcoin"); EXPECT_EQ(c.network(), network_type::bitcoin); - EXPECT_EQ(c.coin(), coins::btc); + EXPECT_TRUE(c.coin().has_value()); + EXPECT_EQ(*c.coin(), coins::btc); EXPECT_EQ(c.id(), std::string{"bitcoin"}); + c.clear_coin(); + EXPECT_FALSE(c.coin().has_value()); } TEST_CASE("chain.str canonical form") { - chain c{network_type::ethereum_vm, coins::eth, "0x1"}; - EXPECT_EQ(c.str(), std::string{"nt:evm:ct:60:ci:0x1"}); - EXPECT_EQ(c.key(), std::string{"nt:evm:ct:60:ci:0x1"}); + chain c{network_type::ethereum_vm, "0x1"}; + const std::string want = "nt:evm:ci:0x1"; + EXPECT_EQ(c.str(), want); + EXPECT_EQ(c.key(), want); + // Attaching coin-type metadata must not change the key. + c.set_coin(coins::eth); + EXPECT_EQ(c.str(), want); + EXPECT_EQ(c.key(), want); +} + +TEST_CASE("chain equality is the (nt, ci) identity") { + chain a{network_type::ethereum_vm, "1"}; + chain b{network_type::ethereum_vm, "1"}; + b.set_coin(coins::eth); // metadata never participates in equality + EXPECT_TRUE(a == b); + chain other{network_type::ethereum_vm, "10"}; + EXPECT_TRUE(a != other); } diff --git a/tests/test_derivation_path.cpp b/tests/test_derivation_path.cpp index ae14219..9c54c9f 100644 --- a/tests/test_derivation_path.cpp +++ b/tests/test_derivation_path.cpp @@ -36,7 +36,7 @@ TEST_CASE("ParseDerivationPath errors") { } TEST_CASE("BIP44 levels view") { - auto addr = parse_urn("urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44'/60'/1'/0/2"); + auto addr = parse_urn("urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/1'/0/2"); std::vector want = { {44, true}, {60, true}, {1, true}, {0, false}, {2, false}, }; @@ -47,7 +47,9 @@ TEST_CASE("BIP44 levels view") { } TEST_CASE("CIP-1852 levels exposed correctly") { - auto addr = parse_urn("urn:mhda:nt:evm:ct:1815:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/3'/2/7"); + // The deliberately mismatched ct (1815 on evm) is fine: ct is unvalidated + // metadata. + auto addr = parse_urn("urn:mhda:nt:evm:ci:mainnet:ct:1815:dt:cip1852:dp:m/1852'/1815'/3'/2/7"); std::vector want = { {1852, true}, {1815, true}, {3, true}, {2, false}, {7, false}, }; @@ -57,7 +59,7 @@ TEST_CASE("CIP-1852 levels exposed correctly") { } TEST_CASE("SLIP-10 mixed hardening round-trip") { - const std::string urn = "urn:mhda:nt:evm:ct:0:ci:mainnet:dt:slip10:dp:m/44'/0'/0'/0/5"; + const std::string urn = "urn:mhda:nt:evm:ci:mainnet:ct:0:dt:slip10:dp:m/44'/0'/0'/0/5"; auto addr = parse_urn(urn); std::vector want = { {44, true}, {0, true}, {0, true}, {0, false}, {5, false}, @@ -100,9 +102,9 @@ TEST_CASE("from_levels ZIP-32 variable length") { TEST_CASE("BIP44 family round-trip") { const char* cases[] = { - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip49:dp:m/49'/0'/0'/0/0:af:p2sh", - "urn:mhda:nt:evm:ct:784:ci:1:dt:bip54:dp:m/54'/784'/0'/0/0", - "urn:mhda:nt:evm:ct:784:ci:1:dt:bip74:dp:m/74'/784'/0'/0/0", + "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip49:dp:m/49'/0'/0'/0/0:af:p2sh", + "urn:mhda:nt:evm:ci:1:ct:784:dt:bip54:dp:m/54'/784'/0'/0/0", + "urn:mhda:nt:evm:ci:1:ct:784:dt:bip74:dp:m/74'/784'/0'/0/0", }; for (auto* in : cases) { auto addr = parse_urn(in); @@ -111,13 +113,13 @@ TEST_CASE("BIP44 family round-trip") { } TEST_CASE("CIP-11 emits coin 118 not 133") { - const std::string in = "urn:mhda:nt:cosmos:ct:118:ci:cosmoshub:dt:cip11:dp:m/44'/118'/3'/0/7"; + const std::string in = "urn:mhda:nt:cosmos:ci:cosmoshub:ct:118:dt:cip11:dp:m/44'/118'/3'/0/7"; auto addr = parse_urn(in); EXPECT_EQ(addr.str(), in); } TEST_CASE("BIP44 hardened leaf round-trip") { - const std::string in = "urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44'/0'/0'/0/0'"; + const std::string in = "urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/0'/0'/0/0'"; auto addr = parse_urn(in); EXPECT_EQ(addr.str(), in); EXPECT_TRUE(addr.path()->is_hardened_address()); @@ -125,9 +127,9 @@ TEST_CASE("BIP44 hardened leaf round-trip") { TEST_CASE("ZIP32 variable-length URN round-trip") { const char* urns[] = { - "urn:mhda:nt:btc:ct:133:ci:zcash:dt:zip32:dp:m/32'/133'/0'", - "urn:mhda:nt:btc:ct:133:ci:zcash:dt:zip32:dp:m/32'/133'/0'/0", - "urn:mhda:nt:btc:ct:133:ci:zcash:dt:zip32:dp:m/32'/133'/0'/0'", + "urn:mhda:nt:bitcoin:ci:zcash:ct:133:dt:zip32:dp:m/32'/133'/0'", + "urn:mhda:nt:bitcoin:ci:zcash:ct:133:dt:zip32:dp:m/32'/133'/0'/0", + "urn:mhda:nt:bitcoin:ci:zcash:ct:133:dt:zip32:dp:m/32'/133'/0'/0'", }; for (auto* in : urns) { auto addr = parse_urn(in); diff --git a/tests/test_edge.cpp b/tests/test_edge.cpp index 723c288..db0bf68 100644 --- a/tests/test_edge.cpp +++ b/tests/test_edge.cpp @@ -10,37 +10,38 @@ using namespace mhda; -TEST_CASE("AVM has no default format and validates without one") { - auto a = parse_urn_strict("urn:mhda:nt:avm:ct:9000:ci:1"); +TEST_CASE("Avalanche has no default format and validates without one") { + auto a = parse_urn_strict("urn:mhda:nt:avalanche:ci:1:ct:9000"); EXPECT_EQ(a.resolved_format(), format{}); // intentionally unset EXPECT_EQ(a.resolved_algorithm(), algorithm::secp256k1); - EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:avm:ct:9000:ci:1"}); + EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:avalanche:ci:1:ct:9000"}); } TEST_CASE("Bitcoin has no default format and validates without one") { - auto a = parse_urn_strict("urn:mhda:nt:btc:ct:0:ci:bitcoin"); + auto a = parse_urn_strict("urn:mhda:nt:bitcoin:ci:bitcoin:ct:0"); EXPECT_EQ(a.resolved_format(), format{}); EXPECT_EQ(a.resolved_algorithm(), algorithm::secp256k1); } TEST_CASE("unmarshal_text replaces state, not merges") { auto a = parse_urn( - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32m:ap:bc1p"); + "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32m:ap:bc1p"); EXPECT_FALSE(a.prefix().empty()); EXPECT_FALSE(a.explicit_format().empty()); - a.unmarshal_text("urn:mhda:nt:evm:ct:60:ci:1"); + a.unmarshal_text("urn:mhda:nt:evm:ci:1:ct:60"); EXPECT_EQ(a.get_chain().network(), network_type::ethereum_vm); - EXPECT_EQ(a.get_chain().coin(), coins::eth); + EXPECT_TRUE(a.get_chain().coin().has_value()); + EXPECT_EQ(*a.get_chain().coin(), coins::eth); EXPECT_TRUE(a.prefix().empty()); // bc1p must be wiped EXPECT_TRUE(a.explicit_format().empty());// bech32m must be wiped EXPECT_EQ(a.get_derivation_type(), derivation_type::root); - EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ct:60:ci:1"}); + EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1:ct:60"}); } TEST_CASE("unmarshal_text rejection leaves receiver intact") { - auto a = parse_urn("urn:mhda:nt:evm:ct:60:ci:1"); + auto a = parse_urn("urn:mhda:nt:evm:ci:1:ct:60"); const std::string before = a.str(); bool threw = false; try { @@ -73,17 +74,17 @@ TEST_CASE("derivation_path::parse with empty type rejects") { } TEST_CASE("set_address_algorithm/format reset trims values") { - auto a = parse_urn("urn:mhda:nt:evm:ct:60:ci:1:aa:secp256k1:af:hex"); + auto a = parse_urn("urn:mhda:nt:evm:ci:1:ct:60:aa:secp256k1:af:hex"); a.set_address_algorithm(" "); // whitespace-only resets a.set_address_format(""); EXPECT_TRUE(a.explicit_algorithm().empty()); EXPECT_TRUE(a.explicit_format().empty()); - EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ct:60:ci:1"}); + EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1:ct:60"}); } TEST_CASE("address copy and move preserve serialised form") { auto orig = parse_urn( - "urn:mhda:nt:cosmos:ct:118:ci:cosmoshub:dt:cip11:dp:m/44'/118'/0'/0/0"); + "urn:mhda:nt:cosmos:ci:cosmoshub:ct:118:dt:cip11:dp:m/44'/118'/0'/0/0"); address copy = orig; EXPECT_EQ(copy.str(), orig.str()); @@ -94,29 +95,30 @@ TEST_CASE("address copy and move preserve serialised form") { TEST_CASE("very long chain_id round-trips intact") { // Cosmos-style chain_id can be arbitrarily long (e.g. axelar-dojo-1). std::string ci(200, 'a'); - std::string urn = "urn:mhda:nt:cosmos:ct:118:ci:" + ci; + std::string urn = "urn:mhda:nt:cosmos:ci:" + ci + ":ct:118"; auto a = parse_urn(urn); EXPECT_EQ(a.get_chain().id(), ci); EXPECT_EQ(a.str(), urn); } TEST_CASE("hardened address index round-trip with 0x form coin type") { - auto a = parse_urn("urn:mhda:nt:evm:ct:0x3c:ci:1"); + auto a = parse_urn("urn:mhda:nt:evm:ci:1:ct:0x3c"); // 0x3c == 60 (decimal); canonical str() emits decimal. - EXPECT_EQ(a.get_chain().coin(), 60u); - EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ct:60:ci:1"}); + EXPECT_TRUE(a.get_chain().coin().has_value()); + EXPECT_EQ(*a.get_chain().coin(), 60u); + EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1:ct:60"}); } TEST_CASE("explicit set_coin via 0x and decimal are equivalent") { - address a{chain{network_type::ethereum_vm, 0, "1"}, std::nullopt}; + address a{chain{network_type::ethereum_vm, "1"}, std::nullopt}; a.set_coin_type("0xa86a"); - EXPECT_EQ(a.get_chain().coin(), 0xa86au); + EXPECT_EQ(*a.get_chain().coin(), 0xa86au); a.set_coin_type("60"); - EXPECT_EQ(a.get_chain().coin(), 60u); + EXPECT_EQ(*a.get_chain().coin(), 60u); } TEST_CASE("optional path() empty for ROOT-form addresses") { - auto a = parse_urn("urn:mhda:nt:evm:ct:60:ci:1"); + auto a = parse_urn("urn:mhda:nt:evm:ci:1:ct:60"); // Path is set (to ROOT) by parse_address_from_components, but the // public derivation_type accessor reports root. EXPECT_EQ(a.get_derivation_type(), derivation_type::root); @@ -124,10 +126,10 @@ TEST_CASE("optional path() empty for ROOT-form addresses") { TEST_CASE("validate accepts ROOT for every registered network") { const std::vector nets = { - "btc:0:bitcoin", "evm:60:1", "avm:9000:1", "tvm:195:mainnet", - "cosmos:118:cosmoshub", "sol:501:mainnet", "xrp:144:mainnet", - "xlm:148:mainnet", "near:397:mainnet", "apt:637:mainnet", - "sui:784:mainnet", "ada:1815:mainnet", "algo:283:mainnet", + "bitcoin:0:bitcoin", "evm:60:1", "avalanche:9000:1", "tron:195:mainnet", + "cosmos:118:cosmoshub", "solana:501:mainnet", "xrpl:144:mainnet", + "stellar:148:mainnet", "near:397:mainnet", "aptos:637:mainnet", + "sui:784:mainnet", "cardano:1815:mainnet", "algorand:283:mainnet", "ton:607:mainnet", }; for (const auto& triple : nets) { @@ -136,7 +138,7 @@ TEST_CASE("validate accepts ROOT for every registered network") { const std::string nt = triple.substr(0, first); const std::string ct = triple.substr(first + 1, second - first - 1); const std::string ci = triple.substr(second + 1); - const std::string urn = "urn:mhda:nt:" + nt + ":ct:" + ct + ":ci:" + ci; + const std::string urn = "urn:mhda:nt:" + nt + ":ci:" + ci + ":ct:" + ct; EXPECT_NO_THROW(parse_urn_strict(urn)); } } diff --git a/tests/test_fuzz.cpp b/tests/test_fuzz.cpp index 2210907..6f3227b 100644 --- a/tests/test_fuzz.cpp +++ b/tests/test_fuzz.cpp @@ -19,15 +19,17 @@ using namespace mhda; namespace { const std::vector kSeedURNs = { - "urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44'/60'/1'/0/1:aa:secp256k1:af:hex:ap:0x", - "urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44'/60'/2'/0/2'", - "urn:mhda:nt:evm:ct:60:ci:1", - "urn:mhda:nt:btc:ct:0:ci:bitcoin_testnet:dt:bip44:dp:m/44'/0'/0'/0/0", - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip44:dp:m/44'/0'/1'/0/1:aa:secp256k1:af:p2pkh:ap:1", - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip84:dp:m/84'/0'/2'/0/2", - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0:af:bech32", - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32m:ap:bc1p", - "urn:mhda:nt:cosmos:ct:118:ci:cosmoshub:dt:cip11:dp:m/44'/118'/0'/0/0", + "urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/1'/0/1:aa:secp256k1:af:hex:ap:0x", + "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/2'/0/2'", + "urn:mhda:nt:evm:ci:1", + "urn:mhda:nt:bitcoin:ci:bitcoin_testnet:ct:0:dt:bip44:dp:m/44'/0'/0'/0/0", + "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip44:dp:m/44'/0'/1'/0/1:aa:secp256k1:af:p2pkh:ap:1", + "urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip84:dp:m/84'/0'/2'/0/2", + "urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0:af:bech32", + "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32m:ap:bc1p", + "urn:mhda:nt:cosmos:ci:cosmoshub:ct:118:dt:cip11:dp:m/44'/118'/0'/0/0", + "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0:wt:web3:wi:5f2a8c31", + "urn:mhda:nt:ton:ci:mainnet:wt:tonconnect:wi:c0a8f2d4-3b6e-4a51-9c7d-2f8e1a0b5c93", // Historically problematic / degenerate inputs from go-mhda fuzz seeds. "", "urn:mhda:", @@ -40,12 +42,16 @@ const std::vector kSeedURNs = { "urn:mhda:nt:evm:ct:", "urn:mhda:nt:evm:ct:60:ci", "urn:mhda:nt:evm:ct:60:ci:", - "urn:mhda:nt:evm:ct:60:ci:1:xx:y", + "urn:mhda:nt:evm:ci:1:ct:60:xx:y", "urn:mhda:::::::", - "urn:mhda:nt:evm:ct:60:ci:1:aa:", - "urn:mhda:nt:evm:ct:60:ci:1:af:", - "urn:mhdA:nt:BtC:ct:0:ci:0 #", - "URN:MHDA:nt:evm:ct:60:ci:1?+x=y", + "urn:mhda:nt:evm:ci:1:ct:60:aa:", + "urn:mhda:nt:evm:ci:1:ct:60:af:", + "urn:mhda:nt:evm:ci:1:wt:web3:wi:5f2a8c31", + "urn:mhda:nt:evm:ci:1:wt:", + "urn:mhda:nt:evm:ci:1:wi:", + "urn:mhda:wt:web3:nt:evm:ci:1", + "urn:mhdA:nt:BitCoin:ct:0:ci:0 #", + "URN:MHDA:nt:evm:ci:1:ct:60?+x=y", }; const std::vector> kSeedPaths = { @@ -176,7 +182,8 @@ TEST_CASE("FuzzParseNSS: no panic, no half-state on success") { } } const std::vector bare_seeds = { - "", "n", "nt", "nt:", "nt:evm", "nt:evm:ct:60:ci:1", + "", "n", "nt", "nt:", "nt:evm", "nt:evm:ci:1:ct:60", "nt:evm:ci:1", + "nt:evm:ci:1:wt:web3:wi:5f2a8c31", }; for (const auto& s : bare_seeds) check(s); for (int i = 0; i < kIterations; ++i) { diff --git a/tests/test_hardening.cpp b/tests/test_hardening.cpp new file mode 100644 index 0000000..c1d590d --- /dev/null +++ b/tests/test_hardening.cpp @@ -0,0 +1,173 @@ +// Post-review hardening suite, mirroring hardening_test.go of the Go +// reference: duplicate-component rejection, canonical-only chain keys, ct +// spelling grammar, printable-ASCII value enforcement, free-form setter +// validation, and case-preservation pins. Plus the review-identified test +// gaps: coin-registry spot checks and the explicit ct:0 URN round-trip. + +#include +#include +#include +#include + +#include "mhda/mhda.hpp" +#include "ostream_helpers.hpp" +#include "test_framework.hpp" + +using namespace mhda; + +// Pins the duplicate-key rule for old and new components alike. +TEST_CASE("duplicate components rejected for ci/ct/wt/wi") { + const std::vector urns = { + "urn:mhda:nt:evm:ci:1:ci:2", + "urn:mhda:nt:evm:ci:1:ct:60:ct:61", + "urn:mhda:nt:evm:ci:1:wt:web3:wt:metamask", + "urn:mhda:nt:evm:ci:1:wi:a:wi:b", + }; + for (const auto& urn : urns) { + EXPECT_THROW_CODE(parse_urn(urn), error_code::invalid_nss); + } +} + +// A chain key must BE the canonical identity string — unknown tokens, +// reordering and surrounding junk are all rejected, not silently normalised. +TEST_CASE("chain::from_key is canonical-only") { + const std::vector keys = { + "nt:evm:ci:1:zz:junk", // unknown trailing token + "nt:evm:ci:1:foo", // dangling token + "ci:1:nt:evm", // reordered + "nt:EVM:ci:1", // non-canonical case in the enum value + }; + for (const auto& k : keys) { + EXPECT_THROW_CODE(chain::from_key(k), error_code::invalid_chain_key); + } + // Surrounding whitespace is tolerated (trimmed before the canonical + // comparison) — a key embedded in config files commonly carries it. + EXPECT_NO_THROW(chain::from_key(" nt:evm:ci:1 ")); + auto c = chain::from_key(" nt:evm:ci:1 "); + EXPECT_EQ(c.key(), std::string{"nt:evm:ci:1"}); +} + +// The free-form components carry client-supplied strings; a value able to +// smuggle ':' (component injection), '?' / '#' (RFC 8141 truncation) or +// whitespace would break the round-trip guarantee — the setters must reject +// them loudly, and a rejected value must not partially mutate the address. +TEST_CASE("free-form setters reject NSS-corrupting values") { + auto addr = parse_urn("urn:mhda:nt:evm:ci:1"); + const std::vector>> setters = { + {"set_wallet_type", [&](std::string_view v) { addr.set_wallet_type(v); }}, + {"set_wallet_id", [&](std::string_view v) { addr.set_wallet_id(v); }}, + {"set_address_prefix", [&](std::string_view v) { addr.set_address_prefix(v); }}, + {"set_address_suffix", [&](std::string_view v) { addr.set_address_suffix(v); }}, + }; + const std::vector bad = { + "x:dt:bip44", "ci:2", "abc#def", "x?y", "a b", + }; + for (const auto& s : setters) { + for (const auto& v : bad) { + EXPECT_THROW_CODE(s.second(v), error_code::invalid_value); + } + } + // The rejected values must not have partially mutated the address. + EXPECT_EQ(addr.str(), std::string{"urn:mhda:nt:evm:ci:1"}); +} + +// Constructor params ride the same validation as parsed input; an invalid +// value throws (the C++ analogue of the Go constructor panic). +TEST_CASE("constructor throws on an NSS-corrupting prefix param") { + EXPECT_THROW_CODE( + (address{chain{network_type::ethereum_vm, "1"}, std::nullopt, + "", "", "x:evil", ""}), + error_code::invalid_value); +} + +// Pins the documented ct grammar: plain decimal and 0x/0X-prefixed hex only. +// Go integer-literal extras (0o/0b, underscores) must be rejected; a leading +// zero stays decimal ("060" == 60, "08" == 8), never octal. +TEST_CASE("coin-type spellings") { + const std::vector> valid = { + {"urn:mhda:nt:evm:ci:1:ct:60", 60}, + {"urn:mhda:nt:evm:ci:1:ct:0x3c", 60}, + {"urn:mhda:nt:evm:ci:1:ct:0X3C", 60}, + {"urn:mhda:nt:evm:ci:1:ct:060", 60}, // leading zero is DECIMAL, not octal + {"urn:mhda:nt:evm:ci:1:ct:08", 8}, // would be invalid as octal + }; + for (const auto& v : valid) { + auto addr = parse_urn(v.first); + EXPECT_TRUE(addr.get_chain().coin().has_value()); + if (addr.get_chain().coin()) EXPECT_EQ(*addr.get_chain().coin(), v.second); + } + const std::vector invalid = { + "urn:mhda:nt:evm:ci:1:ct:6_0", + "urn:mhda:nt:evm:ci:1:ct:0o74", + "urn:mhda:nt:evm:ci:1:ct:0b111100", + "urn:mhda:nt:evm:ci:1:ct:-1", + "urn:mhda:nt:evm:ci:1:ct:0x", + }; + for (const auto& urn : invalid) { + EXPECT_THROW_CODE(parse_urn(urn), error_code::invalid_coin_type); + } +} + +// Values with embedded whitespace cannot appear in a conforming NSS and would +// serialise into a non-parseable form. +TEST_CASE("interior whitespace in values rejected") { + const std::vector urns = { + "urn:mhda:nt:evm:ci:a b", + "urn:mhda:nt:evm:ci:1:wt:a b", + "urn:mhda:nt:evm:ci:1:ap:0x 1", + }; + for (const auto& urn : urns) { + EXPECT_THROW_CODE(parse_urn(urn), error_code::invalid_nss); + } +} + +// An NSS is printable ASCII by definition (RFC 8141). Unicode spaces must not +// be silently trimmed into a different chain identity, and non-ASCII value +// bytes must fail loudly. +TEST_CASE("non-ASCII values rejected") { + const std::vector urns = { + "urn:mhda:nt:evm:ci:1\u3000", // ideographic space in ci + "urn:mhda:nt:evm:ci:1\u00a0", // NBSP in ci + "urn:mhda:nt:evm:ci:\u0442\u0435\u0441\u0442", // Cyrillic value + "urn:mhda:nt:evm:ci:1:wt:web\u00a03", // NBSP inside wt + }; + for (const auto& urn : urns) { + EXPECT_THROW_CODE(parse_urn(urn), error_code::invalid_nss); + } + // Setters enforce the same rule for programmatic input. + auto addr = parse_urn("urn:mhda:nt:evm:ci:1"); + EXPECT_THROW_CODE(addr.set_wallet_id("a\u00a0b"), error_code::invalid_value); + // Keys with a trailing Unicode space are not the canonical string. + EXPECT_THROW_CODE(chain::from_key("nt:evm:ci:1\u3000"), error_code::invalid_nss); +} + +// Enum-valued components normalise to lowercase; free-form values +// (ci/ap/as/wt/wi) are case-preserving and round-trip verbatim. +TEST_CASE("free-form values preserve case") { + const std::string in = "urn:mhda:nt:evm:ci:0xAbC:ap:0xPREFIX:wt:Web3:wi:ABCDEF"; + auto addr = parse_urn(in); + EXPECT_EQ(addr.str(), in); + EXPECT_TRUE(addr.str().find(":wt:Web3") != std::string::npos); +} + +// Registry spot checks, incl. the ATOM fix (118; 168 belongs to Helleniccoin). +TEST_CASE("coin registry spot checks") { + EXPECT_EQ(coins::atom, 118u); + EXPECT_EQ(coins::btc, 0u); + EXPECT_EQ(coins::eth, 60u); + EXPECT_EQ(coins::trx, 195u); + EXPECT_EQ(coins::sol, 501u); + EXPECT_EQ(coins::ton, 607u); + EXPECT_EQ(coins::sui, 784u); + EXPECT_EQ(coins::mon, 268435779u); +} + +// An explicit ct:0 (Bitcoin) is distinct from "not set" and must round-trip +// at the URN level. +TEST_CASE("explicit ct:0 round-trips at URN level") { + const std::string in = "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0"; + auto addr = parse_urn(in); + EXPECT_TRUE(addr.get_chain().coin().has_value()); + if (addr.get_chain().coin()) EXPECT_EQ(*addr.get_chain().coin(), 0u); + EXPECT_EQ(addr.str(), in); +} diff --git a/tests/test_hash.cpp b/tests/test_hash.cpp index 09ba62e..d01c73f 100644 --- a/tests/test_hash.cpp +++ b/tests/test_hash.cpp @@ -5,8 +5,8 @@ using namespace mhda; TEST_CASE("Hash determinism and shape") { - auto a1 = parse_urn("urn:mhda:nt:evm:ct:60:ci:1"); - auto a2 = parse_urn("urn:mhda:nt:evm:ct:60:ci:1"); + auto a1 = parse_urn("urn:mhda:nt:evm:ci:1:ct:60"); + auto a2 = parse_urn("urn:mhda:nt:evm:ci:1:ct:60"); EXPECT_EQ(a1.hash(), a2.hash()); EXPECT_EQ(a1.hash256(), a2.hash256()); @@ -16,7 +16,7 @@ TEST_CASE("Hash determinism and shape") { EXPECT_EQ(a1.hash().size(), 40u); EXPECT_EQ(a1.hash256().size(), 64u); - auto b = parse_urn("urn:mhda:nt:evm:ct:60:ci:2"); + auto b = parse_urn("urn:mhda:nt:evm:ci:2:ct:60"); EXPECT_NE(a1.hash256(), b.hash256()); EXPECT_NE(a1.hash(), a1.nss_hash()); @@ -26,13 +26,13 @@ TEST_CASE("Hash determinism and shape") { // Reference values pre-computed against the canonical strings via OpenSSL // (sha1sum, sha256sum on the exact byte content of str() / nss()). TEST_CASE("Hash reference values") { - auto addr = parse_urn("urn:mhda:nt:evm:ct:60:ci:1"); + auto addr = parse_urn("urn:mhda:nt:evm:ci:1:ct:60"); EXPECT_EQ(addr.hash(), - std::string{"1b67879a4e427b4b26dbf1518569b8ddebb6b6ba"}); + std::string{"25e531aaa6f4668cf5b42d30cffb25b4fd359c10"}); EXPECT_EQ(addr.nss_hash(), - std::string{"5f3e128a6968997f0b00f629296feb5d90678799"}); + std::string{"85f36c062d728ff1c5a6001efde906f4367cbcf7"}); EXPECT_EQ(addr.hash256(), - std::string{"47ff599055bf943d1fca281f2177859709e2c2dfedb3f75a955dd8c0e65ed034"}); + std::string{"a682965c81c59da0d8d8d32fdf168ac31e530b394a99ba2a357f3ffe52f74a11"}); EXPECT_EQ(addr.nss_hash256(), - std::string{"88429e10123e1d49cf67d44145a5493c08bf599937541e0dee4fc00873eb8215"}); + std::string{"828f1579eb41e06dcf1a4b32a0223ce57f4be481d79e62735ca64c4a3696a5a2"}); } diff --git a/tests/test_misc.cpp b/tests/test_misc.cpp index 7ce3a0b..6786697 100644 --- a/tests/test_misc.cpp +++ b/tests/test_misc.cpp @@ -7,16 +7,23 @@ using namespace mhda; TEST_CASE("network_type_from_string is case-insensitive") { - auto a = network_type_from_string("xrp"); + auto a = network_type_from_string("xrpl"); EXPECT_TRUE(a.has_value()); EXPECT_EQ(*a, network_type::xrp_ledger); - auto b = network_type_from_string(" XRP "); + auto b = network_type_from_string(" XRPL "); EXPECT_TRUE(b.has_value()); EXPECT_EQ(*b, network_type::xrp_ledger); - auto c = network_type_from_string("xxx"); - EXPECT_FALSE(c.has_value()); + auto c = network_type_from_string("solana"); + EXPECT_TRUE(c.has_value()); + EXPECT_EQ(*c, network_type::solana); + + // Pre-1.1 short names are gone; there are no aliases. + EXPECT_FALSE(network_type_from_string("sol").has_value()); + EXPECT_FALSE(network_type_from_string("btc").has_value()); + EXPECT_FALSE(network_type_from_string("xrp").has_value()); + EXPECT_FALSE(network_type_from_string("xxx").has_value()); } TEST_CASE("algorithm helpers") { @@ -58,7 +65,7 @@ TEST_CASE("derivation_type helpers") { TEST_CASE("set_derivation_path numeric overflow surfaces correct sentinel") { EXPECT_THROW_CODE(parse_urn( - "urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44'/0'/0'/0/99999999999999999999"), + "urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/0'/0'/0/99999999999999999999"), error_code::invalid_derivation_path); } @@ -66,23 +73,26 @@ TEST_CASE("SLIP-10 programmatic construction round-trips through full URN") { auto dp = derivation_path::from_levels(derivation_type::slip10, { {44, true}, {501, true}, {0, true}, {0, true}, }); - chain c{network_type::solana, coins::sol, "mainnet"}; + chain c{network_type::solana, "mainnet"}; + c.set_coin(coins::sol); address a{c, std::optional{std::move(dp)}}; - const std::string want = "urn:mhda:nt:sol:ct:501:ci:mainnet:dt:slip10:dp:m/44'/501'/0'/0'"; + const std::string want = "urn:mhda:nt:solana:ci:mainnet:ct:501:dt:slip10:dp:m/44'/501'/0'/0'"; EXPECT_EQ(a.str(), want); auto back = parse_urn_strict(want); EXPECT_EQ(back.str(), want); } +// The deliberately mismatched ct (1815 on evm) is kept: ct is unvalidated +// metadata and must not interfere with path parsing. TEST_CASE("Cardano CIP-1852 role mapping") { struct row { std::string urn; charge_type want_role; }; std::vector cases = { - {"urn:mhda:nt:evm:ct:1815:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/0'/0/0", 0}, - {"urn:mhda:nt:evm:ct:1815:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/0'/1/0", 1}, - {"urn:mhda:nt:evm:ct:1815:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/0'/2/0", 2}, - {"urn:mhda:nt:evm:ct:1815:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/0'/3/0", 3}, - {"urn:mhda:nt:evm:ct:1815:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/3'/2/7'", 2}, + {"urn:mhda:nt:evm:ci:mainnet:ct:1815:dt:cip1852:dp:m/1852'/1815'/0'/0/0", 0}, + {"urn:mhda:nt:evm:ci:mainnet:ct:1815:dt:cip1852:dp:m/1852'/1815'/0'/1/0", 1}, + {"urn:mhda:nt:evm:ci:mainnet:ct:1815:dt:cip1852:dp:m/1852'/1815'/0'/2/0", 2}, + {"urn:mhda:nt:evm:ci:mainnet:ct:1815:dt:cip1852:dp:m/1852'/1815'/0'/3/0", 3}, + {"urn:mhda:nt:evm:ci:mainnet:ct:1815:dt:cip1852:dp:m/1852'/1815'/3'/2/7'", 2}, }; for (const auto& c : cases) { auto addr = parse_urn(c.urn); diff --git a/tests/test_parse.cpp b/tests/test_parse.cpp index aaa6e0d..82fc257 100644 --- a/tests/test_parse.cpp +++ b/tests/test_parse.cpp @@ -9,16 +9,19 @@ using namespace mhda; namespace { +// Mirrors the uriMHDA corpus of the Go reference implementation. const std::vector kCorpus = { - "urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44'/60'/1'/0/1:aa:secp256k1:af:hex:ap:0x", - "urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44'/60'/2'/0/2'", - "urn:mhda:nt:evm:ct:60:ci:1", - "urn:mhda:nt:btc:ct:0:ci:bitcoin_testnet:dt:bip44:dp:m/44'/0'/0'/0/0", - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip44:dp:m/44'/0'/1'/0/1:aa:secp256k1:af:p2pkh:ap:1", - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip84:dp:m/84'/0'/2'/0/2", - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0:af:bech32", - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32m:ap:bc1p", - "urn:mhda:nt:cosmos:ct:118:ci:cosmoshub:dt:cip11:dp:m/44'/118'/0'/0/0", + "urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/1'/0/1:aa:secp256k1:af:hex:ap:0x", + "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/2'/0/2'", + "urn:mhda:nt:evm:ci:1", + "urn:mhda:nt:bitcoin:ci:bitcoin_testnet:ct:0:dt:bip44:dp:m/44'/0'/0'/0/0", + "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip44:dp:m/44'/0'/1'/0/1:aa:secp256k1:af:p2pkh:ap:1", + "urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip84:dp:m/84'/0'/2'/0/2", + "urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0:af:bech32", + "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32m:ap:bc1p", + "urn:mhda:nt:cosmos:ci:cosmoshub:ct:118:dt:cip11:dp:m/44'/118'/0'/0/0", + "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0:wt:web3:wi:5f2a8c31", + "urn:mhda:nt:ton:ci:mainnet:wt:tonconnect:wi:c0a8f2d4-3b6e-4a51-9c7d-2f8e1a0b5c93", }; } // namespace @@ -54,32 +57,37 @@ TEST_CASE("idempotent re-parse") { TEST_CASE("sentinel error codes") { struct row { std::string in; error_code want; }; std::vector cases = { - {"mhda:nt:evm:ct:60:ci:1", error_code::invalid_urn}, + {"mhda:nt:evm:ci:1:ct:60", error_code::invalid_urn}, {"urn:mhda:ct:60:ci:1", error_code::missing_network_type}, - {"urn:mhda:nt:notanetwork:ct:60:ci:1", error_code::invalid_network_type}, - {"urn:mhda:nt:evm:ci:1", error_code::missing_coin_type}, - {"urn:mhda:nt:evm:ct:notanumber:ci:1", error_code::invalid_coin_type}, + {"urn:mhda:nt:notanetwork:ci:1:ct:60", error_code::invalid_network_type}, + {"urn:mhda:nt:evm:ci:1:ct:notanumber", error_code::invalid_coin_type}, {"urn:mhda:nt:evm:ct:60", error_code::missing_chain_id}, - {"urn:mhda:nt:evm:ct:60:ci:1:dt:bipxx:dp:m/44'/60'/0'/0/0", + {"urn:mhda:nt:evm:ci:1:ct:60:dt:bipxx:dp:m/44'/60'/0'/0/0", error_code::invalid_derivation_type}, - {"urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:not_a_path", error_code::invalid_derivation_path}, - {"urn:mhda:nt:evm:ct:60:ci:1:aa:rsa", error_code::invalid_algorithm}, - {"urn:mhda:nt:evm:ct:60:ci:1:af:notaformat", error_code::invalid_format}, + {"urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:not_a_path", error_code::invalid_derivation_path}, + {"urn:mhda:nt:evm:ci:1:ct:60:aa:rsa", error_code::invalid_algorithm}, + {"urn:mhda:nt:evm:ci:1:ct:60:af:notaformat", error_code::invalid_format}, }; for (const auto& c : cases) { EXPECT_THROW_CODE(parse_urn(c.in), c.want); } + + // The strict chain-key parser has two dedicated sentinels of its own. + EXPECT_THROW_CODE(chain::from_key("nt:evm:ct:60:ci:1"), + error_code::coin_type_in_chain_key); + EXPECT_THROW_CODE(chain::from_key("nt:evm:ci:1:aa:secp256k1"), + error_code::invalid_chain_key); } TEST_CASE("RFC 8141 case-insensitive prefix") { - const std::string canonical = "urn:mhda:nt:evm:ct:60:ci:1"; + const std::string canonical = "urn:mhda:nt:evm:ci:1:ct:60"; const std::vector variants = { - "urn:mhda:nt:evm:ct:60:ci:1", - "URN:MHDA:nt:evm:ct:60:ci:1", - "Urn:Mhda:nt:evm:ct:60:ci:1", - "URN:mhda:nt:evm:ct:60:ci:1", - "urn:MHDA:nt:evm:ct:60:ci:1", - " urn:mhda:nt:evm:ct:60:ci:1 ", + "urn:mhda:nt:evm:ci:1:ct:60", + "URN:MHDA:nt:evm:ci:1:ct:60", + "Urn:Mhda:nt:evm:ci:1:ct:60", + "URN:mhda:nt:evm:ci:1:ct:60", + "urn:MHDA:nt:evm:ci:1:ct:60", + " urn:mhda:nt:evm:ci:1:ct:60 ", }; for (const auto& v : variants) { auto addr = parse_urn(v); @@ -88,12 +96,12 @@ TEST_CASE("RFC 8141 case-insensitive prefix") { } TEST_CASE("RFC 8141 strips rq/f components") { - const std::string canonical = "urn:mhda:nt:evm:ct:60:ci:1"; + const std::string canonical = "urn:mhda:nt:evm:ci:1:ct:60"; const std::vector variants = { - "urn:mhda:nt:evm:ct:60:ci:1?+resolver=example.com", - "urn:mhda:nt:evm:ct:60:ci:1?=v=1", - "urn:mhda:nt:evm:ct:60:ci:1#fragment", - "urn:mhda:nt:evm:ct:60:ci:1?+a=b#frag", + "urn:mhda:nt:evm:ci:1:ct:60?+resolver=example.com", + "urn:mhda:nt:evm:ci:1:ct:60?=v=1", + "urn:mhda:nt:evm:ci:1:ct:60#fragment", + "urn:mhda:nt:evm:ci:1:ct:60?+a=b#frag", }; for (const auto& v : variants) { auto addr = parse_urn(v); @@ -102,10 +110,10 @@ TEST_CASE("RFC 8141 strips rq/f components") { } TEST_CASE("hardened marker normalisation") { - auto a = parse_urn("urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44H/60H/0H/0/0"); - EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0"}); - auto b = parse_urn("urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44h/60h/0h/0/0"); - EXPECT_EQ(b.str(), std::string{"urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0"}); + auto a = parse_urn("urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44H/60H/0H/0/0"); + EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/0'/0/0"}); + auto b = parse_urn("urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44h/60h/0h/0/0"); + EXPECT_EQ(b.str(), std::string{"urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/0'/0/0"}); } TEST_CASE("empty component values rejected") { @@ -123,14 +131,14 @@ TEST_CASE("empty component values rejected") { } TEST_CASE("derivation type accessor") { - auto a = parse_urn("urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0"); + auto a = parse_urn("urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/0'/0/0"); EXPECT_EQ(a.get_derivation_type(), derivation_type::bip44); - auto root = parse_urn("urn:mhda:nt:evm:ct:60:ci:1"); + auto root = parse_urn("urn:mhda:nt:evm:ci:1:ct:60"); EXPECT_EQ(root.get_derivation_type(), derivation_type::root); } TEST_CASE("regression: whitespace before fragment") { - const std::string in = "urn:mhdA:nt:BtC:ct:0:ci:0 #"; + const std::string in = "urn:mhdA:nt:BitCoin:ct:0:ci:0 #"; auto once = parse_urn(in); auto twice = parse_urn(once.str()); EXPECT_EQ(once.str(), twice.str()); @@ -144,13 +152,13 @@ TEST_CASE("MarshalText on uninitialised address fails") { TEST_CASE("UnmarshalText round-trip") { address a; EXPECT_THROW_CODE(a.unmarshal_text("not-a-urn"), error_code::invalid_urn); - a.unmarshal_text("urn:mhda:nt:evm:ct:60:ci:1"); - EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ct:60:ci:1"}); + a.unmarshal_text("urn:mhda:nt:evm:ci:1:ct:60"); + EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1:ct:60"}); } TEST_CASE("set prefix/suffix reset") { address a{ - chain{network_type::ethereum_vm, coins::eth, "1"}, + chain{network_type::ethereum_vm, "1"}, std::nullopt, "", "", "0xPREFIX", "SUFFIX", }; @@ -158,12 +166,12 @@ TEST_CASE("set prefix/suffix reset") { EXPECT_TRUE(a.str().find(":as:SUFFIX") != std::string::npos); a.set_address_prefix(""); a.set_address_suffix(""); - EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ct:60:ci:1"}); + EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1"}); } TEST_CASE("set_derivation_path is no-op for ROOT") { - address a{chain{network_type::ethereum_vm, coins::eth, "1"}, std::nullopt}; + address a{chain{network_type::ethereum_vm, "1"}, std::nullopt}; a.set_derivation_type(""); a.set_derivation_path("m/44'/0'/0'/0/0"); // silently ignored - EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ct:60:ci:1"}); + EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1"}); } diff --git a/tests/test_validate.cpp b/tests/test_validate.cpp index 68284d8..68c09aa 100644 --- a/tests/test_validate.cpp +++ b/tests/test_validate.cpp @@ -9,24 +9,24 @@ using namespace mhda; TEST_CASE("strict mode accepts valid combos") { const std::vector ok = { - "urn:mhda:nt:evm:ct:60:ci:1", - "urn:mhda:nt:evm:ct:60:ci:1:aa:secp256k1:af:hex", - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip44:dp:m/44'/0'/0'/0/0:af:p2pkh", - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0:af:bech32", - "urn:mhda:nt:sol:ct:501:ci:mainnet", - "urn:mhda:nt:sol:ct:501:ci:mainnet:aa:ed25519:af:base58", - "urn:mhda:nt:cosmos:ct:118:ci:cosmoshub:dt:cip11:dp:m/44'/118'/0'/0/0", + "urn:mhda:nt:evm:ci:1:ct:60", + "urn:mhda:nt:evm:ci:1:ct:60:aa:secp256k1:af:hex", + "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip44:dp:m/44'/0'/0'/0/0:af:p2pkh", + "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip84:dp:m/84'/0'/0'/0/0:af:bech32", + "urn:mhda:nt:solana:ci:mainnet:ct:501", + "urn:mhda:nt:solana:ci:mainnet:ct:501:aa:ed25519:af:base58", + "urn:mhda:nt:cosmos:ci:cosmoshub:ct:118:dt:cip11:dp:m/44'/118'/0'/0/0", }; for (const auto& urn : ok) EXPECT_NO_THROW(parse_urn_strict(urn)); } TEST_CASE("strict mode rejects invalid combos") { const std::vector bad = { - "urn:mhda:nt:evm:ct:60:ci:1:aa:ed25519", - "urn:mhda:nt:btc:ct:0:ci:bitcoin:aa:ed25519", - "urn:mhda:nt:evm:ct:60:ci:1:af:bech32", - "urn:mhda:nt:sol:ct:501:ci:mainnet:aa:secp256k1", - "urn:mhda:nt:cosmos:ct:118:ci:cosmoshub:af:hex", + "urn:mhda:nt:evm:ci:1:ct:60:aa:ed25519", + "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:aa:ed25519", + "urn:mhda:nt:evm:ci:1:ct:60:af:bech32", + "urn:mhda:nt:solana:ci:mainnet:ct:501:aa:secp256k1", + "urn:mhda:nt:cosmos:ci:cosmoshub:ct:118:af:hex", }; for (const auto& urn : bad) { EXPECT_THROW_CODE(parse_urn_strict(urn), error_code::incompatible); @@ -34,7 +34,7 @@ TEST_CASE("strict mode rejects invalid combos") { } TEST_CASE("non-strict parser stays permissive") { - const std::string bad_for_strict = "urn:mhda:nt:evm:ct:60:ci:1:aa:ed25519"; + const std::string bad_for_strict = "urn:mhda:nt:evm:ci:1:ct:60:aa:ed25519"; EXPECT_NO_THROW(parse_urn(bad_for_strict)); EXPECT_THROW_CODE(parse_urn_strict(bad_for_strict), error_code::incompatible); } @@ -42,22 +42,22 @@ TEST_CASE("non-strict parser stays permissive") { TEST_CASE("derivation compatibility leg of strict mode") { struct row { std::string urn; bool ok; }; std::vector cases = { - {"urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0", true}, - {"urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32m", true}, - {"urn:mhda:nt:cosmos:ct:118:ci:cosmoshub:dt:cip11:dp:m/44'/118'/0'/0/0", true}, - {"urn:mhda:nt:sol:ct:501:ci:mainnet:dt:slip10:dp:m/44'/501'/0'/0'", true}, - {"urn:mhda:nt:ada:ct:1815:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/0'/0/0", true}, - {"urn:mhda:nt:algo:ct:283:ci:mainnet", true}, - {"urn:mhda:nt:ton:ct:607:ci:mainnet", true}, - {"urn:mhda:nt:sui:ct:784:ci:mainnet:dt:bip54:dp:m/54'/784'/0'/0/0:aa:secp256k1", true}, - {"urn:mhda:nt:sui:ct:784:ci:mainnet:dt:bip74:dp:m/74'/784'/0'/0/0:aa:secp256r1", true}, + {"urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/0'/0/0", true}, + {"urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32m", true}, + {"urn:mhda:nt:cosmos:ci:cosmoshub:ct:118:dt:cip11:dp:m/44'/118'/0'/0/0", true}, + {"urn:mhda:nt:solana:ci:mainnet:ct:501:dt:slip10:dp:m/44'/501'/0'/0'", true}, + {"urn:mhda:nt:cardano:ci:mainnet:ct:1815:dt:cip1852:dp:m/1852'/1815'/0'/0/0", true}, + {"urn:mhda:nt:algorand:ci:mainnet:ct:283", true}, + {"urn:mhda:nt:ton:ci:mainnet:ct:607", true}, + {"urn:mhda:nt:sui:ci:mainnet:ct:784:dt:bip54:dp:m/54'/784'/0'/0/0:aa:secp256k1", true}, + {"urn:mhda:nt:sui:ci:mainnet:ct:784:dt:bip74:dp:m/74'/784'/0'/0/0:aa:secp256r1", true}, // disallowed combos - {"urn:mhda:nt:evm:ct:1815:ci:1:dt:cip1852:dp:m/1852'/1815'/0'/0/0", false}, - {"urn:mhda:nt:sol:ct:501:ci:mainnet:dt:bip44:dp:m/44'/501'/0'/0/0", false}, - {"urn:mhda:nt:ada:ct:1815:ci:mainnet:dt:bip44:dp:m/44'/1815'/0'/0/0", false}, - {"urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:cip11:dp:m/44'/118'/0'/0/0", false}, - {"urn:mhda:nt:apt:ct:637:ci:mainnet:dt:bip54:dp:m/54'/637'/0'/0/0:aa:secp256k1", false}, - {"urn:mhda:nt:xlm:ct:148:ci:mainnet:dt:bip44:dp:m/44'/148'/0'/0/0", false}, + {"urn:mhda:nt:evm:ci:1:ct:1815:dt:cip1852:dp:m/1852'/1815'/0'/0/0", false}, + {"urn:mhda:nt:solana:ci:mainnet:ct:501:dt:bip44:dp:m/44'/501'/0'/0/0", false}, + {"urn:mhda:nt:cardano:ci:mainnet:ct:1815:dt:bip44:dp:m/44'/1815'/0'/0/0", false}, + {"urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:cip11:dp:m/44'/118'/0'/0/0", false}, + {"urn:mhda:nt:aptos:ci:mainnet:ct:637:dt:bip54:dp:m/54'/637'/0'/0/0:aa:secp256k1", false}, + {"urn:mhda:nt:stellar:ci:mainnet:ct:148:dt:bip44:dp:m/44'/148'/0'/0/0", false}, }; for (const auto& c : cases) { if (c.ok) { @@ -70,13 +70,13 @@ TEST_CASE("derivation compatibility leg of strict mode") { TEST_CASE("Bitcoin all script formats") { const std::vector urns = { - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip44:dp:m/44'/0'/0'/0/0:af:p2pkh", - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip44:dp:m/44'/0'/0'/0/0:af:p2sh", - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0:af:p2wpkh", - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0:af:p2wsh", - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0:af:bech32", - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:p2tr", - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32m", + "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip44:dp:m/44'/0'/0'/0/0:af:p2pkh", + "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip44:dp:m/44'/0'/0'/0/0:af:p2sh", + "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip84:dp:m/84'/0'/0'/0/0:af:p2wpkh", + "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip84:dp:m/84'/0'/0'/0/0:af:p2wsh", + "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip84:dp:m/84'/0'/0'/0/0:af:bech32", + "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip86:dp:m/86'/0'/0'/0/0:af:p2tr", + "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32m", }; for (const auto& urn : urns) { auto addr = parse_urn_strict(urn); @@ -85,57 +85,77 @@ TEST_CASE("Bitcoin all script formats") { } TEST_CASE("Algorithm and format defaults") { - auto evm = parse_urn("urn:mhda:nt:evm:ct:60:ci:1"); + auto evm = parse_urn("urn:mhda:nt:evm:ci:1:ct:60"); EXPECT_EQ(evm.resolved_algorithm(), algorithm::secp256k1); EXPECT_EQ(evm.resolved_format(), format::hex); - auto sol = parse_urn("urn:mhda:nt:sol:ct:501:ci:mainnet"); + auto sol = parse_urn("urn:mhda:nt:solana:ci:mainnet:ct:501"); EXPECT_EQ(sol.resolved_algorithm(), algorithm::ed25519); - auto ton = parse_urn("urn:mhda:nt:ton:ct:607:ci:mainnet"); + auto ton = parse_urn("urn:mhda:nt:ton:ci:mainnet:ct:607"); EXPECT_EQ(ton.resolved_algorithm(), algorithm::ed25519); EXPECT_EQ(ton.resolved_format(), format::base64url); - auto algo = parse_urn("urn:mhda:nt:algo:ct:283:ci:mainnet"); + auto algo = parse_urn("urn:mhda:nt:algorand:ci:mainnet:ct:283"); EXPECT_EQ(algo.resolved_format(), format::base32); } TEST_CASE("defaults do not leak into NSS") { - auto evm = parse_urn("urn:mhda:nt:evm:ct:60:ci:1"); - EXPECT_EQ(evm.str(), std::string{"urn:mhda:nt:evm:ct:60:ci:1"}); + auto evm = parse_urn("urn:mhda:nt:evm:ci:1:ct:60"); + EXPECT_EQ(evm.str(), std::string{"urn:mhda:nt:evm:ci:1:ct:60"}); } +// Mirrors the README documentation-as-test of the Go reference: every URN +// literal in the docs must parse under strict mode and round-trip cleanly. TEST_CASE("README examples round-trip strictly") { const std::vector examples = { - "urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0", - "urn:mhda:nt:evm:ct:60:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0:aa:secp256k1:af:hex:ap:0x", - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip44:dp:m/44'/0'/0'/0/0:af:p2pkh:ap:1", - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip49:dp:m/49'/0'/0'/0/0:af:p2sh:ap:3", - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0:af:bech32:ap:bc1q", - "urn:mhda:nt:btc:ct:0:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32m:ap:bc1p", - "urn:mhda:nt:evm:ct:60:ci:0xa86a:dt:bip44:dp:m/44'/60'/0'/0/0", - "urn:mhda:nt:avm:ct:9000:ci:1:dt:bip44:dp:m/44'/9000'/0'/0/0:af:bech32:ap:X-avax", - "urn:mhda:nt:sol:ct:501:ci:mainnet:dt:slip10:dp:m/44'/501'/0'/0'", - "urn:mhda:nt:xrp:ct:144:ci:mainnet:dt:bip44:dp:m/44'/144'/0'/0/0", - "urn:mhda:nt:xrp:ct:144:ci:mainnet:dt:bip44:dp:m/44'/144'/0'/0/0:aa:ed25519", - "urn:mhda:nt:xlm:ct:148:ci:mainnet:dt:slip10:dp:m/44'/148'/0'", - "urn:mhda:nt:near:ct:397:ci:mainnet:dt:slip10:dp:m/44'/397'/0'", - "urn:mhda:nt:near:ct:397:ci:mainnet:dt:bip44:dp:m/44'/397'/0'/0/0:aa:secp256k1", - "urn:mhda:nt:apt:ct:637:ci:mainnet:dt:slip10:dp:m/44'/637'/0'/0'/0'", - "urn:mhda:nt:apt:ct:637:ci:mainnet:dt:bip44:dp:m/44'/637'/0'/0/0:aa:secp256k1", - "urn:mhda:nt:sui:ct:784:ci:mainnet:dt:slip10:dp:m/44'/784'/0'/0'/0'", - "urn:mhda:nt:sui:ct:784:ci:mainnet:dt:bip54:dp:m/54'/784'/0'/0/0:aa:secp256k1", - "urn:mhda:nt:sui:ct:784:ci:mainnet:dt:bip74:dp:m/74'/784'/0'/0/0:aa:secp256r1", - "urn:mhda:nt:ada:ct:1815:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/0'/0/0", - "urn:mhda:nt:ada:ct:1815:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/0'/2/0", - "urn:mhda:nt:ada:ct:1815:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/0'/0/0:af:base58", - "urn:mhda:nt:algo:ct:283:ci:mainnet", - "urn:mhda:nt:algo:ct:283:ci:mainnet:dt:slip10:dp:m/44'/283'/0'/0'/0'", - "urn:mhda:nt:ton:ct:607:ci:mainnet", - "urn:mhda:nt:ton:ct:607:ci:mainnet:af:hex", - "urn:mhda:nt:ton:ct:607:ci:mainnet:dt:slip10:dp:m/44'/607'/0'", - "urn:mhda:nt:cosmos:ct:118:ci:cosmoshub:dt:cip11:dp:m/44'/118'/0'/0/0", - "urn:mhda:nt:evm:ct:60:ci:1", + // EVM + "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0", + "urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/0'/0/0:aa:secp256k1:af:hex:ap:0x", + // Bitcoin + "urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip44:dp:m/44'/0'/0'/0/0:af:p2pkh:ap:1", + "urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip49:dp:m/49'/0'/0'/0/0:af:p2sh:ap:3", + "urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0:af:bech32:ap:bc1q", + "urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32m:ap:bc1p", + // Avalanche + "urn:mhda:nt:evm:ci:0xa86a:dt:bip44:dp:m/44'/60'/0'/0/0", + "urn:mhda:nt:avalanche:ci:1:ct:9000:dt:bip44:dp:m/44'/9000'/0'/0/0:af:bech32:ap:X-avax", + // Solana + "urn:mhda:nt:solana:ci:mainnet:dt:slip10:dp:m/44'/501'/0'/0'", + // XRP Ledger + "urn:mhda:nt:xrpl:ci:mainnet:dt:bip44:dp:m/44'/144'/0'/0/0", + "urn:mhda:nt:xrpl:ci:mainnet:ct:144:dt:bip44:dp:m/44'/144'/0'/0/0:aa:ed25519", + // Stellar + "urn:mhda:nt:stellar:ci:mainnet:dt:slip10:dp:m/44'/148'/0'", + // NEAR + "urn:mhda:nt:near:ci:mainnet:dt:slip10:dp:m/44'/397'/0'", + "urn:mhda:nt:near:ci:mainnet:ct:397:dt:bip44:dp:m/44'/397'/0'/0/0:aa:secp256k1", + // Aptos + "urn:mhda:nt:aptos:ci:mainnet:dt:slip10:dp:m/44'/637'/0'/0'/0'", + "urn:mhda:nt:aptos:ci:mainnet:ct:637:dt:bip44:dp:m/44'/637'/0'/0/0:aa:secp256k1", + // Sui + "urn:mhda:nt:sui:ci:mainnet:dt:slip10:dp:m/44'/784'/0'/0'/0'", + "urn:mhda:nt:sui:ci:mainnet:ct:784:dt:bip54:dp:m/54'/784'/0'/0/0:aa:secp256k1", + "urn:mhda:nt:sui:ci:mainnet:ct:784:dt:bip74:dp:m/74'/784'/0'/0/0:aa:secp256r1", + // Cardano + "urn:mhda:nt:cardano:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/0'/0/0", + "urn:mhda:nt:cardano:ci:mainnet:ct:1815:dt:cip1852:dp:m/1852'/1815'/0'/2/0", + "urn:mhda:nt:cardano:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/0'/0/0:af:base58", + // Algorand + "urn:mhda:nt:algorand:ci:mainnet", + "urn:mhda:nt:algorand:ci:mainnet:ct:283:dt:slip10:dp:m/44'/283'/0'/0'/0'", + // TON + "urn:mhda:nt:ton:ci:mainnet", + "urn:mhda:nt:ton:ci:mainnet:af:hex", + "urn:mhda:nt:ton:ci:mainnet:ct:607:dt:slip10:dp:m/44'/607'/0'", + // Cosmos + "urn:mhda:nt:cosmos:ci:cosmoshub:dt:cip11:dp:m/44'/118'/0'/0/0", + // Root key, with and without the optional SLIP-44 metadata + "urn:mhda:nt:evm:ci:1", + "urn:mhda:nt:evm:ci:1:ct:60", + // Wallet domain + "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0:wt:web3:wi:5f2a8c31", + "urn:mhda:nt:ton:ci:mainnet:wt:tonconnect:wi:c0a8f2d4-3b6e-4a51-9c7d-2f8e1a0b5c93", }; for (const auto& urn : examples) { auto addr = parse_urn_strict(urn); @@ -145,11 +165,11 @@ TEST_CASE("README examples round-trip strictly") { TEST_CASE("TON strict-mode rejection") { const std::vector bad = { - "urn:mhda:nt:ton:ct:607:ci:mainnet:aa:secp256k1", - "urn:mhda:nt:ton:ct:607:ci:mainnet:aa:sr25519", - "urn:mhda:nt:ton:ct:607:ci:mainnet:af:base58", - "urn:mhda:nt:ton:ct:607:ci:mainnet:af:bech32", - "urn:mhda:nt:ton:ct:607:ci:mainnet:af:strkey", + "urn:mhda:nt:ton:ci:mainnet:ct:607:aa:secp256k1", + "urn:mhda:nt:ton:ci:mainnet:ct:607:aa:sr25519", + "urn:mhda:nt:ton:ci:mainnet:ct:607:af:base58", + "urn:mhda:nt:ton:ci:mainnet:ct:607:af:bech32", + "urn:mhda:nt:ton:ci:mainnet:ct:607:af:strkey", }; for (const auto& urn : bad) { EXPECT_THROW_CODE(parse_urn_strict(urn), error_code::incompatible); @@ -158,9 +178,9 @@ TEST_CASE("TON strict-mode rejection") { TEST_CASE("Stellar strict-mode rejection") { const std::vector bad = { - "urn:mhda:nt:xlm:ct:148:ci:mainnet:aa:secp256k1", - "urn:mhda:nt:xlm:ct:148:ci:mainnet:af:bech32", - "urn:mhda:nt:xlm:ct:148:ci:mainnet:af:base58", + "urn:mhda:nt:stellar:ci:mainnet:ct:148:aa:secp256k1", + "urn:mhda:nt:stellar:ci:mainnet:ct:148:af:bech32", + "urn:mhda:nt:stellar:ci:mainnet:ct:148:af:base58", }; for (const auto& urn : bad) { EXPECT_THROW_CODE(parse_urn_strict(urn), error_code::incompatible); @@ -170,9 +190,9 @@ TEST_CASE("Stellar strict-mode rejection") { TEST_CASE("Sui purpose mapping") { struct row { std::string urn; std::uint32_t want; }; std::vector cases = { - {"urn:mhda:nt:sui:ct:784:ci:mainnet:dt:slip10:dp:m/44'/784'/0'/0'/0'", 44}, - {"urn:mhda:nt:sui:ct:784:ci:mainnet:dt:bip54:dp:m/54'/784'/0'/0/0:aa:secp256k1", 54}, - {"urn:mhda:nt:sui:ct:784:ci:mainnet:dt:bip74:dp:m/74'/784'/0'/0/0:aa:secp256r1", 74}, + {"urn:mhda:nt:sui:ci:mainnet:ct:784:dt:slip10:dp:m/44'/784'/0'/0'/0'", 44}, + {"urn:mhda:nt:sui:ci:mainnet:ct:784:dt:bip54:dp:m/54'/784'/0'/0/0:aa:secp256k1", 54}, + {"urn:mhda:nt:sui:ci:mainnet:ct:784:dt:bip74:dp:m/74'/784'/0'/0/0:aa:secp256r1", 74}, }; for (const auto& c : cases) { auto addr = parse_urn(c.urn); diff --git a/tests/test_wallet.cpp b/tests/test_wallet.cpp new file mode 100644 index 0000000..0fbb6ef --- /dev/null +++ b/tests/test_wallet.cpp @@ -0,0 +1,107 @@ +// Wallet-domain (wt/wi) and optional coin-type (ct) semantics, mirroring the +// wallet tests of the Go reference implementation. + +#include +#include + +#include "mhda/mhda.hpp" +#include "ostream_helpers.hpp" +#include "test_framework.hpp" + +using namespace mhda; + +// Both components together, each alone, and their canonical trailing position. +TEST_CASE("wallet domain round-trip") { + const std::vector urns = { + "urn:mhda:nt:evm:ci:1:wt:web3:wi:5f2a8c31", + "urn:mhda:nt:evm:ci:1:wt:metamask", + "urn:mhda:nt:evm:ci:1:wi:c0a8f2d4-3b6e-4a51-9c7d-2f8e1a0b5c93", + "urn:mhda:nt:ton:ci:mainnet:wt:tonconnect", + "urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/0'/0/0:aa:secp256k1:af:hex:ap:0x:wt:web3:wi:5f2a8c31", + }; + for (const auto& urn : urns) { + auto addr = parse_urn(urn); + EXPECT_EQ(addr.str(), urn); + } +} + +TEST_CASE("wallet domain accessors") { + auto addr = parse_urn("urn:mhda:nt:evm:ci:1:wt:web3:wi:5f2a8c31"); + EXPECT_EQ(addr.wallet_type(), std::string{"web3"}); + EXPECT_EQ(addr.wallet_id(), std::string{"5f2a8c31"}); + + // Unset wallet domain reads as empty. + auto bare = parse_urn("urn:mhda:nt:evm:ci:1"); + EXPECT_TRUE(bare.wallet_type().empty()); + EXPECT_TRUE(bare.wallet_id().empty()); +} + +// Empty-string set_x resets the field, matching the semantics of the other +// optional components. +TEST_CASE("wallet domain setters reset") { + auto addr = parse_urn("urn:mhda:nt:evm:ci:1"); + addr.set_wallet_type("web3"); + addr.set_wallet_id("5f2a8c31"); + EXPECT_EQ(addr.str(), std::string{"urn:mhda:nt:evm:ci:1:wt:web3:wi:5f2a8c31"}); + addr.set_wallet_type(""); + addr.set_wallet_id(""); + EXPECT_EQ(addr.str(), std::string{"urn:mhda:nt:evm:ci:1"}); +} + +// Parsers accept any component order on input; the wallet domain re-serializes +// in the canonical trailing position. +TEST_CASE("wallet domain order independence") { + const std::string canonical = "urn:mhda:nt:evm:ci:1:ct:60:wt:web3:wi:5f2a8c31"; + const std::vector inputs = { + "urn:mhda:wt:web3:wi:5f2a8c31:nt:evm:ci:1:ct:60", + "urn:mhda:nt:evm:wt:web3:ci:1:wi:5f2a8c31:ct:60", + "urn:mhda:nt:evm:ct:60:ci:1:wt:web3:wi:5f2a8c31", // pre-1.1 ct position + }; + for (const auto& in : inputs) { + auto addr = parse_urn(in); + EXPECT_EQ(addr.str(), canonical); + } +} + +// Empty values for wt/wi are malformed, consistent with every other component. +TEST_CASE("wallet domain empty values rejected") { + const std::vector bad = { + "urn:mhda:nt:evm:ci:1:wt:", + "urn:mhda:nt:evm:ci:1:wt::wi:x", + "urn:mhda:nt:evm:ci:1:wi:", + }; + for (const auto& urn : bad) { + bool threw = false; + try { parse_urn(urn); } catch (const parse_error&) { threw = true; } + EXPECT_TRUE(threw); + } +} + +// The wallet domain is orthogonal metadata; strict validation must pass with +// it present and keep rejecting incompatible triples regardless of it. +TEST_CASE("wallet domain is validation-orthogonal") { + EXPECT_NO_THROW(parse_urn_strict( + "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0:wt:web3:wi:5f2a8c31")); + EXPECT_THROW_CODE(parse_urn_strict("urn:mhda:nt:evm:ci:1:aa:ed25519:wt:web3"), + error_code::incompatible); +} + +// Pins the 1.1 semantics of ct: absent stays absent, present round-trips in +// the canonical position after ci, and the value normalizes to decimal. +TEST_CASE("coin type is optional metadata") { + // Absent: no ct in output, coin() empty. + auto addr = parse_urn("urn:mhda:nt:evm:ci:1"); + EXPECT_FALSE(addr.get_chain().coin().has_value()); + EXPECT_EQ(addr.str(), std::string{"urn:mhda:nt:evm:ci:1"}); + + // Present: captured, emitted after ci, decimal-normalized (0x3c -> 60). + addr = parse_urn("urn:mhda:nt:evm:ci:1:ct:0x3c"); + EXPECT_TRUE(addr.get_chain().coin().has_value()); + if (addr.get_chain().coin()) EXPECT_EQ(*addr.get_chain().coin(), 60u); + EXPECT_EQ(addr.str(), std::string{"urn:mhda:nt:evm:ci:1:ct:60"}); + + // set_coin_type with an empty string clears the metadata. + addr.set_coin_type(""); + EXPECT_FALSE(addr.get_chain().coin().has_value()); + EXPECT_EQ(addr.str(), std::string{"urn:mhda:nt:evm:ci:1"}); +}