diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12e3666..efd09c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,9 @@ name: ci on: [push, pull_request] +permissions: + contents: read + jobs: build: strategy: @@ -18,3 +21,70 @@ jobs: run: cmake --build build --parallel - name: Test run: ctest --test-dir build --output-on-failure + + sanitizers: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Configure + run: > + cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug + "-DCMAKE_CXX_FLAGS=-fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all" + "-DCMAKE_EXE_LINKER_FLAGS=-fsanitize=address,undefined" + - name: Build + run: cmake --build build --parallel + - name: Test + env: + ASAN_OPTIONS: halt_on_error=1:detect_leaks=1 + UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1 + run: ctest --test-dir build --output-on-failure + + shared: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Configure + run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON + - name: Build + run: cmake --build build --parallel + - name: Test + run: ctest --test-dir build --output-on-failure + + # The two ways a project consumes mhda: an installed package found with + # find_package (from a project that asks for C++14), and add_subdirectory / + # FetchContent, which must leave the parent's build type and targets alone. + packaging: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build and install + run: | + cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DMHDA_BUILD_TESTS=OFF -DMHDA_BUILD_EXAMPLES=OFF + cmake --build build --parallel + cmake --install build --prefix "$PWD/prefix" + - name: find_package consumer + run: | + cmake -S tests/cmake/find_package -B build-find-package -DCMAKE_PREFIX_PATH="$PWD/prefix" + cmake --build build-find-package + ./build-find-package/consumer + - name: add_subdirectory consumer + run: | + cmake -S tests/cmake/subdirectory -B build-subdirectory -DMHDA_SOURCE_DIR="$PWD" + cmake --build build-subdirectory + ./build-subdirectory/consumer + + # The warning set the README promises, as errors. Pinned to the GCC the + # README lists as verified (Ubuntu 22.04, GCC 11): a newer compiler may add + # warnings that need a deliberate look rather than a red build. + warnings: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - name: Configure + run: > + cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug + "-DCMAKE_CXX_FLAGS=-Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-conversion -Wold-style-cast -Wnon-virtual-dtor -Wcast-align -Wunused -Woverloaded-virtual -Wnull-dereference -Wdouble-promotion -Wformat=2 -Werror" + - name: Build + run: cmake --build build --parallel + - name: Test + run: ctest --test-dir build --output-on-failure diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f09c5..9a9ae1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,216 @@ 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). +## [1.2.0] — 2026-10-05 + +URNs and values accepted by 1.1 are refused now: see Changed. Code that +builds addresses gets stricter constructors and setters. Mirrors go-mhda +1.2.0. + +### Changed + +- **A derivation-path level index of 2^31 or more is refused** with + `parse_error(invalid_derivation_path)`, at every level of every + derivation type (`bip32`, `bip44`, `bip49`, `bip54`, `bip74`, `bip84`, + `bip86`, `cip11`, `cip1852`, `zip32`, `slip10`), hardened or not, by + `parse_urn`, `parse_urn_strict`, `parse_nss`, `derivation_path::parse` + and `validate_derivation_path`. 1.1 accepted any value up to 2^32-1. A + BIP-32 child number keeps the hardened flag in its top bit (`n'` is child + number 2^31+n), so such an index aliased another key: a consumer + computing `hardened ? 0x80000000 | index : index` derives the key of + `m/44'/60'/0'/0/0` for `m/44'/60'/2147483648'/0/0`, and an unhardened + `m/44'/60'/0'/0/2147483648` asks for a hardened child number through the + public-key formula. No longer parse: any `dp` with a level of + `2147483648` to `4294967295`, with or without a hardened marker, e.g. + `urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/2147483648'/0/0` and + `urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/4294967295`. The largest + index is `2147483647`. The `ct` component is not a path level and keeps + its 32-bit range. +- **Fixed path levels are spelled exactly**, as in go-mhda. The purpose, + the fixed coin of `cip11` / `cip1852` / `zip32` and the `0`/`1` charge of + `bip32` and the BIP-44 family are literals in the Go reference's grammar; + the port compared their values only and so accepted a leading zero that + Go refuses (`m/044'/60'/0'/0/0`, `m/44'/60'/0'/00/0`, + `m/1852'/01815'/0'/0/0`). These now throw + `parse_error(invalid_derivation_path)`. Leading zeros in a variable level + (`m/44'/060'/0'/0/0`) are still accepted, as in Go, and dropped in the + canonical form. +- **Programmatic values are validated like parsed input.** The network + type, the chain id and the derivation type are written verbatim into + every URN, and nothing checked them when set in code: a chain id + `1:dt:bip44:dp:m/44'/60'/0'/0/666` on a root address produced a URN that + re-parsed as a bip44 path. The `chain` constructor, `set_network` and + `set_chain_id` now require a registered network type and a non-empty + chain id of printable ASCII without `:`, `?` or `#` (ASCII-trimmed) and + throw `parse_error` (`invalid_network_type`, `missing_chain_id`, + `invalid_value`) otherwise, leaving the chain unchanged. The + `derivation_path` constructor, `from_levels` and `set_type` throw + `parse_error(invalid_derivation_type)` on an unregistered type. Mirrors + go-mhda. +- **A derivation path without a derivation type, or under `dt:root`, is + refused** with `parse_error(invalid_derivation_path)`. Both used to be + dropped silently: `urn:mhda:nt:evm:ci:1:dp:m/44'/60'/0'/0/0` and + `urn:mhda:nt:evm:ci:1:dt:root:dp:m/0` parsed as the root address + `urn:mhda:nt:evm:ci:1`, naming the root key instead of the path they + spell out. `address::set_derivation_path` on a root address accepts only + an empty path. +- **The NSS is parsed strictly as `key:value` pairs.** An unknown key was + skipped as a single token, so its value was read as the next key and a + key with the wrong case vanished: `...:ci:1:DT:bip44:DP:m/44'/60'/0'/0/5` + parsed as the root address `urn:mhda:nt:evm:ci:1`, and + `...:ci:1:ext:wi:dt:bip44:...` read `dt` as the wallet id. An unknown + component is now skipped together with its value; a key that differs from + a known key only by case, a dangling token, a trailing `:` and an empty + key throw `parse_error(invalid_nss)`. `chain::from_key` reports a + dangling token as `invalid_chain_key`, as before. +- **`parse_nss`, `chain::from_nss` and `chain::from_key` refuse `?` and + `#`** with `parse_error(invalid_nss)`. `parse_urn` strips the RFC 8141 + r/q/f components before parsing, but an NSS given on its own kept the + byte in a value, so the URN emitted from it was truncated on the next + parse (`nt:evm:ci:1:x#y:z` was accepted). The NSS fuzz test now checks + that the emitted URN re-parses to itself. +- **A `slip10` path has at most 255 levels**; a deeper one throws + `parse_error(invalid_derivation_path)`. BIP-32 serialises a key's depth + in one byte, so no wallet can represent a deeper key. +- **Strict validation refuses curve, purpose and format combinations no + wallet can derive.** `validate` checked the algorithm, the format and the + derivation type each on its own, so it accepted SLIP-10 ed25519 paths + with soft levels (`nt:solana:...:dp:m/44'/501'/0/0`; ed25519 has no soft + derivation), `bip44` with ed25519 on XRPL, NEAR and Aptos, Sui `bip54` + with ed25519 or secp256r1, a Bitcoin `bip84` path with `af:p2pkh`, and + Cosmos `bip44` with coin `118'`, which is the `cip11` path again. + `parse_urn_strict` / `validate` now throw `parse_error(incompatible)` + for: an ed25519 path with an unhardened level (except `cip1852`, which + is BIP32-Ed25519); a derivation type with another curve than its own on + Sui (`slip10` ed25519, `bip54` secp256k1, `bip74` secp256r1), Aptos and + NEAR (`slip10` ed25519, `bip44` secp256k1); an explicit Bitcoin format + that does not match the purpose (`bip44` p2pkh, `bip49` p2sh, `bip84` + p2wpkh or bech32, `bip86` p2tr or bech32m); and Cosmos `bip44` with coin + `118'`. A Bitcoin URN without `af` stays valid; SPEC.md no longer claims + strict mode requires it. Lenient parsing is unchanged. Mirrors go-mhda. +- **No whitespace inside the NSS.** Each value was trimmed, so + `urn:mhda:nt:evm:ci: 1 :dt: bip44 :...` parsed like the URN without the + spaces, against SPEC §6.1. ASCII whitespace is now trimmed only around the + whole URN, around an NSS or chain key parsed on its own, and before a + stripped r/q/f component (`ci:0 #frag` still parses); around a key or + value it throws `parse_error(invalid_nss)`. Mirrors go-mhda. +- **NSS bytes follow RFC 3986.** Keys and values accepted any printable + ASCII, so `"`, `<`, `>`, `\`, `^`, `` ` ``, `{`, `|`, `}`, `[`, `]` and a + raw `%` passed and were emitted in URNs that RFC 8141 does not allow. A + key or value is now made of letters, digits and `-._~!$&'()*+,;=@/` + only (RFC 3986 pchar and `/`, without `:` and `%`); anything else throws + `invalid_nss` when parsed and `invalid_value` from the setters and the + `chain` constructor. `%` is refused because percent-encoding is not + supported and `%41` would be a second spelling of `A`. Mirrors go-mhda. + +### Fixed + +- **`charge_type` is 32 bits wide (was `std::uint8_t`).** The CIP-11 charge + and the CIP-1852 role accept any level index, but the parsed value was + truncated to a byte: `m/1852'/1815'/0'/256/0` parsed as role 0, so it + named the role-0 key, re-serialised as `m/1852'/1815'/0'/0/0`, and + `levels()` returned the truncated value. The full value is now kept in + `charge()`, `levels()` and `str()`. This changes the ABI of + `derivation_path` (its constructor, `charge()` and its layout); rebuild + dependants. +- **Static initialisation and destruction order.** The named constants + (`network_type::bitcoin`, `algorithm::secp256k1`, `format::hex`, + `derivation_type::root`, ...) were defined in the library's `.cpp` files + and built at start-up in an unspecified order relative to the consumer's + globals, and the lookup tables copied them on first use. A consumer + global that used one (a built-in chain table, a URN validated up front) + could run first: it saw empty values and left the tables wrong for the + whole process (`parse_urn_strict` then refused every network as + unknown). A global destructor that parsed at exit read destroyed tables + and crashed. The constants are now inline variables defined in the + public headers, so they are initialised before any global defined after + the include, and the lookup tables are allocated once and never + destroyed. +- **No stale or mixed derivation state.** `set_type` and + `address::set_derivation_type` changed the type and kept the old path, so + a bip44 address switched to zip32 serialised as + `dt:zip32:dp:m/32'/133'/3'/9`, a valid URN naming a key nobody gave, and + a SLIP-10 path parsed after it kept the BIP-44 coin, account and charge. + `set_derivation_type` followed by a failing `set_derivation_path` left + that mixed address behind, and a `bad_alloc` inside `parse_path` left + `str()` and `levels()` disagreeing. A new type now clears the path; + until one is set the URN carries `dt` without `dp` (and does not parse), + and `validate` / `marshal_text` throw `invalid_derivation_path`. + `parse_path` parses into a fresh path and replaces the old one only on + success. The new `address::set_derivation(dt, dp)` sets both at once and + changes nothing on error; the URN parser uses it. Mirrors go-mhda. +- **`levels()` and `str()` cannot disagree.** `from_levels` kept any + levels it was given while `str()` printed the type's template: a BIP-44 + path built from `49'/60'/0/0/0` printed `m/44'/60'/0'/0/0` but returned + purpose 49' and an unhardened account from `levels()`, which is what a + wallet derives from. Too few levels (or none for SLIP-10) or an index of + 2^31 produced a URN that does not parse, and the five-argument + constructor accepted a charge of 7 or an account of 2^31. Both now + return exactly the path `parse()` gives back for their own `str()`, and + throw `parse_error(invalid_derivation_path)` otherwise. Two boundary + tests that pinned the old results now expect the refusal. Mirrors + go-mhda. +- **Error messages escape the input they quote.** `what()` copied the + caller's bytes verbatim, so a newline or an ANSI escape in a + client-supplied value (`set_wallet_id("x\n[INFO] ok")`) forged or + coloured lines in any log that recorded the exception. Quoted input is + now escaped like Go's `%q` (`\"`, `\\`, `\n`, `\r`, `\t`, `\xNN` for + other control and non-ASCII bytes), so `what()` is one line of printable + ASCII. +- **The headers compile under ``.** Its `near` macro (empty, from + `minwindef.h`) turned `coins::near` into a syntax error in any + translation unit that included `` first. `coin_type.hpp` now + sets the macro aside for its list and restores it, and the new alias + `coins::near_protocol` (after `network_type::near_protocol`) stays usable + while the macro is in effect. +- **Embedding mhda leaves the parent build alone.** Added with + `add_subdirectory` or FetchContent, mhda forced `CMAKE_BUILD_TYPE=Release` + into the cache of a parent that had none (so the parent's own code was + built with `-O3 -DNDEBUG` and its asserts off), built its tests and + examples into the parent and added its install rules to the parent's + install. These now apply only to a top-level build; `MHDA_BUILD_TESTS`, + `MHDA_BUILD_EXAMPLES` and the new `MHDA_INSTALL` default to it. +- **`BUILD_SHARED_LIBS=ON` produces a usable library.** The hidden-symbol + preset, without export macros, left `libmhda.so` with no mhda symbols + and every consumer failed to link. A shared mhda now exports its symbols + (`WINDOWS_EXPORT_ALL_SYMBOLS` for a DLL); a static one keeps them hidden. +- **The installed package is complete.** `mhda::mhda` now requires + `cxx_std_17` publicly, so a consumer asking for C++14 is raised to C++17 + instead of failing on `std::optional`, and the install include path + follows `CMAKE_INSTALL_INCLUDEDIR`. + +### Documentation + +- SPEC.md states that an explicit `dt:root` is folded away (root is the + default and has no path, so a root address has one canonical form and + one hash); the Algorand and TON notes no longer call `dt:root` the + canonical form. It also lists ZIP-32 as a known limitation: `zip32` + parses, but no network registers it, so strict parsing refuses it. Both + behaviours are unchanged and now pinned by tests. +- SPEC.md notes that an EVM chain id is opaque: `ci:1`, `ci:0x1` and + `ci:01` are three chain keys for one chain, and a producer must keep to + one spelling. + +### Tests + +- `tests/data/dp_conformance.txt`: a derivation-path conformance table + shared verbatim with go-mhda (`testdata/dp_conformance.txt`) and run by + both suites, standalone and inside a URN, so a path one implementation + refuses, the other refuses too. The boundary test and the fuzz seed that + pinned `4294967295` now sit at the new bound. +- `tests/data/urn_conformance.txt`: a URN-level table shared verbatim with + go-mhda, run through `parse_urn` / `parse_nss` with the expected error + code, for the parser changes above. +- `tests/test_static_init.cpp` uses the library from a global constructor + and a global destructor, `tests/test_state.cpp` covers construction, + re-parsing and type changes, and `tests/test_windows_macros.cpp` compiles + the headers under the `` macros; hash digests are pinned at + the SHA block and padding boundaries (55 to 1000 bytes). +- CI adds an ASan/UBSan job and a `-Werror` job with the README's warning + set, a shared-library job, and a packaging job that builds the + `find_package` and `add_subdirectory` consumers in `tests/cmake/`; it + runs with read-only permissions. 160 test cases total. + ## [1.1.0] — 2026-07-04 URN grammar 1.1, mirroring the go-mhda reference. The chain identity is now diff --git a/CMakeLists.txt b/CMakeLists.txt index a413a50..12ea387 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,22 +1,35 @@ cmake_minimum_required(VERSION 3.14) project(mhda - VERSION 1.1.0 + VERSION 1.2.0 DESCRIPTION "MultiChain Hierarchical Deterministic Address (MHDA) — C++ port of go-mhda" LANGUAGES CXX ) -option(MHDA_BUILD_TESTS "Build the mhda test suite" ON) -option(MHDA_BUILD_EXAMPLES "Build the mhda example programs" ON) +# Built on its own, or embedded in another project (add_subdirectory, +# FetchContent)? Only a top-level build picks a build type and, by default, +# builds the tests and examples and generates install rules; an embedding +# project keeps its own settings and can opt in. +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + set(MHDA_IS_TOP_LEVEL ON) +else() + set(MHDA_IS_TOP_LEVEL OFF) +endif() + +option(MHDA_BUILD_TESTS "Build the mhda test suite" ${MHDA_IS_TOP_LEVEL}) +option(MHDA_BUILD_EXAMPLES "Build the mhda example programs" ${MHDA_IS_TOP_LEVEL}) +option(MHDA_INSTALL "Generate the mhda install rules" ${MHDA_IS_TOP_LEVEL}) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) -if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) +if(MHDA_IS_TOP_LEVEL AND NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) endif() +include(GNUInstallDirs) + if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") add_compile_options(-Wall -Wextra -Wpedantic -Wshadow) endif() @@ -41,16 +54,28 @@ add_library(mhda::mhda ALIAS mhda) target_include_directories(mhda PUBLIC $ - $ + $ PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src ) -set_target_properties(mhda PROPERTIES - POSITION_INDEPENDENT_CODE ON - CXX_VISIBILITY_PRESET hidden - VISIBILITY_INLINES_HIDDEN ON -) +# The public headers need C++17 (std::optional, inline variables): a +# consumer that asks for an older standard is raised to C++17. +target_compile_features(mhda PUBLIC cxx_std_17) + +set_target_properties(mhda PROPERTIES POSITION_INDEPENDENT_CODE ON) +get_target_property(MHDA_LIBRARY_TYPE mhda TYPE) +if(MHDA_LIBRARY_TYPE STREQUAL "SHARED_LIBRARY") + # There are no export macros: a shared mhda exports every symbol (and + # needs WINDOWS_EXPORT_ALL_SYMBOLS for a DLL). + set_target_properties(mhda PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON) +else() + # A static mhda linked into a consumer's shared object stays private to it. + set_target_properties(mhda PROPERTIES + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN ON + ) +endif() if(MHDA_BUILD_TESTS) enable_testing() @@ -61,34 +86,35 @@ if(MHDA_BUILD_EXAMPLES) add_subdirectory(examples) endif() -include(GNUInstallDirs) -include(CMakePackageConfigHelpers) +if(MHDA_INSTALL) + include(CMakePackageConfigHelpers) -install(TARGETS mhda - EXPORT mhdaTargets - ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} - LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} - RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} -) -install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) -install(EXPORT mhdaTargets - FILE mhdaTargets.cmake - NAMESPACE mhda:: - DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mhda -) + install(TARGETS mhda + EXPORT mhdaTargets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + ) + install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + install(EXPORT mhdaTargets + FILE mhdaTargets.cmake + NAMESPACE mhda:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mhda + ) -configure_package_config_file( - "${CMAKE_CURRENT_SOURCE_DIR}/cmake/mhdaConfig.cmake.in" - "${CMAKE_CURRENT_BINARY_DIR}/mhdaConfig.cmake" - INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mhda -) -write_basic_package_version_file( - "${CMAKE_CURRENT_BINARY_DIR}/mhdaConfigVersion.cmake" - VERSION ${PROJECT_VERSION} - COMPATIBILITY SameMajorVersion -) -install(FILES - "${CMAKE_CURRENT_BINARY_DIR}/mhdaConfig.cmake" - "${CMAKE_CURRENT_BINARY_DIR}/mhdaConfigVersion.cmake" - DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mhda -) + configure_package_config_file( + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/mhdaConfig.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/mhdaConfig.cmake" + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mhda + ) + write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/mhdaConfigVersion.cmake" + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion + ) + install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/mhdaConfig.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/mhdaConfigVersion.cmake" + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mhda + ) +endif() diff --git a/README.md b/README.md index 5cdaea5..e52b0e5 100644 --- a/README.md +++ b/README.md @@ -33,16 +33,19 @@ urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0:wt:web3:wi:5f2a8c31 ## Status -- Version: **1.1.0** +- Version: **1.2.0** - Standard: **C++17**, no external runtime dependencies -- Tests: **139** unit + fuzz-equivalent stress cases (≈11 000 randomised +- Tests: **160** 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 + available); the CI matrix runs Linux + macOS, Release + Debug, plus + ASan/UBSan, `-Werror` (the warning set below), shared-library and + packaging (`find_package` / `add_subdirectory`) jobs - Warning policy: clean under `-Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-conversion -Werror` -- Binary stability is not yet guaranteed across minor versions; 1.1.0 - changes the URN grammar (see [CHANGELOG.md](./CHANGELOG.md)) +- Binary stability is not yet guaranteed across minor versions; 1.2.0 + tightens the URN grammar and strict validation and changes the ABI (see + [CHANGELOG.md](./CHANGELOG.md)) ## Building @@ -55,8 +58,11 @@ ctest --test-dir build --output-on-failure ``` The library target is `mhda::mhda`; public headers live under `include/mhda/`. -Both options below are ON by default and can be disabled with -`-DMHDA_BUILD_TESTS=OFF` / `-DMHDA_BUILD_EXAMPLES=OFF`. +Built on its own, the project builds its tests and examples and generates +install rules; embedded with `add_subdirectory` / FetchContent it builds only +the library and leaves the parent's build type alone. `-DMHDA_BUILD_TESTS`, +`-DMHDA_BUILD_EXAMPLES` and `-DMHDA_INSTALL` override either default, and +`-DBUILD_SHARED_LIBS=ON` builds a shared library. ### Installing @@ -64,8 +70,9 @@ Both options below are ON by default and can be disabled with cmake --install build --prefix /usr/local ``` -This installs `libmhda.a`, the `include/mhda/` headers and a -`mhda::mhda` CMake export so downstream projects can: +This installs the library, the `mhda/` headers under +`CMAKE_INSTALL_INCLUDEDIR` and a `mhda::mhda` CMake export, which carries the +C++17 requirement, so downstream projects can: ```cmake find_package(mhda REQUIRED) @@ -78,12 +85,16 @@ target_link_libraries(my_app PRIVATE mhda::mhda) include(FetchContent) FetchContent_Declare(mhda GIT_REPOSITORY https://github.com/censync/mhda.git - GIT_TAG v1.1.0 + GIT_TAG v1.2.0 ) FetchContent_MakeAvailable(mhda) target_link_libraries(my_app PRIVATE mhda::mhda) ``` +Embedded this way, mhda adds only its library target: tests, examples and +install rules stay off unless enabled, and it does not choose a build type +for the parent. + ## Quick start ```cpp @@ -159,6 +170,7 @@ A runnable version is in [`examples/basic.cpp`](./examples/basic.cpp). | `mhda.ParseNSS` | `mhda::parse_nss` | | `mhda.ChainFromKey` / `FromNSS` | `mhda::chain::from_key` / `from_nss` | | `mhda.NewChain(nt, ci)` | `mhda::chain{nt, ci}` | +| `Chain.SetNetworkType` / `SetChainId` | `chain::set_network` / `set_chain_id` | | `Chain.SetCoinType` / `ClearCoinType` | `chain::set_coin` / `chain::clear_coin` | | `Chain.CoinType` + `HasCoinType` | `chain::coin` (`std::optional`) | | `mhda.ParseDerivationPath` | `mhda::derivation_path::parse` | @@ -166,6 +178,7 @@ A runnable version is in [`examples/basic.cpp`](./examples/basic.cpp). | `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.SetDerivation(dt, dp)` | `address::set_derivation(dt, dp)` | | `Address.MarshalText` | `address::marshal_text` | | `Address.UnmarshalText` | `address::unmarshal_text` | | `Address.Hash` / `Hash256` | `address::hash` / `hash256` | @@ -208,13 +221,16 @@ Mirrors the [SPEC §8](./SPEC.md#8-concurrency) contract. index, algorithm/format/derivation registries) are populated lazily inside `static const` function-locals (Magic Statics — guaranteed thread-safe by C++11 §6.7.4) and are read-only thereafter; concurrent reads are safe and - introduce no locking. + introduce no locking. The tables are allocated once and never destroyed, + and the named constants (`network_type::bitcoin`, `derivation_type::root`, + ...) are inline variables defined in the public headers, so the library + also works from a consumer's global constructors and destructors. - No `std::mutex`, `std::atomic`, recursive locks or condition variables are used anywhere in the library — deadlock by construction is impossible. ## Testing & validation -- 139 unit + fuzz-equivalent test cases. +- 160 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 a287470..d06b3ad 100644 --- a/SPEC.md +++ b/SPEC.md @@ -27,7 +27,16 @@ urn:mhda:nt::ci::ct::dt::dp::aa:\^`{|}[]?#``) — is rejected with `ErrInvalidNSS`. + +ASCII whitespace is trimmed only around the whole URN (or around an NSS or +chain key parsed on its own), never around a key or value inside it; a +Unicode space is malformed input, never decoration to strip. The setters and +the `...FromString` helpers follow the same rule: they trim ASCII whitespace +and fold ASCII case only, so a Unicode letter that lowercases to an ASCII one +(the Kelvin sign `K`) is not another spelling. ### 1.6 Wallet domain @@ -109,9 +139,10 @@ 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. +against the §1.5 byte set and return `ErrInvalidValue` otherwise: in +particular 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 and to the chain id. ## 2. Network Catalogue @@ -150,13 +181,18 @@ keeps the family name rather than any flagship chain's name. ### 2.1 Per-network notes #### 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). +Multiple legitimate scripts, so there is no default format and `af` may be +omitted. When given, it must match the script the purpose defines, under +strict validation: `bip44` p2pkh, `bip49` p2sh, `bip84` p2wpkh or bech32, +`bip86` p2tr or bech32m; `bip32` takes any. `ap` is conventionally `1` +(P2PKH), `3` (P2SH), `bc1q` (bech32), `bc1p` (bech32m / Taproot). #### Ethereum and EVM clones (`evm`) Single canonical format (hex). Chain ID typically a numeric chain ID such as -`1`, `0xa86a`, `0x10`. +`1`, `0xa86a`, `0x10`. The chain id is opaque: MHDA does not canonicalise +it, so `ci:1`, `ci:0x1` and `ci:01` are three different chain keys (and +hashes) for one chain. Chain keys compare as strings; a producer must keep to +one spelling for a chain (the EIP-155 decimal form is the usual choice). #### Avalanche (`avalanche`) C-Chain uses hex (EVM-compatible); X/P-Chain use bech32 with HRPs `X-avax`, @@ -165,6 +201,8 @@ C-Chain uses hex (EVM-compatible); X/P-Chain use bech32 with HRPs `X-avax`, #### Cosmos (`cosmos`) 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`. +`bip44` with coin `118'` is the same path as `cip11`, so strict validation +refuses it and keeps the `cip11` spelling; `bip44` with another coin stays. #### Solana (`solana`) SLIP-10 ed25519 only. Common path forms: `m/44'/501'`, `m/44'/501'/account'`, @@ -172,7 +210,9 @@ SLIP-10 ed25519 only. Common path forms: `m/44'/501'`, `m/44'/501'/account'`, #### 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`). +XRPL ed25519 keys come from a family seed, not an HD path, so their URN is +root (`aa:ed25519`, no `dt`); `bip44` with ed25519 fails strict validation +(see §6.2). Addresses use base58 with XRPL's custom alphabet (start with `r`). #### Stellar (`stellar`) SEP-0005 mandates SLIP-10 ed25519 with `m/44'/148'/account'` (3 levels, all @@ -183,11 +223,14 @@ SEP-0023. Common version bytes: `G` (account), `S` (seed), `M` (muxed), #### NEAR (`near`) 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. +are not HD-derived and not represented by MHDA. ed25519 keys derive through +`slip10`, secp256k1 keys through `bip44`; strict validation holds each +derivation type to its curve. #### 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). +Strict validation holds `slip10` to ed25519 and `bip44` to secp256k1. #### Sui (`sui`) The signature scheme is encoded in the `purpose` field of the path: @@ -199,7 +242,9 @@ 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. +0x00 ed25519, 0x01 secp256k1, 0x02 secp256r1. Strict validation holds each +derivation type to its scheme: `slip10` ed25519, `bip54` secp256k1, `bip74` +secp256r1. #### Cardano (`cardano`) BIP32-Ed25519 (extended keys with soft-derivation, distinct from SLIP-10 @@ -210,9 +255,9 @@ CIP-19); Byron-era addresses use base58 and remain on-chain. #### 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 -not Algorand-canonical. +32-byte ed25519 seed directly. Canonical URNs are root (no `dt`/`dp`). Some +third-party wallets layer SLIP-10 at `m/44'/283'/account'/0'/0'`; this is +accepted but is not Algorand-canonical. #### TON (`ton`) Native scheme is non-HD: a 24-word TON-specific mnemonic (different word list @@ -239,7 +284,7 @@ protocol messages. | bip74 | `m/74'/coin'/account'/charge/index[']` | 5 | sui-keys/src/key_derive.rs (Sui secp256r1) | | bip84 | `m/84'/coin'/account'/charge/index[']` | 5 | https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki | | bip86 | `m/86'/coin'/account'/charge/index[']` | 5 | https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki | -| slip10 | `m(/uint32['])+` (variable length) | 1+ | https://github.com/satoshilabs/slips/blob/master/slip-0010.md | +| slip10 | `m(/index['])+` (variable length) | 1-255 | https://github.com/satoshilabs/slips/blob/master/slip-0010.md | | cip11 | `m/44'/118'/account'/charge_extra/index[']` | 5 | https://github.com/confio/cosmos-hd-key-derivation-spec | | cip1852 | `m/1852'/1815'/account'/role/index[']` | 5 | https://github.com/cardano-foundation/CIPs/blob/master/CIP-1852/ | | zip32 | `m/32'/133'/account'[/index[']]` | 3 or 4 | https://zips.z.cash/zip-0032 | @@ -248,11 +293,24 @@ Hardening markers in input: `'`, `H`, or `h` are all accepted and normalised to `'` in canonical output. The trailing `[']` on `index` denotes that the leaf level itself may be hardened. +Each level's index is 0..2^31-1 (at most `2147483647`); the hardened marker +is a separate flag, so `n'` is BIP-32 child number 2^31+n. An index of 2^31 +or more is refused at every level of every derivation type, hardened or not: +it collides with the hardened bit and would name the key of another path. + +Leading zeros are accepted in a variable level and dropped in canonical +output (`m/44'/060'/0'/0/0` is `m/44'/60'/0'/0/0`); the range applies to the +value. A fixed level (the purpose, the fixed coin of `cip11`, `cip1852` and +`zip32`, the `0`/`1` charge of `bip32` and the BIP-44 family) must be spelled +exactly as in the template. + ### 3.1 Variable-length paths -`slip10` accepts any number of levels and is the right type for chains whose +`slip10` accepts 1 to 255 levels and is the right type for chains whose HD derivation does not fit a fixed shape (Solana, Stellar, Aptos ed25519, -Sui ed25519, NEAR, Ledger TON). +Sui ed25519, NEAR, Ledger TON). The upper bound is BIP-32's: a key's depth +is serialised in one byte, so a deeper path is refused with +`ErrInvalidDerivationPath`. `zip32` accepts both 3-level (`m/32'/133'/account'`) and 4-level (`m/32'/133'/account'/index[']`) forms; both round-trip exactly. @@ -263,6 +321,17 @@ Regardless of derivation type, the canonical level-by-level view is exposed via `DerivationPath.Levels()`. For BIP-family schemes the BIP-44 shortcut fields (`Coin`, `Account`, `Charge`, `AddressIndex`) are also populated. +`Levels()` and `String()` always describe the same path. A path built in code +(`NewDerivationPath`, `NewDerivationPathFromLevels`) must be one the parser +would produce from its own `String()`: levels that do not fit the type (a +BIP-44 path with purpose `49'`, an unhardened account, too few levels, a +SLIP-10 path with none) or an index of 2^31 or more panic with +`ErrInvalidDerivationPath`. `Levels()` returns a copy, and `NewAddress` keeps +its own copies of the chain and the path it is given. (C++: the +`derivation_path` constructor and `from_levels` throw +`parse_error(invalid_derivation_path)`; `levels()` returns a const reference +and `address` holds its chain and path by value.) + ## 4. Algorithms | `aa` | Notes | @@ -306,11 +375,16 @@ Performs structural validation only: - Network type is one of the registered values. - 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). +- Values contain no whitespace. ASCII whitespace around the whole URN (or + around an NSS or chain key parsed on its own, or before a stripped r/q/f + component) is trimmed; around a key or value inside the NSS it is + rejected. - 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. +- Derivation path, if present, matches the regex of its derivation type, + and every level's index is below 2^31 (§3). +- Derivation path is present if and only if the derivation type is set and + is not `root` (§1.2). - Algorithm, if present, is one of the registered constants. - Format, if present, is one of the registered constants. @@ -326,6 +400,29 @@ as if rewritten in long form. ROOT is always accepted regardless of network. +Strict validation also refuses combinations no wallet can derive or that +name one key twice: + +- ed25519 with any unhardened level, except under `cip1852`: SLIP-10 + derives ed25519 keys through hardened levels only (CIP-1852 is + BIP32-Ed25519, which has soft derivation). +- A derivation type with a curve other than its own where the network binds + them (Sui, Aptos, NEAR; see §2.1). +- A Bitcoin format that does not match the purpose of the path (§2.1). +- A Cosmos `bip44` path with coin `118'`, which is the `cip11` path. + +These fail with `ErrIncompatible`; lenient parsing still accepts the paths. + +An address built in code can have a derivation type without a path: +`SetDerivationType` with a new type drops the old path, which belongs to the +old scheme. Such an address serialises with `dt` but no `dp`, so its URN does +not parse, and `Validate` / `MarshalText` refuse it with +`ErrInvalidDerivationPath`. `SetDerivation(dt, dp)` sets both at once and +changes nothing unless both are valid. `ParsePath` replaces the whole path, +or leaves it unchanged on error. (C++: `address::set_derivation_type`, +`address::set_derivation`, `derivation_path::set_type` and +`derivation_path::parse_path`, which gives the strong exception guarantee.) + ### 6.3 Round-trip semantics The reference implementation guarantees: @@ -354,9 +451,9 @@ text. | `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 | +| `ErrInvalidValue` | Free-form or chain id value outside the §1.5 byte set | | `ErrInvalidDerivationType` | `dt` value not registered | -| `ErrInvalidDerivationPath` | `dp` does not match the regex of `dt` | +| `ErrInvalidDerivationPath` | `dp` does not fit `dt` (or no `dt`), or an index >= 2^31 | | `ErrInvalidAlgorithm` | `aa` value not registered | | `ErrInvalidFormat` | `af` value not registered | | `ErrIncompatible` | Strict validation: triple not allowed for the network | @@ -406,6 +503,20 @@ with its own factory functions: | `ChainFromNSS(s string)` | Extract the chain domain from any NSS (lenient). | | `ChainFromKey(key)` | Parse a `ChainKey` (alias of string) produced by `Key()`.| +Values set programmatically are written verbatim into every URN and chain +key, so they are validated like parsed input. `NewChain`, +`Chain.SetNetworkType` and `Chain.SetChainId` require a registered network +type and a non-empty chain id (ASCII-trimmed) of printable ASCII without +`:`, `?` or `#`; a `:` would otherwise inject components on re-parse. +`NewChain` panics on an invalid value; the setters return +`ErrInvalidNetworkType`, `ErrMissingChainID` or `ErrInvalidValue` and leave +the chain unchanged. `NewDerivationPath` and `NewDerivationPathFromLevels` +panic with `ErrInvalidDerivationType` on an unregistered derivation type. +(C++: the `chain` constructor, `chain::set_network` and +`chain::set_chain_id` throw `parse_error` with the matching code and leave +the chain unchanged; the `derivation_path` constructor, `from_levels` and +`set_type` throw `parse_error(invalid_derivation_type)`.) + `Chain.String()` and `Chain.Key()` both return the canonical 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 @@ -440,7 +551,9 @@ This automatically provides: - Any framework that consults `TextMarshaler` for value serialisation. `MarshalText` returns the canonical URN form. `UnmarshalText` accepts any -valid input (lenient mode). +valid input (lenient mode). `MarshalText` has a value receiver, so an +`Address` held by value (a struct field, a map value) encodes as its URN as +well; a nil `*Address` is encoded by the codec itself (JSON `null`). A `database/sql` adapter is not currently provided; callers can wrap `MarshalText`/`UnmarshalText` in their own `driver.Valuer` / `sql.Scanner`. @@ -456,13 +569,19 @@ The C++ port exposes the same pair as `address::marshal_text` / model. A future `dt:substrate` could be added if needed. - **Algorand** and **TON** native schemes are not hierarchical. Their - canonical URN form uses `dt:root` (no `dp`). HD forms (`m/44'/283'/...` / + canonical URN form is root (no `dt`, no `dp`). HD forms (`m/44'/283'/...` / `m/44'/607'/...`) are wallet-specific layering on top, not protocol-canonical. - **Sui** uses the `purpose` field of the derivation path to encode the signature scheme (44'/54'/74'). This is supported via three distinct derivation types (`bip44`, `bip54`, `bip74`). +- **ZIP-32** (`dt:zip32`) parses, but no network registers it: Zcash is not + a registered network type, and its shielded keys use curves (Jubjub, + Pallas) outside the algorithm catalogue. `ParseURNStrict` therefore + refuses every `zip32` URN with `ErrIncompatible`; only lenient parsing + accepts one. + - **Hardening markers** are accepted in three input forms (`'`, `H`, `h`) but always serialised canonically as `'`. Round-trip `Parse(s).String() == s` therefore holds only for inputs already in diff --git a/include/mhda/address.hpp b/include/mhda/address.hpp index 83ad07d..1e14125 100644 --- a/include/mhda/address.hpp +++ b/include/mhda/address.hpp @@ -71,12 +71,24 @@ class address { // set_derivation_type sets the path's derivation type; an empty string // resolves to ROOT. Allocates a fresh derivation_path if none was attached. + // A type other than the current one drops the current path, which + // belongs to the old scheme: until set_derivation_path sets a new one + // the address has no path, its URN carries dt without dp, and validate / + // marshal_text throw parse_error(invalid_derivation_path). Setting the + // current type again keeps the path. Use set_derivation to change both. void set_derivation_type(std::string_view dt); - // set_derivation_path validates and applies the textual path. If the path's - // derivation type is ROOT this is a silent no-op. Throws parse_error. + // set_derivation_path validates and applies the textual path. A root + // address has no path: an empty (or all-whitespace) dp is a no-op, any + // other dp throws parse_error(invalid_derivation_path). Throws + // parse_error. void set_derivation_path(std::string_view dp); + // set_derivation sets the derivation type and path together, as a parsed + // URN does: both are validated first, and the address changes only if + // both are valid. An empty dt means ROOT, which takes an empty dp. + void set_derivation(std::string_view dt, std::string_view dp); + // 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. @@ -136,6 +148,10 @@ class address { std::string nss_hash256() const; private: + // check_path_set throws parse_error(invalid_derivation_path) for a + // derivation type set without a path (see set_derivation_type). + void check_path_set() const; + chain chain_; std::optional path_; algorithm algorithm_; diff --git a/include/mhda/algorithm.hpp b/include/mhda/algorithm.hpp index b729cee..6c607de 100644 --- a/include/mhda/algorithm.hpp +++ b/include/mhda/algorithm.hpp @@ -33,6 +33,18 @@ class algorithm { std::string value_; }; +// The named constants are inline variables defined in this header: every +// translation unit that names one includes their definitions, so they are +// initialised before any namespace-scope object defined after the include, +// and a consumer's global constructors and destructors can use them. +inline const algorithm algorithm::secp256k1 {"secp256k1"}; +inline const algorithm algorithm::ed25519 {"ed25519"}; +inline const algorithm algorithm::sr25519 {"sr25519"}; +inline const algorithm algorithm::secp256r1 {"secp256r1"}; +inline const algorithm algorithm::secp384r1 {"secp384r1"}; +inline const algorithm algorithm::secp521r1 {"secp521r1"}; +inline const algorithm algorithm::prime256v1 {"prime256v1"}; + // algorithm_from_string parses a string into an algorithm. Lookup is // case-insensitive; surrounding whitespace is stripped. Returns an empty // optional for unknown values. diff --git a/include/mhda/chain.hpp b/include/mhda/chain.hpp index 51a27c8..ca372c4 100644 --- a/include/mhda/chain.hpp +++ b/include/mhda/chain.hpp @@ -33,8 +33,14 @@ using chain_key = std::string; class chain { public: chain() = default; - chain(network_type nt, chain_id ci) - : network_(std::move(nt)), chain_id_(std::move(ci)) {} + + // The network type and the chain id are written verbatim into every URN + // and chain key, so they are validated like parsed input: the network + // type must be registered (error_code::invalid_network_type), and the + // chain id, ASCII-trimmed, must be non-empty (missing_chain_id) printable + // ASCII without ':', '?' or '#' (invalid_value) - a ':' would inject + // components on re-parse. Throws parse_error. + chain(network_type nt, chain_id ci); // from_key parses a chain key produced by chain::key(). A chain key is // the canonical identity form "nt::ci:" and nothing @@ -49,11 +55,14 @@ class chain { // 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. + // tolerated and ignored, so a full address NSS is valid input. Surrounding + // ASCII whitespace is trimmed; whitespace inside the NSS is refused. static chain from_nss(std::string_view nss); - void set_network(network_type nt) { network_ = std::move(nt); } - void set_chain_id(chain_id ci) { chain_id_ = std::move(ci); } + // set_network / set_chain_id validate like the constructor and throw + // parse_error; on error the chain is left unchanged. + void set_network(network_type nt); + void set_chain_id(chain_id ci); // set_coin attaches the optional SLIP-44 coin-type metadata. void set_coin(coin_type ct) { coin_ = ct; } diff --git a/include/mhda/coin_type.hpp b/include/mhda/coin_type.hpp index 5db5ae1..be74597 100644 --- a/include/mhda/coin_type.hpp +++ b/include/mhda/coin_type.hpp @@ -7,9 +7,19 @@ namespace mhda { // CoinType is a SLIP-44 coin type (32-bit unsigned integer). using coin_type = std::uint32_t; +// (minwindef.h) defines `near` as an empty macro, which would +// turn coins::near below into a syntax error. It is set aside for this list +// and restored afterwards; while it is in effect, use coins::near_protocol. +#if defined(near) +#pragma push_macro("near") +#undef near +#define MHDA_COIN_TYPE_RESTORE_NEAR +#endif + // SLIP-44 registered coin types, ascending by index // (https://github.com/satoshilabs/slips/blob/master/slip-0044.md). -// Matches go-mhda/coin_type.go. +// Matches go-mhda/coin_type.go. Several names (move, near) are common words: +// qualify them (coins::move) rather than `using namespace mhda::coins`. namespace coins { constexpr coin_type btc = 0; @@ -35,6 +45,7 @@ constexpr coin_type zil = 313; constexpr coin_type luna = 330; constexpr coin_type dot = 354; constexpr coin_type near = 397; +constexpr coin_type near_protocol = near; // usable under constexpr coin_type ksm = 434; constexpr coin_type kava = 459; constexpr coin_type fil = 461; @@ -75,4 +86,9 @@ constexpr coin_type mon = 268435779; } // namespace coins +#if defined(MHDA_COIN_TYPE_RESTORE_NEAR) +#pragma pop_macro("near") +#undef MHDA_COIN_TYPE_RESTORE_NEAR +#endif + } // namespace mhda diff --git a/include/mhda/derivation_path.hpp b/include/mhda/derivation_path.hpp index fa3aa7a..b8f366c 100644 --- a/include/mhda/derivation_path.hpp +++ b/include/mhda/derivation_path.hpp @@ -11,13 +11,17 @@ namespace mhda { using account_index = std::uint32_t; -using charge_type = std::uint8_t; +// charge_type is the level after the account: the change level of BIP-32 and +// the BIP-44 family (0 external, 1 internal), the CIP-11 charge and the +// CIP-1852 role. It is as wide as a level index: the CIP-11 charge and the +// CIP-1852 role take any index. +using charge_type = std::uint32_t; constexpr charge_type charge_external = 0; constexpr charge_type charge_internal = 1; -// address_index represents a single level of a derivation path (an unsigned -// 32-bit index plus a hardening flag). +// address_index represents a single level of a derivation path: the index, +// below 2^31 in every parsed path, plus the separate hardening flag. struct address_index { std::uint32_t index = 0; bool is_hardened = false; @@ -47,6 +51,11 @@ class derivation_path { // Construct a path for a fixed-shape BIP-family scheme. SLIP-10 cannot be // reconstructed from the five shortcut fields; passing it here throws // std::invalid_argument (mirroring the panic in the Go implementation). + // An unregistered derivation type throws + // parse_error(invalid_derivation_type): the type is written verbatim + // into the URN. The result is the path parse() gives back for its own + // str(); values that do not form a valid path (charge 7 in a BIP-44 + // path, an index of 2^31) throw parse_error(invalid_derivation_path). derivation_path(derivation_type dt, coin_type coin, account_index account, @@ -55,7 +64,14 @@ class derivation_path { // Construct a path from an explicit sequence of levels. Required for // SLIP-10 and a convenient alternative for any scheme. For fixed-shape - // schemes the shortcut fields are populated from the levels. + // schemes the shortcut fields are populated from the levels. An + // unregistered derivation type throws parse_error(invalid_derivation_type). + // The result is the path parse() gives back for its own str(), and it + // must have exactly the given levels: levels that do not form a path of + // the type (purpose 49' for bip44, an unhardened account, too few levels, + // none for slip10, an index of 2^31) throw + // parse_error(invalid_derivation_path), so levels() and str() never + // disagree. static derivation_path from_levels(derivation_type dt, std::vector levels); @@ -79,14 +95,21 @@ class derivation_path { std::string str() const; // parse_path replaces the contents of this path with the result of parsing - // the given string under the path's current derivation type. Throws - // parse_error on failure. The derivation type must already be set (via - // construction or set_type); for ROOT, path must be empty. + // the given string under the path's current derivation type. The whole + // path is replaced, so nothing of a previous path survives; on failure it + // throws parse_error and leaves the path unchanged. The derivation type + // must already be set (via construction or set_type); for ROOT, path must + // be empty. void parse_path(std::string_view path); + // set_type throws parse_error(invalid_derivation_type) for an + // unregistered type. A type other than the current one clears the path, + // which belongs to the old scheme: str() is empty until parse_path sets a + // new one. Setting the current type again keeps the path. void set_type(const derivation_type& dt); private: + void parse_fresh(std::string_view path); void rebuild_levels(); void populate_shortcuts_from_levels(); bool fixed_prefix(std::uint32_t& purpose, std::uint32_t& coin) const; diff --git a/include/mhda/derivation_type.hpp b/include/mhda/derivation_type.hpp index 1249c77..f06a8d8 100644 --- a/include/mhda/derivation_type.hpp +++ b/include/mhda/derivation_type.hpp @@ -38,6 +38,23 @@ class derivation_type { std::string value_; }; +// The named constants are inline variables defined in this header: every +// translation unit that names one includes their definitions, so they are +// initialised before any namespace-scope object defined after the include, +// and a consumer's global constructors and destructors can use them. +inline const derivation_type derivation_type::root {"root"}; +inline const derivation_type derivation_type::bip32 {"bip32"}; +inline const derivation_type derivation_type::bip44 {"bip44"}; +inline const derivation_type derivation_type::bip49 {"bip49"}; +inline const derivation_type derivation_type::bip54 {"bip54"}; +inline const derivation_type derivation_type::bip74 {"bip74"}; +inline const derivation_type derivation_type::bip84 {"bip84"}; +inline const derivation_type derivation_type::bip86 {"bip86"}; +inline const derivation_type derivation_type::slip10 {"slip10"}; +inline const derivation_type derivation_type::cip1852 {"cip1852"}; +inline const derivation_type derivation_type::cip11 {"cip11"}; +inline const derivation_type derivation_type::zip32 {"zip32"}; + // derivation_type_from_string parses a string into a derivation_type. Lookup // is case-insensitive; surrounding whitespace is stripped. Throws parse_error // with code error_code::invalid_derivation_type for unknown values. diff --git a/include/mhda/format.hpp b/include/mhda/format.hpp index fae75b7..41c7eae 100644 --- a/include/mhda/format.hpp +++ b/include/mhda/format.hpp @@ -39,6 +39,24 @@ class format { std::string value_; }; +// The named constants are inline variables defined in this header: every +// translation unit that names one includes their definitions, so they are +// initialised before any namespace-scope object defined after the include, +// and a consumer's global constructors and destructors can use them. +inline const format format::hex {"hex"}; +inline const format format::p2pkh {"p2pkh"}; +inline const format format::p2sh {"p2sh"}; +inline const format format::p2wpkh {"p2wpkh"}; +inline const format format::p2wsh {"p2wsh"}; +inline const format format::p2tr {"p2tr"}; +inline const format format::bech32 {"bech32"}; +inline const format format::bech32m {"bech32m"}; +inline const format format::base58 {"base58"}; +inline const format format::base32 {"base32"}; +inline const format format::strkey {"strkey"}; +inline const format format::base64url {"base64url"}; +inline const format format::ss58 {"ss58"}; + // format_from_string parses a string into a format. Lookup is case-insensitive; // surrounding whitespace is stripped. Returns an empty optional for unknown // values. diff --git a/include/mhda/network_type.hpp b/include/mhda/network_type.hpp index 6b01b89..565172c 100644 --- a/include/mhda/network_type.hpp +++ b/include/mhda/network_type.hpp @@ -41,6 +41,29 @@ class network_type { std::string value_; }; +// The named constants are inline variables defined in this header: every +// translation unit that names one includes their definitions, so they are +// initialised before any namespace-scope object defined after the include, +// and a consumer's global constructors and destructors can use them. +// +// 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. +inline const network_type network_type::bitcoin {"bitcoin"}; +inline const network_type network_type::ethereum_vm {"evm"}; +inline const network_type network_type::avalanche_vm {"avalanche"}; +inline const network_type network_type::tron_vm {"tron"}; +inline const network_type network_type::cosmos {"cosmos"}; +inline const network_type network_type::solana {"solana"}; +inline const network_type network_type::xrp_ledger {"xrpl"}; +inline const network_type network_type::stellar {"stellar"}; +inline const network_type network_type::near_protocol {"near"}; +inline const network_type network_type::aptos {"aptos"}; +inline const network_type network_type::sui {"sui"}; +inline const network_type network_type::cardano {"cardano"}; +inline const network_type network_type::algorand {"algorand"}; +inline const network_type network_type::toncoin {"ton"}; + // network_type_from_string parses a string into a network_type. Lookup is // case-insensitive; surrounding whitespace is stripped. Returns an empty // optional for unknown values. diff --git a/include/mhda/parser.hpp b/include/mhda/parser.hpp index b445081..72bd413 100644 --- a/include/mhda/parser.hpp +++ b/include/mhda/parser.hpp @@ -23,7 +23,8 @@ address parse_urn(std::string_view src); address parse_urn_strict(std::string_view src); // parse_nss parses an MHDA namespace-specific string (the substring after -// "urn:mhda:"). The "nt" component is required. +// "urn:mhda:"). The "nt" component is required. Surrounding ASCII whitespace +// is trimmed; whitespace inside the NSS is refused. address parse_nss(std::string_view nss); } // namespace mhda diff --git a/src/address.cpp b/src/address.cpp index 08dfc44..223133f 100644 --- a/src/address.cpp +++ b/src/address.cpp @@ -22,24 +22,6 @@ 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, @@ -74,25 +56,47 @@ format address::resolved_format() const { void address::set_derivation_type(std::string_view dt) { auto trimmed = detail::trim(dt); auto lowered = detail::to_lower(trimmed); - if (!path_) path_.emplace(); - if (lowered.empty()) { - path_->set_type(derivation_type::root); - return; + derivation_type next = derivation_type::root; + if (!lowered.empty()) { + next = derivation_type{lowered}; + if (!next.is_valid()) { + throw parse_error(error_code::invalid_derivation_type, + detail::quote(lowered)); + } } - derivation_type next{lowered}; - if (!next.is_valid()) { - throw parse_error(error_code::invalid_derivation_type, - std::string{"\""} + lowered + "\""); + if (!path_) path_.emplace(); + path_->set_type(next); // a new type drops the old path +} + +void address::set_derivation(std::string_view dt, std::string_view dp) { + address scratch; + scratch.set_derivation_type(dt); + scratch.set_derivation_path(dp); + path_ = std::move(scratch.path_); +} + +void address::check_path_set() const { + if (path_ && !path_->type().empty() && path_->type() != derivation_type::root && + path_->levels().empty()) { + throw parse_error(error_code::invalid_derivation_path, + std::string{"derivation type \""} + path_->type().str() + + "\" is set without a path"); } - path_->set_type(next); } void address::set_derivation_path(std::string_view dp) { if (!path_) path_.emplace(); + auto trimmed = detail::trim(dp); if (path_->type() == derivation_type::root || path_->type().empty()) { - return; // root has no path; silent no-op matches Go semantics + // A root address has no path. Dropping one instead would let a URN + // with a dp but no dt (which parses as root) name the root key rather + // than the path it spells out. Mirrors go-mhda. + if (!trimmed.empty()) { + throw parse_error(error_code::invalid_derivation_path, + std::string{"root derivation must have empty path, got "} + detail::quote(trimmed)); + } + return; } - auto trimmed = detail::trim(dp); auto lowered = detail::to_lower(trimmed); path_->parse_path(lowered); } @@ -106,7 +110,7 @@ void address::set_coin_type(std::string_view ct) { std::uint32_t v = 0; if (!detail::parse_uint32(trimmed, v)) { throw parse_error(error_code::invalid_coin_type, - std::string{"\""} + std::string{trimmed} + "\""); + detail::quote(trimmed)); } chain_.set_coin(v); } @@ -121,7 +125,7 @@ void address::set_address_algorithm(std::string_view aa) { algorithm next{lowered}; if (!next.is_valid()) { throw parse_error(error_code::invalid_algorithm, - std::string{"\""} + lowered + "\""); + detail::quote(lowered)); } algorithm_ = std::move(next); } @@ -136,32 +140,32 @@ void address::set_address_format(std::string_view af) { format next{lowered}; if (!next.is_valid()) { throw parse_error(error_code::invalid_format, - std::string{"\""} + lowered + "\""); + detail::quote(lowered)); } format_ = std::move(next); } void address::set_address_prefix(std::string_view ap) { auto trimmed = detail::trim(ap); - validate_free_form_value(detail::comp_address_prefix, trimmed); + detail::validate_free_form_value(detail::comp_address_prefix, trimmed); prefix_ = std::string{trimmed}; } void address::set_address_suffix(std::string_view as) { auto trimmed = detail::trim(as); - validate_free_form_value(detail::comp_address_suffix, trimmed); + detail::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); + detail::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); + detail::validate_free_form_value(detail::comp_wallet_id, trimmed); wallet_id_ = std::string{trimmed}; } @@ -188,11 +192,16 @@ std::string address::nss() const { } // Derivation domain — present when not ROOT and a non-empty type is set. + // A type set without a path yet is emitted without dp, so the URN fails + // to parse rather than name another key. if (path_ && !path_->type().empty() && path_->type() != derivation_type::root) { out += ":dt:"; out += path_->type().str(); - out += ":dp:"; - out += path_->str(); + const std::string p = path_->str(); + if (!p.empty()) { + out += ":dp:"; + out += p; + } } // Address-format metadata — emitted only when explicitly set. @@ -235,6 +244,7 @@ std::string address::marshal_text() const { if (chain_.network().empty()) { throw parse_error(error_code::uninitialized_address); } + check_path_set(); return str(); } @@ -247,15 +257,16 @@ void address::validate() const { if (nt.empty()) { throw parse_error(error_code::uninitialized_address); } + check_path_set(); if (!detail::network_is_registered(nt)) { throw parse_error(error_code::incompatible, - std::string{"unknown network type \""} + nt.str() + "\""); + std::string{"unknown network type "} + detail::quote(nt.str())); } auto algo = resolved_algorithm(); if (algo.empty()) { throw parse_error(error_code::incompatible, - std::string{"no algorithm resolved for network \""} + nt.str() + "\""); + std::string{"no algorithm resolved for network "} + detail::quote(nt.str())); } if (!detail::network_allows_algorithm(nt, algo)) { throw parse_error(error_code::incompatible, @@ -271,13 +282,45 @@ void address::validate() const { } // ROOT (no derivation path) is always permitted. - if (path_ && !path_->type().empty() && path_->type() != derivation_type::root) { - if (!detail::network_allows_derivation(nt, path_->type())) { - throw parse_error(error_code::incompatible, - std::string{"derivation \""} + path_->type().str() + - "\" not allowed for network \"" + nt.str() + "\""); + if (!path_ || path_->type().empty() || path_->type() == derivation_type::root) return; + const derivation_type& dt = path_->type(); + if (!detail::network_allows_derivation(nt, dt)) { + throw parse_error(error_code::incompatible, + std::string{"derivation \""} + dt.str() + + "\" not allowed for network \"" + nt.str() + "\""); + } + const algorithm want = detail::derivation_algorithm(nt, dt); + if (!want.empty() && want != algo) { + throw parse_error(error_code::incompatible, + std::string{"derivation \""} + dt.str() + "\" derives \"" + want.str() + + "\" keys on network \"" + nt.str() + "\", not \"" + algo.str() + "\""); + } + if (!fmt.empty() && !detail::derivation_allows_format(nt, dt, fmt)) { + throw parse_error(error_code::incompatible, + std::string{"format \""} + fmt.str() + + "\" does not match the purpose of derivation \"" + dt.str() + + "\" on network \"" + nt.str() + "\""); + } + // SLIP-10 derives ed25519 keys through hardened levels only; CIP-1852 + // (BIP32-Ed25519) is the one ed25519 scheme with soft derivation. + if (algo == algorithm::ed25519 && dt != derivation_type::cip1852) { + const auto& lvls = path_->levels(); + for (std::size_t i = 0; i < lvls.size(); ++i) { + if (!lvls[i].is_hardened) { + throw parse_error(error_code::incompatible, + "ed25519 derives hardened levels only, level " + + std::to_string(i) + " of \"" + path_->str() + + "\" is not hardened"); + } } } + // On Cosmos, bip44 with coin 118' is the cip11 path under another name; + // strict mode keeps the one spelling. + if (nt == network_type::cosmos && dt == derivation_type::bip44 && + path_->coin() == coins::atom) { + throw parse_error(error_code::incompatible, + "\"bip44\" with coin 118' is the cip11 path, use dt:cip11"); + } } std::string address::hash() const { diff --git a/src/algorithm.cpp b/src/algorithm.cpp index a1e20d5..b923c57 100644 --- a/src/algorithm.cpp +++ b/src/algorithm.cpp @@ -6,22 +6,16 @@ namespace mhda { -const algorithm algorithm::secp256k1 {"secp256k1"}; -const algorithm algorithm::ed25519 {"ed25519"}; -const algorithm algorithm::sr25519 {"sr25519"}; -const algorithm algorithm::secp256r1 {"secp256r1"}; -const algorithm algorithm::secp384r1 {"secp384r1"}; -const algorithm algorithm::secp521r1 {"secp521r1"}; -const algorithm algorithm::prime256v1 {"prime256v1"}; - namespace { const std::unordered_set& algo_index() { - static const std::unordered_set index = { + // Allocated once and never destroyed: a consumer's global destructor may + // still parse after static destruction has begun. + static const auto* index = new std::unordered_set{ "secp256k1", "ed25519", "sr25519", "secp256r1", "secp384r1", "secp521r1", "prime256v1", }; - return index; + return *index; } } // namespace diff --git a/src/chain.cpp b/src/chain.cpp index 45091af..53c30bf 100644 --- a/src/chain.cpp +++ b/src/chain.cpp @@ -25,7 +25,7 @@ chain build_chain(const std::unordered_map& m) { network_type nt{nt_norm}; if (!nt.is_valid()) { throw parse_error(error_code::invalid_network_type, - std::string{"\""} + nt_norm + "\""); + detail::quote(nt_norm)); } auto ci_it = m.find(std::string{detail::comp_chain_id}); @@ -45,7 +45,7 @@ chain build_chain(const std::unordered_map& m) { 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} + "\""); + detail::quote(ct_trim)); } out.set_coin(ct); } @@ -61,8 +61,30 @@ chain build_chain_from_components(const std::unordered_map derivations; algorithm default_algorithm; format default_format; + // derivation_algorithm binds a derivation type to the one algorithm it + // derives on this network; unbound types take any listed algorithm. + std::unordered_map derivation_algorithm = {}; + // derivation_formats binds a derivation type to the formats its purpose + // defines, checked when a format is resolved; unbound types take any. + std::unordered_map> derivation_formats = {}; }; const std::unordered_map& matrix() { - static const std::unordered_map cache = []() { + // Allocated once and never destroyed: a consumer's global destructor may + // still parse after static destruction has begun. + static const auto* cache = new std::unordered_map([]() { std::unordered_map m; // Bitcoin m.emplace(network_type::bitcoin, network_compat{ @@ -29,6 +37,15 @@ const std::unordered_map& matrix() { derivation_type::bip86}, algorithm::secp256k1, format{}, // no default — multiple legitimate scripts + {}, + // The purpose defines the script, so an explicit format must + // match it. + { + {derivation_type::bip44, {format::p2pkh}}, + {derivation_type::bip49, {format::p2sh}}, + {derivation_type::bip84, {format::p2wpkh, format::bech32}}, + {derivation_type::bip86, {format::p2tr, format::bech32m}}, + }, }); // EthereumVM m.emplace(network_type::ethereum_vm, network_compat{ @@ -93,6 +110,8 @@ const std::unordered_map& matrix() { {derivation_type::slip10, derivation_type::bip44}, algorithm::ed25519, format::hex, + {{derivation_type::slip10, algorithm::ed25519}, + {derivation_type::bip44, algorithm::secp256k1}}, }); // Aptos m.emplace(network_type::aptos, network_compat{ @@ -101,6 +120,8 @@ const std::unordered_map& matrix() { {derivation_type::slip10, derivation_type::bip44}, algorithm::ed25519, format::hex, + {{derivation_type::slip10, algorithm::ed25519}, + {derivation_type::bip44, algorithm::secp256k1}}, }); // Sui m.emplace(network_type::sui, network_compat{ @@ -109,6 +130,9 @@ const std::unordered_map& matrix() { {derivation_type::slip10, derivation_type::bip54, derivation_type::bip74}, algorithm::ed25519, format::hex, + {{derivation_type::slip10, algorithm::ed25519}, + {derivation_type::bip54, algorithm::secp256k1}, + {derivation_type::bip74, algorithm::secp256r1}}, }); // Cardano m.emplace(network_type::cardano, network_compat{ @@ -135,8 +159,8 @@ const std::unordered_map& matrix() { format::base64url, }); return m; - }(); - return cache; + }()); + return *cache; } } // namespace @@ -169,6 +193,22 @@ bool network_allows_format(const network_type& nt, const format& fmt) { return it->second.formats.find(fmt) != it->second.formats.end(); } +algorithm derivation_algorithm(const network_type& nt, const derivation_type& dt) { + auto it = matrix().find(nt); + if (it == matrix().end()) return algorithm{}; + auto bound = it->second.derivation_algorithm.find(dt); + if (bound == it->second.derivation_algorithm.end()) return algorithm{}; + return bound->second; +} + +bool derivation_allows_format(const network_type& nt, const derivation_type& dt, const format& fmt) { + auto it = matrix().find(nt); + if (it == matrix().end()) return true; + auto bound = it->second.derivation_formats.find(dt); + if (bound == it->second.derivation_formats.end()) return true; + return bound->second.find(fmt) != bound->second.end(); +} + bool network_allows_derivation(const network_type& nt, const derivation_type& dt) { auto it = matrix().find(nt); if (it == matrix().end()) return false; diff --git a/src/compatibility.hpp b/src/compatibility.hpp index 51589e9..1cdfe4f 100644 --- a/src/compatibility.hpp +++ b/src/compatibility.hpp @@ -29,6 +29,16 @@ bool network_allows_format(const network_type& nt, const format& fmt); // derivation scheme. ROOT is considered valid for every registered network. bool network_allows_derivation(const network_type& nt, const derivation_type& dt); +// derivation_algorithm returns the one algorithm dt derives on nt (Sui: +// slip10 ed25519, bip54 secp256k1, bip74 secp256r1; Aptos and NEAR: slip10 +// ed25519, bip44 secp256k1), or an empty algorithm when nt does not bind dt. +algorithm derivation_algorithm(const network_type& nt, const derivation_type& dt); + +// derivation_allows_format reports whether fmt is a script the purpose of dt +// defines on nt (Bitcoin: bip44 p2pkh, bip49 p2sh, bip84 p2wpkh or bech32, +// bip86 p2tr or bech32m); true when nt does not bind dt to formats. +bool derivation_allows_format(const network_type& nt, const derivation_type& dt, const format& fmt); + // network_is_registered reports whether the network type is in the // compatibility matrix at all. bool network_is_registered(const network_type& nt); diff --git a/src/derivation_path.cpp b/src/derivation_path.cpp index 2609c8f..7d0b235 100644 --- a/src/derivation_path.cpp +++ b/src/derivation_path.cpp @@ -15,6 +15,10 @@ namespace { struct raw_level { std::uint32_t index; bool is_hardened; + // exact is false when the digits carry a leading zero ("060"). A variable + // level accepts that; a fixed level (purpose, fixed coin, 0|1 charge) is + // a literal in the Go reference's regexes and must be spelled exactly. + bool exact; }; // is_hardening_marker reports whether c is one of the accepted hardening @@ -24,7 +28,9 @@ bool is_hardening_marker(char c) noexcept { } // parse_segment decomposes a single path segment into (numeric_part, hardened -// flag). Returns false on empty/non-numeric input or 32-bit overflow. +// flag). Returns false on empty/non-numeric input or an index of 2^31 or +// more: the hardened flag is the top bit of a BIP-32 child number, so a larger +// index would name another level's key. Applies to every level of every type. bool parse_segment(std::string_view seg, raw_level& out) noexcept { bool hardened = false; if (!seg.empty() && is_hardening_marker(seg.back())) { @@ -32,9 +38,10 @@ bool parse_segment(std::string_view seg, raw_level& out) noexcept { seg.remove_suffix(1); } std::uint32_t value = 0; - if (!detail::parse_uint32_dec(seg, value)) return false; + if (!detail::parse_uint31_dec(seg, value)) return false; out.index = value; out.is_hardened = hardened; + out.exact = seg.size() == 1 || seg[0] != '0'; return true; } @@ -67,14 +74,18 @@ bool parse_levels(std::string_view path, std::vector& levels) { return true; } -// expect_purpose verifies that level[0] is the given purpose (hardened). +// expect_purpose verifies that level[0] is the given purpose (hardened, +// spelled exactly). bool expect_purpose(const std::vector& lvls, std::uint32_t purpose) { - return !lvls.empty() && lvls[0].index == purpose && lvls[0].is_hardened; + return !lvls.empty() && lvls[0].index == purpose && lvls[0].is_hardened && + lvls[0].exact; } -// expect_coin verifies that level[1] is the given coin (hardened). +// expect_coin verifies that level[1] is the given coin (hardened, spelled +// exactly). bool expect_coin(const std::vector& lvls, std::uint32_t coin) { - return lvls.size() >= 2 && lvls[1].index == coin && lvls[1].is_hardened; + return lvls.size() >= 2 && lvls[1].index == coin && lvls[1].is_hardened && + lvls[1].exact; } // validate_bip44_family validates the 5-level BIP-44-shaped layout: @@ -91,6 +102,7 @@ bool validate_bip44_family(const std::vector& lvls, if (!lvls[2].is_hardened) return false; // account hardened if (lvls[3].is_hardened) return false; // charge soft if (lvls[3].index > 1) return false; // charge ∈ {0,1} + if (!lvls[3].exact) return false; // charge spelled 0 or 1 return true; // index leaf: arbitrary, hardening optional } @@ -100,11 +112,16 @@ bool validate_bip32(const std::vector& lvls) { if (!lvls[0].is_hardened) return false; if (lvls[1].is_hardened) return false; if (lvls[1].index > 1) return false; + if (!lvls[1].exact) return false; return true; } +// max_slip10_depth is the deepest SLIP-10 path: BIP-32 serialises a key's +// depth in one byte. +constexpr std::size_t max_slip10_depth = 255; + bool validate_slip10(const std::vector& lvls) { - return !lvls.empty(); // any number of levels >= 1 + return !lvls.empty() && lvls.size() <= max_slip10_depth; } bool validate_cip11(const std::vector& lvls) { @@ -155,6 +172,21 @@ bool validate_levels_for(const derivation_type& dt, const std::vector return false; } +// reparsed returns the path the parser gives back for dp.str() and throws +// parse_error(invalid_derivation_path) unless it has exactly the given +// levels. A constructed path is then identical to a parsed one: its URN +// parses, and levels() never disagrees with str() (a BIP-44 path given +// purpose 49', an unhardened account, too few levels or an index of 2^31 is +// refused). Mirrors go-mhda. +derivation_path reparsed(const derivation_path& dp, const std::vector& levels) { + derivation_path out = derivation_path::parse(dp.type(), dp.str()); + if (out.levels() != levels) { + throw parse_error(error_code::invalid_derivation_path, + "levels do not form a " + dp.type().str() + " path"); + } + return out; +} + } // namespace bool validate_derivation_path(const derivation_type& dt, std::string_view path) { @@ -175,27 +207,36 @@ derivation_path::derivation_path(derivation_type dt, account_(account), charge_(charge), index_(index) { + if (!type_.is_valid()) { + throw parse_error(error_code::invalid_derivation_type, + detail::quote(type_.str())); + } if (type_ == derivation_type::slip10) { throw std::invalid_argument( "mhda: NewDerivationPath cannot construct SLIP10 paths; use derivation_path::from_levels"); } has_index_ = (type_ != derivation_type::root); rebuild_levels(); + *this = reparsed(*this, levels_); } derivation_path derivation_path::from_levels(derivation_type dt, std::vector levels) { + if (!dt.is_valid()) { + throw parse_error(error_code::invalid_derivation_type, + detail::quote(dt.str())); + } derivation_path dp; dp.type_ = std::move(dt); dp.levels_ = std::move(levels); dp.populate_shortcuts_from_levels(); - return dp; + return reparsed(dp, dp.levels_); } derivation_path derivation_path::parse(derivation_type dt, std::string_view path) { if (!dt.is_valid()) { throw parse_error(error_code::invalid_derivation_type, - std::string{"\""} + dt.str() + "\""); + detail::quote(dt.str())); } derivation_path dp; dp.type_ = std::move(dt); @@ -204,19 +245,36 @@ derivation_path derivation_path::parse(derivation_type dt, std::string_view path } void derivation_path::set_type(const derivation_type& dt) { - type_ = dt; + if (!dt.is_valid()) { + throw parse_error(error_code::invalid_derivation_type, + detail::quote(dt.str())); + } + if (dt == type_) return; + // The old levels and shortcuts belong to the old scheme. + derivation_path fresh; + fresh.type_ = dt; + *this = std::move(fresh); } void derivation_path::parse_path(std::string_view path) { + // Parse into a fresh path and replace this one only on success: nothing + // of the previous path survives, and an error (bad_alloc included) + // leaves it unchanged. + derivation_path fresh; + fresh.type_ = type_; + fresh.parse_fresh(path); + *this = std::move(fresh); +} + +void derivation_path::parse_fresh(std::string_view path) { if (!type_.is_valid()) { throw parse_error(error_code::invalid_derivation_type, - std::string{"\""} + type_.str() + "\""); + detail::quote(type_.str())); } if (type_ == derivation_type::root) { if (!path.empty()) { throw parse_error(error_code::invalid_derivation_path, - std::string{"root derivation must have empty path, got \""} - + std::string{path} + "\""); + std::string{"root derivation must have empty path, got "} + detail::quote(path)); } levels_.clear(); has_index_ = false; @@ -226,7 +284,7 @@ void derivation_path::parse_path(std::string_view path) { std::vector lvls; if (!parse_levels(path, lvls) || !validate_levels_for(type_, lvls)) { throw parse_error(error_code::invalid_derivation_path, - std::string{"\""} + std::string{path} + "\""); + detail::quote(path)); } if (type_ == derivation_type::bip32) { @@ -279,7 +337,7 @@ void derivation_path::parse_path(std::string_view path) { } } else { throw parse_error(error_code::invalid_derivation_type, - std::string{"\""} + type_.str() + "\""); + detail::quote(type_.str())); } rebuild_levels(); @@ -400,7 +458,10 @@ std::string format_levels(const std::vector& lvls) { } // namespace std::string derivation_path::str() const { - if (type_ == derivation_type::root) return ""; + // Root has no levels; a path whose type is set but which has none yet + // (none was parsed or constructed) is empty too, rather than the zero + // shortcuts of its type. + if (levels_.empty()) return ""; if (type_ == derivation_type::slip10) return format_levels(levels_); if (type_ == derivation_type::bip32) { std::string out = "m/"; diff --git a/src/derivation_type.cpp b/src/derivation_type.cpp index e9d60d0..f9ae82e 100644 --- a/src/derivation_type.cpp +++ b/src/derivation_type.cpp @@ -7,28 +7,17 @@ namespace mhda { -const derivation_type derivation_type::root {"root"}; -const derivation_type derivation_type::bip32 {"bip32"}; -const derivation_type derivation_type::bip44 {"bip44"}; -const derivation_type derivation_type::bip49 {"bip49"}; -const derivation_type derivation_type::bip54 {"bip54"}; -const derivation_type derivation_type::bip74 {"bip74"}; -const derivation_type derivation_type::bip84 {"bip84"}; -const derivation_type derivation_type::bip86 {"bip86"}; -const derivation_type derivation_type::slip10 {"slip10"}; -const derivation_type derivation_type::cip1852 {"cip1852"}; -const derivation_type derivation_type::cip11 {"cip11"}; -const derivation_type derivation_type::zip32 {"zip32"}; - namespace { const std::unordered_set& dt_index() { - static const std::unordered_set index = { + // Allocated once and never destroyed: a consumer's global destructor may + // still parse after static destruction has begun. + static const auto* index = new std::unordered_set{ "root", "bip32", "bip44", "bip49", "bip54", "bip74", "bip84", "bip86", "slip10", "cip1852", "cip11", "zip32", }; - return index; + return *index; } } // namespace @@ -40,7 +29,7 @@ bool derivation_type::is_valid() const noexcept { derivation_type derivation_type_from_string(std::string_view s) { auto key = detail::normalize(s); if (dt_index().find(key) == dt_index().end()) { - throw parse_error(error_code::invalid_derivation_type, std::string{"\""} + std::string{s} + "\""); + throw parse_error(error_code::invalid_derivation_type, detail::quote(s)); } return derivation_type{std::move(key)}; } diff --git a/src/detail.cpp b/src/detail.cpp index 3d59501..6aa4091 100644 --- a/src/detail.cpp +++ b/src/detail.cpp @@ -2,6 +2,9 @@ #include #include +#include + +#include "mhda/error.hpp" namespace mhda { namespace detail { @@ -51,10 +54,46 @@ bool parse_uint32(std::string_view s, std::uint32_t& out) noexcept { return true; } -bool parse_uint32_dec(std::string_view s, std::uint32_t& out) noexcept { +std::string quote(std::string_view s) { + static constexpr char hex[] = "0123456789abcdef"; + std::string out; + out.reserve(s.size() + 2); + out += '"'; + for (char c : s) { + const auto b = static_cast(c); + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (b < 0x20 || b > 0x7e) { + out += "\\x"; + out += hex[b >> 4]; + out += hex[b & 0xf]; + } else { + out += c; + } + } + } + out += '"'; + return out; +} + +void validate_free_form_value(std::string_view component, std::string_view v) { + for (char c : v) { + if (!nss_byte(c)) { + throw parse_error(error_code::invalid_value, + quote(v) + " for " + quote(component)); + } + } +} + +bool parse_uint31_dec(std::string_view s, std::uint32_t& out) noexcept { std::uint64_t value = 0; if (!parse_in_base(s, 10, value)) return false; - if (value > std::numeric_limits::max()) return false; + if (value >= (std::uint64_t{1} << 31)) return false; out = std::uint32_t(value); return true; } diff --git a/src/detail.hpp b/src/detail.hpp index 792b29a..fbd9ba5 100644 --- a/src/detail.hpp +++ b/src/detail.hpp @@ -31,6 +31,29 @@ inline std::string_view trim(std::string_view s) noexcept { return s.substr(i, j - i); } +// nss_byte reports whether c may appear in an NSS key or value (SPEC §1.5): +// an RFC 3986 pchar or "/" - letters, digits and -._~!$&'()*+,;=@/ - except +// ':' (the component separator) and '%' (percent-encoding is not supported, +// and a raw "%41" would be a second spelling of "A"). Whitespace, control +// bytes, non-ASCII bytes and the printable ASCII outside pchar cannot appear +// in a conforming URN. Mirrors go-mhda's nssByte. +inline bool nss_byte(char c) noexcept { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) { + return true; + } + for (char ok : std::string_view{"-._~!$&'()*+,;=@/"}) { + if (c == ok) return true; + } + return false; +} + +// trim_right removes trailing ASCII whitespace only. +inline std::string_view trim_right(std::string_view s) noexcept { + std::size_t j = s.size(); + while (j > 0 && is_ascii_space(s[j - 1])) --j; + return s.substr(0, j); +} + // to_lower returns an ASCII-lowercased copy of s. inline std::string to_lower(std::string_view s) { std::string out; @@ -92,9 +115,27 @@ inline std::vector split(std::string_view s, char sep) { // 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 -// derivation-path parsing where Go uses base 10. -bool parse_uint32_dec(std::string_view s, std::uint32_t& out) noexcept; +// quote returns s in double quotes for an error message, escaping like Go's +// %q: '"' and '\\' get a backslash, \n \r \t their escapes, and every other +// control or non-ASCII byte \xNN. The message stays one line of printable +// ASCII, so input logged through what() cannot forge or colour log lines. +std::string quote(std::string_view s); + +// validate_free_form_value guards a value written verbatim into the NSS +// (ap/as/wt/wi and the chain id) with the NSS byte set (see nss_byte): the +// ':' component separator would inject foreign components on re-parse, +// '?' / '#' would truncate the URN at the RFC 8141 r/q/f delimiters, and +// whitespace, control bytes, Unicode and the rest of the printable ASCII +// outside RFC 3986 pchar cannot appear in a conforming NSS at all. Throws +// parse_error(invalid_value). Mirrors the Go reference's +// validateFreeFormValue. +void validate_free_form_value(std::string_view component, std::string_view v); + +// parse_uint31_dec parses a strictly decimal value below 2^31. Used by +// per-level derivation-path parsing: a BIP-32 child number keeps the hardened +// flag in its top bit (n' is 2^31+n), so a level index has 31 bits. Mirrors +// Go's strconv.ParseUint(s, 10, 31). +bool parse_uint31_dec(std::string_view s, std::uint32_t& out) noexcept; } // namespace detail } // namespace mhda diff --git a/src/format.cpp b/src/format.cpp index 75b0fbf..35da878 100644 --- a/src/format.cpp +++ b/src/format.cpp @@ -6,29 +6,17 @@ namespace mhda { -const format format::hex {"hex"}; -const format format::p2pkh {"p2pkh"}; -const format format::p2sh {"p2sh"}; -const format format::p2wpkh {"p2wpkh"}; -const format format::p2wsh {"p2wsh"}; -const format format::p2tr {"p2tr"}; -const format format::bech32 {"bech32"}; -const format format::bech32m {"bech32m"}; -const format format::base58 {"base58"}; -const format format::base32 {"base32"}; -const format format::strkey {"strkey"}; -const format format::base64url {"base64url"}; -const format format::ss58 {"ss58"}; - namespace { const std::unordered_set& format_index() { - static const std::unordered_set index = { + // Allocated once and never destroyed: a consumer's global destructor may + // still parse after static destruction has begun. + static const auto* index = new std::unordered_set{ "hex", "p2pkh", "p2sh", "p2wpkh", "p2wsh", "p2tr", "bech32", "bech32m", "base58", "base32", "strkey", "base64url", "ss58", }; - return index; + return *index; } } // namespace diff --git a/src/network_type.cpp b/src/network_type.cpp index a72eb90..2872364 100644 --- a/src/network_type.cpp +++ b/src/network_type.cpp @@ -6,28 +6,12 @@ namespace mhda { -// 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 {"avalanche"}; -const network_type network_type::tron_vm {"tron"}; -const network_type network_type::cosmos {"cosmos"}; -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 {"aptos"}; -const network_type network_type::sui {"sui"}; -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 = { + // Allocated once and never destroyed: a consumer's global destructor may + // still parse after static destruction has begun. + static const auto* index = new std::unordered_map{ {"bitcoin", network_type::bitcoin}, {"evm", network_type::ethereum_vm}, {"avalanche", network_type::avalanche_vm}, @@ -43,7 +27,7 @@ const std::unordered_map& nt_index() { {"algorand", network_type::algorand}, {"ton", network_type::toncoin}, }; - return index; + return *index; } } // namespace diff --git a/src/nss_parser.cpp b/src/nss_parser.cpp index 714a01b..e129fa7 100644 --- a/src/nss_parser.cpp +++ b/src/nss_parser.cpp @@ -11,14 +11,16 @@ namespace detail { namespace { const std::unordered_set& known_components() { - static const std::unordered_set set = { + // Allocated once and never destroyed: a consumer's global destructor may + // still parse after static destruction has begun. + static const auto* set = new std::unordered_set{ 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; + return *set; } } // namespace @@ -29,46 +31,60 @@ bool is_known_component(std::string_view key) noexcept { std::unordered_map parse_nss_map(std::string_view nss) { std::unordered_map out; + if (nss.empty()) return out; + // '?' and '#' open the RFC 8141 r/q/f components. parse_urn strips them + // before the NSS reaches this parser; an NSS that still carries one + // (parse_nss, chain::from_nss, chain::from_key) is rejected, since the + // URN emitted from it would be truncated at that byte on the next parse. + if (const auto i = nss.find_first_of("?#"); i != std::string_view::npos) { + throw parse_error(error_code::invalid_nss, + std::string{"'"} + nss[i] + "' inside the NSS"); + } auto parts = split(nss, ':'); - for (std::size_t i = 0; i < parts.size();) { - auto key = parts[i]; - if (!is_known_component(key)) { - // Unknown token (could be an unrelated word, a future component - // name, or part of a value we mis-identified). Skip and move on. - ++i; - continue; + if (parts.size() % 2 != 0) { + throw parse_error(error_code::invalid_nss, + std::string{"missing value for "} + quote(parts.back())); + } + for (std::size_t i = 0; i < parts.size(); i += 2) { + const auto key = parts[i]; + if (key.empty()) { + throw parse_error(error_code::invalid_nss, "empty component key"); } - if (i + 1 >= parts.size()) { - throw parse_error(error_code::invalid_nss, - std::string{"missing value for \""} + std::string{key} + "\""); + for (char c : key) { + if (!nss_byte(c)) { + throw parse_error(error_code::invalid_nss, + std::string{"byte not allowed in component key "} + quote(key)); + } } - // 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]); + // Nothing is trimmed: the caller removes whitespace around the whole + // NSS, and whitespace around a key or value is malformed input. + const auto value = parts[i + 1]; if (value.empty()) { throw parse_error(error_code::invalid_nss, - std::string{"empty value for \""} + std::string{key} + "\""); + std::string{"empty value for "} + quote(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) { + if (!nss_byte(c)) { + throw parse_error(error_code::invalid_nss, + std::string{"byte not allowed in value for "} + quote(key)); + } + } + if (!is_known_component(key)) { + if (is_known_component(to_lower(key))) { throw parse_error(error_code::invalid_nss, - std::string{"non-ASCII or control byte in value for \""} + - std::string{key} + "\""); + "component key " + quote(key) + " must be lowercase"); } + continue; // unknown component, skipped with its value } std::string key_str{key}; if (out.count(key_str)) { throw parse_error(error_code::invalid_nss, - std::string{"duplicate component \""} + key_str + "\""); + std::string{"duplicate component "} + quote(key_str)); } out.emplace(std::move(key_str), std::string{value}); - i += 2; } return out; } diff --git a/src/nss_parser.hpp b/src/nss_parser.hpp index d2ccc6c..fa07347 100644 --- a/src/nss_parser.hpp +++ b/src/nss_parser.hpp @@ -32,14 +32,22 @@ 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, 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. +// parse_nss splits an NSS string into a component map; an empty NSS has no +// components. The NSS is a sequence of `key:value` pairs joined by `:` +// separators. A pair with an unknown key is skipped together with its value +// (forward-compat with future URN extensions), so a value is never read as a +// key and a following known key is never read as a value. These throw +// parse_error(invalid_nss): a key without a value (an odd number of tokens), +// an empty key or value, a key that differs from a known key only by case +// (it would drop the component silently), a duplicate known key, and a key +// or value with anything outside printable ASCII (0x21–0x7E: control bytes, +// whitespace of any kind, non-ASCII bytes). Nothing is trimmed: the caller +// removes whitespace around the whole NSS. '?' and '#' anywhere in the NSS throw parse_error(invalid_nss): +// parse_urn strips the RFC 8141 r/q/f components first, and an NSS parsed on +// its own must not carry them. Mirrors go-mhda's parseNSS. // -// Form: a sequence of `key:value` pairs joined by `:` separators. Values may -// not contain ':'; this holds for every component currently defined. +// Values may not contain ':'; this holds for every component currently +// defined. std::unordered_map parse_nss_map(std::string_view nss); } // namespace detail diff --git a/src/parser.cpp b/src/parser.cpp index 6498732..2e8921e 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -22,8 +22,8 @@ address parse_address_from_components(const std::unordered_mapsecond; }; - addr.set_derivation_type(get(detail::comp_derivation_type)); - addr.set_derivation_path(get(detail::comp_derivation_path)); + addr.set_derivation(get(detail::comp_derivation_type), + get(detail::comp_derivation_path)); addr.set_address_algorithm(get(detail::comp_address_algorithm)); addr.set_address_format(get(detail::comp_address_format)); addr.set_address_prefix(get(detail::comp_address_prefix)); @@ -33,6 +33,15 @@ address parse_address_from_components(const std::unordered_map + +int main() { + const auto a = mhda::parse_urn_strict("urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0"); + return a.str() == "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0" ? 0 : 1; +} diff --git a/tests/cmake/find_package/CMakeLists.txt b/tests/cmake/find_package/CMakeLists.txt new file mode 100644 index 0000000..dbdc496 --- /dev/null +++ b/tests/cmake/find_package/CMakeLists.txt @@ -0,0 +1,9 @@ +# Consumes an installed mhda through find_package. The project asks for +# C++14: the mhda::mhda target must raise its consumers to C++17 itself. +cmake_minimum_required(VERSION 3.14) +project(mhda_find_package_consumer LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 14) +find_package(mhda REQUIRED) +add_executable(consumer ../consumer.cpp) +target_link_libraries(consumer PRIVATE mhda::mhda) diff --git a/tests/cmake/subdirectory/CMakeLists.txt b/tests/cmake/subdirectory/CMakeLists.txt new file mode 100644 index 0000000..c7638df --- /dev/null +++ b/tests/cmake/subdirectory/CMakeLists.txt @@ -0,0 +1,19 @@ +# Embeds mhda with add_subdirectory, the way FetchContent does. The parent +# sets no build type, and mhda must not choose one for it, nor add its +# tests, examples or install rules. Configure with -DMHDA_SOURCE_DIR=. +cmake_minimum_required(VERSION 3.14) +project(mhda_subdirectory_consumer LANGUAGES CXX) + +add_subdirectory(${MHDA_SOURCE_DIR} mhda) + +if(CMAKE_BUILD_TYPE) + message(FATAL_ERROR "mhda set the parent's build type to '${CMAKE_BUILD_TYPE}'") +endif() +foreach(t mhda_tests mhda_basic mhda_by_key) + if(TARGET ${t}) + message(FATAL_ERROR "mhda added its target '${t}' to the parent build") + endif() +endforeach() + +add_executable(consumer ../consumer.cpp) +target_link_libraries(consumer PRIVATE mhda::mhda) diff --git a/tests/data/dp_conformance.txt b/tests/data/dp_conformance.txt new file mode 100644 index 0000000..5adba1d --- /dev/null +++ b/tests/data/dp_conformance.txt @@ -0,0 +1,472 @@ +# Derivation-path conformance table, shared verbatim by go-mhda +# (testdata/dp_conformance.txt) and the C++ port (tests/data/dp_conformance.txt). +# Both test suites run every row; a path one implementation refuses, the other +# refuses too. Edit both copies together. +# +# Row forms: +# accept
+# refuse
+# A refused row fails with ErrInvalidDerivationPath / error_code::invalid_derivation_path. +# +# Each level's index is 0..2^31-1; the hardened marker is a separate flag. +# 2147483647 is the largest index, 2147483648 (2^31) and 4294967295 (2^32-1) +# are refused at every level of every type, hardened or not. Values at a +# level whose shape they do not fit (an unhardened account, a hardened +# charge, any value in a fixed level) are refused for shape. + +# bip32 m/0'/0/0 +# bip32 account (hardened only) +refuse bip32 m/2147483647/0/0 +accept bip32 m/2147483647'/0/0 m/2147483647'/0/0 +refuse bip32 m/2147483648/0/0 +refuse bip32 m/2147483648'/0/0 +refuse bip32 m/4294967295/0/0 +refuse bip32 m/4294967295'/0/0 +# bip32 charge (fixed 0|1) +refuse bip32 m/0'/2147483647/0 +refuse bip32 m/0'/2147483647'/0 +refuse bip32 m/0'/2147483648/0 +refuse bip32 m/0'/2147483648'/0 +refuse bip32 m/0'/4294967295/0 +refuse bip32 m/0'/4294967295'/0 +# bip32 index (hardening optional) +accept bip32 m/0'/0/2147483647 m/0'/0/2147483647 +accept bip32 m/0'/0/2147483647' m/0'/0/2147483647' +refuse bip32 m/0'/0/2147483648 +refuse bip32 m/0'/0/2147483648' +refuse bip32 m/0'/0/4294967295 +refuse bip32 m/0'/0/4294967295' + +# bip44 m/44'/60'/0'/0/0 +# bip44 purpose (fixed) +refuse bip44 m/2147483647/60'/0'/0/0 +refuse bip44 m/2147483647'/60'/0'/0/0 +refuse bip44 m/2147483648/60'/0'/0/0 +refuse bip44 m/2147483648'/60'/0'/0/0 +refuse bip44 m/4294967295/60'/0'/0/0 +refuse bip44 m/4294967295'/60'/0'/0/0 +# bip44 coin (hardened only) +refuse bip44 m/44'/2147483647/0'/0/0 +accept bip44 m/44'/2147483647'/0'/0/0 m/44'/2147483647'/0'/0/0 +refuse bip44 m/44'/2147483648/0'/0/0 +refuse bip44 m/44'/2147483648'/0'/0/0 +refuse bip44 m/44'/4294967295/0'/0/0 +refuse bip44 m/44'/4294967295'/0'/0/0 +# bip44 account (hardened only) +refuse bip44 m/44'/60'/2147483647/0/0 +accept bip44 m/44'/60'/2147483647'/0/0 m/44'/60'/2147483647'/0/0 +refuse bip44 m/44'/60'/2147483648/0/0 +refuse bip44 m/44'/60'/2147483648'/0/0 +refuse bip44 m/44'/60'/4294967295/0/0 +refuse bip44 m/44'/60'/4294967295'/0/0 +# bip44 charge (fixed 0|1) +refuse bip44 m/44'/60'/0'/2147483647/0 +refuse bip44 m/44'/60'/0'/2147483647'/0 +refuse bip44 m/44'/60'/0'/2147483648/0 +refuse bip44 m/44'/60'/0'/2147483648'/0 +refuse bip44 m/44'/60'/0'/4294967295/0 +refuse bip44 m/44'/60'/0'/4294967295'/0 +# bip44 index (hardening optional) +accept bip44 m/44'/60'/0'/0/2147483647 m/44'/60'/0'/0/2147483647 +accept bip44 m/44'/60'/0'/0/2147483647' m/44'/60'/0'/0/2147483647' +refuse bip44 m/44'/60'/0'/0/2147483648 +refuse bip44 m/44'/60'/0'/0/2147483648' +refuse bip44 m/44'/60'/0'/0/4294967295 +refuse bip44 m/44'/60'/0'/0/4294967295' + +# bip49 m/49'/0'/0'/0/0 +# bip49 purpose (fixed) +refuse bip49 m/2147483647/0'/0'/0/0 +refuse bip49 m/2147483647'/0'/0'/0/0 +refuse bip49 m/2147483648/0'/0'/0/0 +refuse bip49 m/2147483648'/0'/0'/0/0 +refuse bip49 m/4294967295/0'/0'/0/0 +refuse bip49 m/4294967295'/0'/0'/0/0 +# bip49 coin (hardened only) +refuse bip49 m/49'/2147483647/0'/0/0 +accept bip49 m/49'/2147483647'/0'/0/0 m/49'/2147483647'/0'/0/0 +refuse bip49 m/49'/2147483648/0'/0/0 +refuse bip49 m/49'/2147483648'/0'/0/0 +refuse bip49 m/49'/4294967295/0'/0/0 +refuse bip49 m/49'/4294967295'/0'/0/0 +# bip49 account (hardened only) +refuse bip49 m/49'/0'/2147483647/0/0 +accept bip49 m/49'/0'/2147483647'/0/0 m/49'/0'/2147483647'/0/0 +refuse bip49 m/49'/0'/2147483648/0/0 +refuse bip49 m/49'/0'/2147483648'/0/0 +refuse bip49 m/49'/0'/4294967295/0/0 +refuse bip49 m/49'/0'/4294967295'/0/0 +# bip49 charge (fixed 0|1) +refuse bip49 m/49'/0'/0'/2147483647/0 +refuse bip49 m/49'/0'/0'/2147483647'/0 +refuse bip49 m/49'/0'/0'/2147483648/0 +refuse bip49 m/49'/0'/0'/2147483648'/0 +refuse bip49 m/49'/0'/0'/4294967295/0 +refuse bip49 m/49'/0'/0'/4294967295'/0 +# bip49 index (hardening optional) +accept bip49 m/49'/0'/0'/0/2147483647 m/49'/0'/0'/0/2147483647 +accept bip49 m/49'/0'/0'/0/2147483647' m/49'/0'/0'/0/2147483647' +refuse bip49 m/49'/0'/0'/0/2147483648 +refuse bip49 m/49'/0'/0'/0/2147483648' +refuse bip49 m/49'/0'/0'/0/4294967295 +refuse bip49 m/49'/0'/0'/0/4294967295' + +# bip54 m/54'/784'/0'/0/0 +# bip54 purpose (fixed) +refuse bip54 m/2147483647/784'/0'/0/0 +refuse bip54 m/2147483647'/784'/0'/0/0 +refuse bip54 m/2147483648/784'/0'/0/0 +refuse bip54 m/2147483648'/784'/0'/0/0 +refuse bip54 m/4294967295/784'/0'/0/0 +refuse bip54 m/4294967295'/784'/0'/0/0 +# bip54 coin (hardened only) +refuse bip54 m/54'/2147483647/0'/0/0 +accept bip54 m/54'/2147483647'/0'/0/0 m/54'/2147483647'/0'/0/0 +refuse bip54 m/54'/2147483648/0'/0/0 +refuse bip54 m/54'/2147483648'/0'/0/0 +refuse bip54 m/54'/4294967295/0'/0/0 +refuse bip54 m/54'/4294967295'/0'/0/0 +# bip54 account (hardened only) +refuse bip54 m/54'/784'/2147483647/0/0 +accept bip54 m/54'/784'/2147483647'/0/0 m/54'/784'/2147483647'/0/0 +refuse bip54 m/54'/784'/2147483648/0/0 +refuse bip54 m/54'/784'/2147483648'/0/0 +refuse bip54 m/54'/784'/4294967295/0/0 +refuse bip54 m/54'/784'/4294967295'/0/0 +# bip54 charge (fixed 0|1) +refuse bip54 m/54'/784'/0'/2147483647/0 +refuse bip54 m/54'/784'/0'/2147483647'/0 +refuse bip54 m/54'/784'/0'/2147483648/0 +refuse bip54 m/54'/784'/0'/2147483648'/0 +refuse bip54 m/54'/784'/0'/4294967295/0 +refuse bip54 m/54'/784'/0'/4294967295'/0 +# bip54 index (hardening optional) +accept bip54 m/54'/784'/0'/0/2147483647 m/54'/784'/0'/0/2147483647 +accept bip54 m/54'/784'/0'/0/2147483647' m/54'/784'/0'/0/2147483647' +refuse bip54 m/54'/784'/0'/0/2147483648 +refuse bip54 m/54'/784'/0'/0/2147483648' +refuse bip54 m/54'/784'/0'/0/4294967295 +refuse bip54 m/54'/784'/0'/0/4294967295' + +# bip74 m/74'/784'/0'/0/0 +# bip74 purpose (fixed) +refuse bip74 m/2147483647/784'/0'/0/0 +refuse bip74 m/2147483647'/784'/0'/0/0 +refuse bip74 m/2147483648/784'/0'/0/0 +refuse bip74 m/2147483648'/784'/0'/0/0 +refuse bip74 m/4294967295/784'/0'/0/0 +refuse bip74 m/4294967295'/784'/0'/0/0 +# bip74 coin (hardened only) +refuse bip74 m/74'/2147483647/0'/0/0 +accept bip74 m/74'/2147483647'/0'/0/0 m/74'/2147483647'/0'/0/0 +refuse bip74 m/74'/2147483648/0'/0/0 +refuse bip74 m/74'/2147483648'/0'/0/0 +refuse bip74 m/74'/4294967295/0'/0/0 +refuse bip74 m/74'/4294967295'/0'/0/0 +# bip74 account (hardened only) +refuse bip74 m/74'/784'/2147483647/0/0 +accept bip74 m/74'/784'/2147483647'/0/0 m/74'/784'/2147483647'/0/0 +refuse bip74 m/74'/784'/2147483648/0/0 +refuse bip74 m/74'/784'/2147483648'/0/0 +refuse bip74 m/74'/784'/4294967295/0/0 +refuse bip74 m/74'/784'/4294967295'/0/0 +# bip74 charge (fixed 0|1) +refuse bip74 m/74'/784'/0'/2147483647/0 +refuse bip74 m/74'/784'/0'/2147483647'/0 +refuse bip74 m/74'/784'/0'/2147483648/0 +refuse bip74 m/74'/784'/0'/2147483648'/0 +refuse bip74 m/74'/784'/0'/4294967295/0 +refuse bip74 m/74'/784'/0'/4294967295'/0 +# bip74 index (hardening optional) +accept bip74 m/74'/784'/0'/0/2147483647 m/74'/784'/0'/0/2147483647 +accept bip74 m/74'/784'/0'/0/2147483647' m/74'/784'/0'/0/2147483647' +refuse bip74 m/74'/784'/0'/0/2147483648 +refuse bip74 m/74'/784'/0'/0/2147483648' +refuse bip74 m/74'/784'/0'/0/4294967295 +refuse bip74 m/74'/784'/0'/0/4294967295' + +# bip84 m/84'/0'/0'/0/0 +# bip84 purpose (fixed) +refuse bip84 m/2147483647/0'/0'/0/0 +refuse bip84 m/2147483647'/0'/0'/0/0 +refuse bip84 m/2147483648/0'/0'/0/0 +refuse bip84 m/2147483648'/0'/0'/0/0 +refuse bip84 m/4294967295/0'/0'/0/0 +refuse bip84 m/4294967295'/0'/0'/0/0 +# bip84 coin (hardened only) +refuse bip84 m/84'/2147483647/0'/0/0 +accept bip84 m/84'/2147483647'/0'/0/0 m/84'/2147483647'/0'/0/0 +refuse bip84 m/84'/2147483648/0'/0/0 +refuse bip84 m/84'/2147483648'/0'/0/0 +refuse bip84 m/84'/4294967295/0'/0/0 +refuse bip84 m/84'/4294967295'/0'/0/0 +# bip84 account (hardened only) +refuse bip84 m/84'/0'/2147483647/0/0 +accept bip84 m/84'/0'/2147483647'/0/0 m/84'/0'/2147483647'/0/0 +refuse bip84 m/84'/0'/2147483648/0/0 +refuse bip84 m/84'/0'/2147483648'/0/0 +refuse bip84 m/84'/0'/4294967295/0/0 +refuse bip84 m/84'/0'/4294967295'/0/0 +# bip84 charge (fixed 0|1) +refuse bip84 m/84'/0'/0'/2147483647/0 +refuse bip84 m/84'/0'/0'/2147483647'/0 +refuse bip84 m/84'/0'/0'/2147483648/0 +refuse bip84 m/84'/0'/0'/2147483648'/0 +refuse bip84 m/84'/0'/0'/4294967295/0 +refuse bip84 m/84'/0'/0'/4294967295'/0 +# bip84 index (hardening optional) +accept bip84 m/84'/0'/0'/0/2147483647 m/84'/0'/0'/0/2147483647 +accept bip84 m/84'/0'/0'/0/2147483647' m/84'/0'/0'/0/2147483647' +refuse bip84 m/84'/0'/0'/0/2147483648 +refuse bip84 m/84'/0'/0'/0/2147483648' +refuse bip84 m/84'/0'/0'/0/4294967295 +refuse bip84 m/84'/0'/0'/0/4294967295' + +# bip86 m/86'/0'/0'/0/0 +# bip86 purpose (fixed) +refuse bip86 m/2147483647/0'/0'/0/0 +refuse bip86 m/2147483647'/0'/0'/0/0 +refuse bip86 m/2147483648/0'/0'/0/0 +refuse bip86 m/2147483648'/0'/0'/0/0 +refuse bip86 m/4294967295/0'/0'/0/0 +refuse bip86 m/4294967295'/0'/0'/0/0 +# bip86 coin (hardened only) +refuse bip86 m/86'/2147483647/0'/0/0 +accept bip86 m/86'/2147483647'/0'/0/0 m/86'/2147483647'/0'/0/0 +refuse bip86 m/86'/2147483648/0'/0/0 +refuse bip86 m/86'/2147483648'/0'/0/0 +refuse bip86 m/86'/4294967295/0'/0/0 +refuse bip86 m/86'/4294967295'/0'/0/0 +# bip86 account (hardened only) +refuse bip86 m/86'/0'/2147483647/0/0 +accept bip86 m/86'/0'/2147483647'/0/0 m/86'/0'/2147483647'/0/0 +refuse bip86 m/86'/0'/2147483648/0/0 +refuse bip86 m/86'/0'/2147483648'/0/0 +refuse bip86 m/86'/0'/4294967295/0/0 +refuse bip86 m/86'/0'/4294967295'/0/0 +# bip86 charge (fixed 0|1) +refuse bip86 m/86'/0'/0'/2147483647/0 +refuse bip86 m/86'/0'/0'/2147483647'/0 +refuse bip86 m/86'/0'/0'/2147483648/0 +refuse bip86 m/86'/0'/0'/2147483648'/0 +refuse bip86 m/86'/0'/0'/4294967295/0 +refuse bip86 m/86'/0'/0'/4294967295'/0 +# bip86 index (hardening optional) +accept bip86 m/86'/0'/0'/0/2147483647 m/86'/0'/0'/0/2147483647 +accept bip86 m/86'/0'/0'/0/2147483647' m/86'/0'/0'/0/2147483647' +refuse bip86 m/86'/0'/0'/0/2147483648 +refuse bip86 m/86'/0'/0'/0/2147483648' +refuse bip86 m/86'/0'/0'/0/4294967295 +refuse bip86 m/86'/0'/0'/0/4294967295' + +# cip11 m/44'/118'/0'/0/0 +# cip11 purpose (fixed) +refuse cip11 m/2147483647/118'/0'/0/0 +refuse cip11 m/2147483647'/118'/0'/0/0 +refuse cip11 m/2147483648/118'/0'/0/0 +refuse cip11 m/2147483648'/118'/0'/0/0 +refuse cip11 m/4294967295/118'/0'/0/0 +refuse cip11 m/4294967295'/118'/0'/0/0 +# cip11 coin (fixed) +refuse cip11 m/44'/2147483647/0'/0/0 +refuse cip11 m/44'/2147483647'/0'/0/0 +refuse cip11 m/44'/2147483648/0'/0/0 +refuse cip11 m/44'/2147483648'/0'/0/0 +refuse cip11 m/44'/4294967295/0'/0/0 +refuse cip11 m/44'/4294967295'/0'/0/0 +# cip11 account (hardened only) +refuse cip11 m/44'/118'/2147483647/0/0 +accept cip11 m/44'/118'/2147483647'/0/0 m/44'/118'/2147483647'/0/0 +refuse cip11 m/44'/118'/2147483648/0/0 +refuse cip11 m/44'/118'/2147483648'/0/0 +refuse cip11 m/44'/118'/4294967295/0/0 +refuse cip11 m/44'/118'/4294967295'/0/0 +# cip11 charge (soft only) +accept cip11 m/44'/118'/0'/2147483647/0 m/44'/118'/0'/2147483647/0 +refuse cip11 m/44'/118'/0'/2147483647'/0 +refuse cip11 m/44'/118'/0'/2147483648/0 +refuse cip11 m/44'/118'/0'/2147483648'/0 +refuse cip11 m/44'/118'/0'/4294967295/0 +refuse cip11 m/44'/118'/0'/4294967295'/0 +# cip11 index (hardening optional) +accept cip11 m/44'/118'/0'/0/2147483647 m/44'/118'/0'/0/2147483647 +accept cip11 m/44'/118'/0'/0/2147483647' m/44'/118'/0'/0/2147483647' +refuse cip11 m/44'/118'/0'/0/2147483648 +refuse cip11 m/44'/118'/0'/0/2147483648' +refuse cip11 m/44'/118'/0'/0/4294967295 +refuse cip11 m/44'/118'/0'/0/4294967295' + +# cip1852 m/1852'/1815'/0'/0/0 +# cip1852 purpose (fixed) +refuse cip1852 m/2147483647/1815'/0'/0/0 +refuse cip1852 m/2147483647'/1815'/0'/0/0 +refuse cip1852 m/2147483648/1815'/0'/0/0 +refuse cip1852 m/2147483648'/1815'/0'/0/0 +refuse cip1852 m/4294967295/1815'/0'/0/0 +refuse cip1852 m/4294967295'/1815'/0'/0/0 +# cip1852 coin (fixed) +refuse cip1852 m/1852'/2147483647/0'/0/0 +refuse cip1852 m/1852'/2147483647'/0'/0/0 +refuse cip1852 m/1852'/2147483648/0'/0/0 +refuse cip1852 m/1852'/2147483648'/0'/0/0 +refuse cip1852 m/1852'/4294967295/0'/0/0 +refuse cip1852 m/1852'/4294967295'/0'/0/0 +# cip1852 account (hardened only) +refuse cip1852 m/1852'/1815'/2147483647/0/0 +accept cip1852 m/1852'/1815'/2147483647'/0/0 m/1852'/1815'/2147483647'/0/0 +refuse cip1852 m/1852'/1815'/2147483648/0/0 +refuse cip1852 m/1852'/1815'/2147483648'/0/0 +refuse cip1852 m/1852'/1815'/4294967295/0/0 +refuse cip1852 m/1852'/1815'/4294967295'/0/0 +# cip1852 role (soft only) +accept cip1852 m/1852'/1815'/0'/2147483647/0 m/1852'/1815'/0'/2147483647/0 +refuse cip1852 m/1852'/1815'/0'/2147483647'/0 +refuse cip1852 m/1852'/1815'/0'/2147483648/0 +refuse cip1852 m/1852'/1815'/0'/2147483648'/0 +refuse cip1852 m/1852'/1815'/0'/4294967295/0 +refuse cip1852 m/1852'/1815'/0'/4294967295'/0 +# cip1852 index (hardening optional) +accept cip1852 m/1852'/1815'/0'/0/2147483647 m/1852'/1815'/0'/0/2147483647 +accept cip1852 m/1852'/1815'/0'/0/2147483647' m/1852'/1815'/0'/0/2147483647' +refuse cip1852 m/1852'/1815'/0'/0/2147483648 +refuse cip1852 m/1852'/1815'/0'/0/2147483648' +refuse cip1852 m/1852'/1815'/0'/0/4294967295 +refuse cip1852 m/1852'/1815'/0'/0/4294967295' + +# zip32 m/32'/133'/0' +# zip32 purpose (fixed) +refuse zip32 m/2147483647/133'/0' +refuse zip32 m/2147483647'/133'/0' +refuse zip32 m/2147483648/133'/0' +refuse zip32 m/2147483648'/133'/0' +refuse zip32 m/4294967295/133'/0' +refuse zip32 m/4294967295'/133'/0' +# zip32 coin (fixed) +refuse zip32 m/32'/2147483647/0' +refuse zip32 m/32'/2147483647'/0' +refuse zip32 m/32'/2147483648/0' +refuse zip32 m/32'/2147483648'/0' +refuse zip32 m/32'/4294967295/0' +refuse zip32 m/32'/4294967295'/0' +# zip32 account (hardened only) +refuse zip32 m/32'/133'/2147483647 +accept zip32 m/32'/133'/2147483647' m/32'/133'/2147483647' +refuse zip32 m/32'/133'/2147483648 +refuse zip32 m/32'/133'/2147483648' +refuse zip32 m/32'/133'/4294967295 +refuse zip32 m/32'/133'/4294967295' + +# zip32 m/32'/133'/0'/0 +# zip32 purpose (fixed) +refuse zip32 m/2147483647/133'/0'/0 +refuse zip32 m/2147483647'/133'/0'/0 +refuse zip32 m/2147483648/133'/0'/0 +refuse zip32 m/2147483648'/133'/0'/0 +refuse zip32 m/4294967295/133'/0'/0 +refuse zip32 m/4294967295'/133'/0'/0 +# zip32 coin (fixed) +refuse zip32 m/32'/2147483647/0'/0 +refuse zip32 m/32'/2147483647'/0'/0 +refuse zip32 m/32'/2147483648/0'/0 +refuse zip32 m/32'/2147483648'/0'/0 +refuse zip32 m/32'/4294967295/0'/0 +refuse zip32 m/32'/4294967295'/0'/0 +# zip32 account (hardened only) +refuse zip32 m/32'/133'/2147483647/0 +accept zip32 m/32'/133'/2147483647'/0 m/32'/133'/2147483647'/0 +refuse zip32 m/32'/133'/2147483648/0 +refuse zip32 m/32'/133'/2147483648'/0 +refuse zip32 m/32'/133'/4294967295/0 +refuse zip32 m/32'/133'/4294967295'/0 +# zip32 index (hardening optional) +accept zip32 m/32'/133'/0'/2147483647 m/32'/133'/0'/2147483647 +accept zip32 m/32'/133'/0'/2147483647' m/32'/133'/0'/2147483647' +refuse zip32 m/32'/133'/0'/2147483648 +refuse zip32 m/32'/133'/0'/2147483648' +refuse zip32 m/32'/133'/0'/4294967295 +refuse zip32 m/32'/133'/0'/4294967295' + +# slip10 m/44'/501'/0'/0' +# slip10 level 0 (hardening optional) +accept slip10 m/2147483647/501'/0'/0' m/2147483647/501'/0'/0' +accept slip10 m/2147483647'/501'/0'/0' m/2147483647'/501'/0'/0' +refuse slip10 m/2147483648/501'/0'/0' +refuse slip10 m/2147483648'/501'/0'/0' +refuse slip10 m/4294967295/501'/0'/0' +refuse slip10 m/4294967295'/501'/0'/0' +# slip10 level 1 (hardening optional) +accept slip10 m/44'/2147483647/0'/0' m/44'/2147483647/0'/0' +accept slip10 m/44'/2147483647'/0'/0' m/44'/2147483647'/0'/0' +refuse slip10 m/44'/2147483648/0'/0' +refuse slip10 m/44'/2147483648'/0'/0' +refuse slip10 m/44'/4294967295/0'/0' +refuse slip10 m/44'/4294967295'/0'/0' +# slip10 level 2 (hardening optional) +accept slip10 m/44'/501'/2147483647/0' m/44'/501'/2147483647/0' +accept slip10 m/44'/501'/2147483647'/0' m/44'/501'/2147483647'/0' +refuse slip10 m/44'/501'/2147483648/0' +refuse slip10 m/44'/501'/2147483648'/0' +refuse slip10 m/44'/501'/4294967295/0' +refuse slip10 m/44'/501'/4294967295'/0' +# slip10 level 3 (hardening optional) +accept slip10 m/44'/501'/0'/2147483647 m/44'/501'/0'/2147483647 +accept slip10 m/44'/501'/0'/2147483647' m/44'/501'/0'/2147483647' +refuse slip10 m/44'/501'/0'/2147483648 +refuse slip10 m/44'/501'/0'/2147483648' +refuse slip10 m/44'/501'/0'/4294967295 +refuse slip10 m/44'/501'/0'/4294967295' + +# slip10 m/0 +# slip10 level 0 (hardening optional) +accept slip10 m/2147483647 m/2147483647 +accept slip10 m/2147483647' m/2147483647' +refuse slip10 m/2147483648 +refuse slip10 m/2147483648' +refuse slip10 m/4294967295 +refuse slip10 m/4294967295' + +# Hardened markers h and H count like ' and are emitted as '. +accept bip44 m/44h/60H/2147483647h/0/2147483647H m/44'/60'/2147483647'/0/2147483647' +refuse bip44 m/44'/60'/2147483648h/0/0 +refuse bip44 m/44'/60'/0'/0/2147483648H +accept slip10 m/2147483647h/2147483647H/2147483647 m/2147483647'/2147483647'/2147483647 +refuse slip10 m/2147483647h/2147483648H + +# Beyond 32 bits. +refuse bip44 m/44'/60'/0'/0/4294967296 +refuse bip44 m/44'/60'/0'/0/18446744073709551616 +refuse slip10 m/99999999999999999999 +refuse slip10 m/0/99999999999999999999' + +# Leading zeros. A variable level accepts them and the canonical form drops +# them; the range check applies to the value. A fixed level (purpose, the +# fixed coin of cip11/cip1852/zip32, the 0|1 charge of bip32 and the +# BIP-44 family) must be spelled exactly as in its template. +accept bip44 m/44'/060'/0'/0/0 m/44'/60'/0'/0/0 +accept bip44 m/44'/60'/00'/0/00 m/44'/60'/0'/0/0 +accept bip44 m/44'/60'/0'/0/02147483647 m/44'/60'/0'/0/2147483647 +accept bip44 m/44'/60'/0'/0/000000000000000000002147483647' m/44'/60'/0'/0/2147483647' +refuse bip44 m/44'/60'/0'/0/02147483648 +refuse bip44 m/44'/60'/000000000000000000002147483648'/0/0 +refuse bip44 m/044'/60'/0'/0/0 +refuse bip44 m/44'/60'/0'/00/0 +refuse bip44 m/44'/60'/0'/01/0 +refuse bip84 m/084'/0'/0'/0/0 +refuse bip32 m/0'/00/0 +accept bip32 m/00'/1/007' m/0'/1/7' +refuse cip11 m/044'/118'/0'/0/0 +refuse cip11 m/44'/0118'/0'/0/0 +accept cip11 m/44'/118'/0'/09/0 m/44'/118'/0'/9/0 +refuse cip1852 m/01852'/1815'/0'/0/0 +refuse cip1852 m/1852'/01815'/0'/0/0 +accept cip1852 m/1852'/1815'/0'/02/0 m/1852'/1815'/0'/2/0 +refuse zip32 m/032'/133'/0' +refuse zip32 m/32'/0133'/0' +accept zip32 m/32'/133'/00'/00' m/32'/133'/0'/0' +accept slip10 m/044'/0501'/00' m/44'/501'/0' + +# SLIP-10 depth. BIP-32 serialises a key's depth in one byte, so a path has +# at most 255 levels. +accept slip10 m/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0' m/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0' +accept slip10 m/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/2147483647' m/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/2147483647' +refuse slip10 m/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0' diff --git a/tests/data/urn_conformance.txt b/tests/data/urn_conformance.txt new file mode 100644 index 0000000..3212e3d --- /dev/null +++ b/tests/data/urn_conformance.txt @@ -0,0 +1,145 @@ +# URN conformance table, shared verbatim by go-mhda +# (testdata/urn_conformance.txt) and the C++ port (tests/data/urn_conformance.txt). +# Both test suites run every row; an input one implementation refuses, the +# other refuses too, with the same error. Edit both copies together. +# +# Row forms: +# accept urn +# accept nss +# accept strict +# refuse urn +# refuse nss +# refuse strict +# urn rows use the lenient parser, strict rows ParseURNStrict / +# parse_urn_strict. is the C++ error_code name; go-mhda maps it to +# its sentinel (invalid_nss is ErrInvalidNSS, incompatible is +# ErrIncompatible, ...). + +# A derivation path needs a derivation type other than root. Without dt the +# address is root, and a dp there would be dropped: the URN would name the +# root key instead of the path it spells out. +refuse urn invalid_derivation_path urn:mhda:nt:evm:ci:1:dp:m/44'/60'/0'/0/0 +refuse urn invalid_derivation_path urn:mhda:nt:evm:ci:1:dt:root:dp:m/0 +refuse urn invalid_derivation_path urn:mhda:nt:evm:ci:1:dt:ROOT:dp:m/0 +refuse urn invalid_derivation_path urn:mhda:nt:ton:ci:mainnet:dt:root:dp:m/44'/607'/0' +refuse urn invalid_derivation_path urn:mhda:dp:m/44'/60'/0'/0/0:nt:evm:ci:1 +refuse nss invalid_derivation_path nt:evm:ci:1:dp:m/44'/60'/0'/0/0 +refuse urn invalid_derivation_path urn:mhda:nt:evm:ci:1:dt:bip44 +accept urn urn:mhda:nt:ton:ci:mainnet urn:mhda:nt:ton:ci:mainnet +accept urn urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0 urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0 +accept nss nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0 urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0 + +# The NSS is a sequence of key:value pairs. An unknown key is skipped +# together with its value (forward compatibility), so a following known key +# is never read as its value. A key that differs from a known key only by +# case is refused rather than skipped, and so is a token without a value. +refuse urn invalid_nss urn:mhda:nt:evm:ci:1:DT:bip44:DP:m/44'/60'/0'/0/5 +refuse urn invalid_nss urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0:WI:wallet-A +refuse urn invalid_nss urn:mhda:NT:evm:ci:1 +refuse urn invalid_nss urn:mhda:nt:evm:Ci:1 +accept urn urn:mhda:nt:evm:ci:1:ext:wi:dt:bip44:dp:m/44'/60'/0'/0/5 urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/5 +accept urn urn:mhda:nt:evm:ci:1:xx:future urn:mhda:nt:evm:ci:1 +accept urn urn:mhda:xx:nt:nt:evm:ci:1 urn:mhda:nt:evm:ci:1 +refuse urn invalid_nss urn:mhda:nt:evm:ci:1:garbage +refuse urn invalid_nss urn:mhda:nt:evm:junk:ci:1 +refuse urn invalid_nss urn:mhda:nt:evm:ci:1: +refuse urn invalid_nss urn:mhda::nt:evm:ci:1 +refuse urn invalid_nss urn:mhda:nt:evm:ci:1::x +refuse urn invalid_nss urn:mhda:nt:evm:ci:1:xx: +refuse urn missing_network_type urn:mhda: +refuse nss invalid_nss nt:evm:ci:1:foo +accept nss nt:evm:ci:1:wt:web3 urn:mhda:nt:evm:ci:1:wt:web3 + +# '?' and '#' open the RFC 8141 r/q/f components. ParseURN strips them with +# everything after; an NSS passed on its own must not carry them in a value, +# or the URN emitted from it would be truncated on the next parse. +refuse nss invalid_nss nt:evm:ci:1?=q:dt:bip44:dp:m/44'/60'/0'/0/0 +refuse nss invalid_nss nt:evm:ci:1#f +refuse nss invalid_nss nt:evm:ci:1:wi:a?b +refuse nss invalid_nss nt:evm:ci:1:x#y:z +accept urn urn:mhda:nt:evm:ci:1?=q:dt:bip44 urn:mhda:nt:evm:ci:1 +accept urn urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0#frag urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0 + +# Root is the default derivation type and carries no path: an explicit +# dt:root is folded away, so a root address has one canonical form. +accept urn urn:mhda:nt:ton:ci:mainnet:dt:root urn:mhda:nt:ton:ci:mainnet +accept urn urn:mhda:nt:algorand:ci:mainnet:ct:283:dt:root:af:base32 urn:mhda:nt:algorand:ci:mainnet:ct:283:af:base32 +accept urn urn:mhda:nt:evm:ci:1:dt:ROOT urn:mhda:nt:evm:ci:1 + +# Strict mode and the curves. SLIP-10 derives ed25519 keys through hardened +# levels only, so every level of an ed25519 path must be hardened; cip1852 +# (BIP32-Ed25519) is the one ed25519 scheme with soft levels. The lenient +# parser still accepts these paths. +refuse strict incompatible urn:mhda:nt:solana:ci:mainnet:dt:slip10:dp:m/44'/501'/0/0 +refuse strict incompatible urn:mhda:nt:solana:ci:mainnet:dt:slip10:dp:m/44'/501'/0'/0'/7 +refuse strict incompatible urn:mhda:nt:stellar:ci:pubnet:dt:slip10:dp:m/44'/148'/0 +refuse strict incompatible urn:mhda:nt:ton:ci:mainnet:dt:slip10:dp:m/44'/607'/0'/0'/0 +refuse strict incompatible urn:mhda:nt:cosmos:ci:secret:dt:bip44:dp:m/44'/529'/0'/0/0:aa:ed25519 +refuse strict incompatible urn:mhda:nt:xrpl:ci:mainnet:dt:bip44:dp:m/44'/144'/0'/0/0:aa:ed25519 +accept urn urn:mhda:nt:solana:ci:mainnet:dt:slip10:dp:m/44'/501'/0/0 urn:mhda:nt:solana:ci:mainnet:dt:slip10:dp:m/44'/501'/0/0 +accept strict urn:mhda:nt:solana:ci:mainnet:dt:slip10:dp:m/44'/501'/0'/0' urn:mhda:nt:solana:ci:mainnet:dt:slip10:dp:m/44'/501'/0'/0' +accept strict urn:mhda:nt:stellar:ci:pubnet:dt:slip10:dp:m/44'/148'/0' urn:mhda:nt:stellar:ci:pubnet:dt:slip10:dp:m/44'/148'/0' +accept strict urn:mhda:nt:ton:ci:mainnet:dt:slip10:dp:m/44'/607'/0' urn:mhda:nt:ton:ci:mainnet:dt:slip10:dp:m/44'/607'/0' +accept strict urn:mhda:nt:cardano:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/0'/0/0 urn:mhda:nt:cardano:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/0'/0/0 +accept strict urn:mhda:nt:xrpl:ci:mainnet:dt:bip44:dp:m/44'/144'/0'/0/0 urn:mhda:nt:xrpl:ci:mainnet:dt:bip44:dp:m/44'/144'/0'/0/0 + +# Sui encodes the signature scheme in the purpose (bip54 secp256k1, bip74 +# secp256r1, slip10 ed25519); Aptos and NEAR derive ed25519 through slip10 +# and secp256k1 through bip44. A derivation type takes only its own curve. +refuse strict incompatible urn:mhda:nt:sui:ci:mainnet:dt:bip54:dp:m/54'/784'/0'/0/0 +refuse strict incompatible urn:mhda:nt:sui:ci:mainnet:dt:bip54:dp:m/54'/784'/0'/0/0:aa:secp256r1 +refuse strict incompatible urn:mhda:nt:sui:ci:mainnet:dt:bip74:dp:m/74'/784'/0'/0/0:aa:secp256k1 +refuse strict incompatible urn:mhda:nt:sui:ci:mainnet:dt:slip10:dp:m/44'/784'/0'/0'/0':aa:secp256k1 +refuse strict incompatible urn:mhda:nt:aptos:ci:mainnet:dt:bip44:dp:m/44'/637'/0'/0/0 +refuse strict incompatible urn:mhda:nt:aptos:ci:mainnet:dt:slip10:dp:m/44'/637'/0'/0'/0':aa:secp256k1 +refuse strict incompatible urn:mhda:nt:near:ci:mainnet:dt:bip44:dp:m/44'/397'/0'/0/0 +accept strict urn:mhda:nt:sui:ci:mainnet:dt:bip54:dp:m/54'/784'/0'/0/0:aa:secp256k1 urn:mhda:nt:sui:ci:mainnet:dt:bip54:dp:m/54'/784'/0'/0/0:aa:secp256k1 +accept strict urn:mhda:nt:sui:ci:mainnet:dt:bip74:dp:m/74'/784'/0'/0/0:aa:secp256r1 urn:mhda:nt:sui:ci:mainnet:dt:bip74:dp:m/74'/784'/0'/0/0:aa:secp256r1 +accept strict urn:mhda:nt:sui:ci:mainnet:dt:slip10:dp:m/44'/784'/0'/0'/0' urn:mhda:nt:sui:ci:mainnet:dt:slip10:dp:m/44'/784'/0'/0'/0' +accept strict urn:mhda:nt:aptos:ci:mainnet:dt:bip44:dp:m/44'/637'/0'/0/0:aa:secp256k1 urn:mhda:nt:aptos:ci:mainnet:dt:bip44:dp:m/44'/637'/0'/0/0:aa:secp256k1 +accept strict urn:mhda:nt:aptos:ci:mainnet:dt:slip10:dp:m/44'/637'/0'/0'/0' urn:mhda:nt:aptos:ci:mainnet:dt:slip10:dp:m/44'/637'/0'/0'/0' +accept strict urn:mhda:nt:near:ci:mainnet:dt:bip44:dp:m/44'/397'/0'/0/1:aa:secp256k1 urn:mhda:nt:near:ci:mainnet:dt:bip44:dp:m/44'/397'/0'/0/1:aa:secp256k1 +accept strict urn:mhda:nt:near:ci:mainnet:dt:slip10:dp:m/44'/397'/0' urn:mhda:nt:near:ci:mainnet:dt:slip10:dp:m/44'/397'/0' + +# Bitcoin: the purpose defines the script, so an explicit af must match it +# (bip44 p2pkh, bip49 p2sh, bip84 p2wpkh or bech32, bip86 p2tr or bech32m; +# bip32 takes any). Without af the URN stays valid. +refuse strict incompatible urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0:af:p2pkh +refuse strict incompatible urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip44:dp:m/44'/0'/0'/0/0:af:p2wpkh +refuse strict incompatible urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip49:dp:m/49'/0'/0'/0/0:af:p2wpkh +refuse strict incompatible urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32 +accept strict urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0 urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0 +accept strict urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0:af:p2wpkh urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0:af:p2wpkh +accept strict urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0:af:bech32 urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0:af:bech32 +accept strict urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:p2tr urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:p2tr +accept strict urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32m urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32m +accept strict urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip49:dp:m/49'/0'/0'/0/0:af:p2sh urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip49:dp:m/49'/0'/0'/0/0:af:p2sh +accept strict urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip44:dp:m/44'/0'/0'/0/0:af:p2pkh urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip44:dp:m/44'/0'/0'/0/0:af:p2pkh +accept strict urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip32:dp:m/0'/0/0:af:p2wpkh urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip32:dp:m/0'/0/0:af:p2wpkh + +# Cosmos: bip44 with coin 118' is the cip11 path m/44'/118'/... (one key, +# two spellings); strict mode keeps cip11. bip44 with another coin stays. +refuse strict incompatible urn:mhda:nt:cosmos:ci:cosmoshub:dt:bip44:dp:m/44'/118'/0'/0/0 +accept strict urn:mhda:nt:cosmos:ci:cosmoshub:dt:cip11:dp:m/44'/118'/0'/0/0 urn:mhda:nt:cosmos:ci:cosmoshub:dt:cip11:dp:m/44'/118'/0'/0/0 +accept strict urn:mhda:nt:cosmos:ci:secret:dt:bip44:dp:m/44'/529'/0'/0/0 urn:mhda:nt:cosmos:ci:secret:dt:bip44:dp:m/44'/529'/0'/0/0 + +# NSS bytes are RFC 3986 pchar or "/": letters, digits and -._~!$&'()*+,;=@/, +# except ':' (the component separator) and '%' (percent-encoding is not +# supported, and a raw %41 would be a second spelling of "A"). The other +# printable ASCII bytes cannot appear in a URN at all. +refuse urn invalid_nss urn:mhda:nt:evm:ci:a"b +refuse urn invalid_nss urn:mhda:nt:evm:ci:ab +refuse urn invalid_nss urn:mhda:nt:evm:ci:a\b +refuse urn invalid_nss urn:mhda:nt:evm:ci:a^b +refuse urn invalid_nss urn:mhda:nt:evm:ci:a`b +refuse urn invalid_nss urn:mhda:nt:evm:ci:a{b +refuse urn invalid_nss urn:mhda:nt:evm:ci:a|b +refuse urn invalid_nss urn:mhda:nt:evm:ci:a}b +refuse urn invalid_nss urn:mhda:nt:evm:ci:a[b +refuse urn invalid_nss urn:mhda:nt:evm:ci:a]b +refuse urn invalid_nss urn:mhda:nt:evm:ci:a%41 +refuse urn invalid_nss urn:mhda:nt:evm:ci:1:wi:x|y +refuse urn invalid_nss urn:mhda:nt:evm:ci:1:x|y:z +accept urn urn:mhda:nt:evm:ci:a-b.c_d~e!f$g&h'i(j)k*l+m,n;o=p@q/r urn:mhda:nt:evm:ci:a-b.c_d~e!f$g&h'i(j)k*l+m,n;o=p@q/r +accept urn urn:mhda:nt:cosmos:ci:cosmoshub-4:ap:X-avax:wi:c0a8f2d4-3b6e urn:mhda:nt:cosmos:ci:cosmoshub-4:ap:X-avax:wi:c0a8f2d4-3b6e diff --git a/tests/test_boundary.cpp b/tests/test_boundary.cpp index a75ec37..d8db74e 100644 --- a/tests/test_boundary.cpp +++ b/tests/test_boundary.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include #include "mhda/mhda.hpp" @@ -59,18 +61,22 @@ TEST_CASE("coin_type rejects leading plus") { error_code::invalid_coin_type); } -TEST_CASE("derivation_path leaf accepts uint32 max") { +TEST_CASE("derivation_path leaf accepts 2^31-1") { auto a = parse_urn( - "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()); + "urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/0'/0/2147483647"); + EXPECT_EQ(a.path()->index().index, std::uint32_t{0x7FFFFFFF}); EXPECT_EQ(a.str(), - std::string{"urn:mhda:nt:evm:ci:1:ct:60: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/2147483647"}); } -TEST_CASE("derivation_path leaf rejects uint32 max + 1") { - EXPECT_THROW_CODE(parse_urn( - "urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/0'/0/4294967296"), - error_code::invalid_derivation_path); +TEST_CASE("derivation_path leaf rejects 2^31 and above") { + // A level index is 31 bits: 2^31 and above collide with the hardened bit. + for (const char* leaf : {"2147483648", "2147483648'", "4294967295", "4294967295'", + "4294967296"}) { + EXPECT_THROW_CODE(parse_urn( + std::string{"urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/0'/0/"} + leaf), + error_code::invalid_derivation_path); + } } TEST_CASE("BIP-44 charge field rejects values outside {0,1}") { @@ -132,6 +138,44 @@ TEST_CASE("derivation_path rejects double hardening marker") { error_code::invalid_derivation_path); } +TEST_CASE("derivation_path fixed levels are spelled exactly") { + // Mirrors the go-mhda regexes: the purpose, the fixed coin and the 0|1 + // charge are literals, so a leading zero there is refused. + const std::vector> refused = { + {derivation_type::bip44, "m/044'/60'/0'/0/0"}, + {derivation_type::bip84, "m/084'/0'/0'/0/0"}, + {derivation_type::bip44, "m/44'/60'/0'/00/0"}, + {derivation_type::bip44, "m/44'/60'/0'/01/0"}, + {derivation_type::bip32, "m/0'/00/0"}, + {derivation_type::cip11, "m/044'/118'/0'/0/0"}, + {derivation_type::cip11, "m/44'/0118'/0'/0/0"}, + {derivation_type::cip1852, "m/01852'/1815'/0'/0/0"}, + {derivation_type::cip1852, "m/1852'/01815'/0'/0/0"}, + {derivation_type::zip32, "m/032'/133'/0'"}, + {derivation_type::zip32, "m/32'/0133'/0'"}, + }; + for (const auto& r : refused) { + EXPECT_THROW_CODE(derivation_path::parse(r.first, r.second), + error_code::invalid_derivation_path); + EXPECT_FALSE(validate_derivation_path(r.first, r.second)); + } +} + +TEST_CASE("derivation_path variable levels accept leading zeros") { + // The value counts; the canonical form drops the zeros. + const std::vector> accepted = { + {derivation_type::bip44, "m/44'/060'/00'/0/007", "m/44'/60'/0'/0/7"}, + {derivation_type::bip32, "m/00'/1/007'", "m/0'/1/7'"}, + {derivation_type::cip11, "m/44'/118'/0'/09/0", "m/44'/118'/0'/9/0"}, + {derivation_type::cip1852, "m/1852'/1815'/0'/02/0", "m/1852'/1815'/0'/2/0"}, + {derivation_type::zip32, "m/32'/133'/00'/00'", "m/32'/133'/0'/0'"}, + {derivation_type::slip10, "m/044'/0501'/00'", "m/44'/501'/0'"}, + }; + for (const auto& r : accepted) { + EXPECT_EQ(derivation_path::parse(std::get<0>(r), std::get<1>(r)).str(), std::get<2>(r)); + } +} + TEST_CASE("derivation_path accepts SLIP-10 with single level") { auto dp = derivation_path::parse(derivation_type::slip10, "m/0"); EXPECT_EQ(dp.str(), std::string{"m/0"}); @@ -142,12 +186,17 @@ TEST_CASE("derivation_path accepts SLIP-10 with single level") { // Huge inputs // --------------------------------------------------------------------------- -TEST_CASE("very long SLIP-10 path (256 levels) round-trips") { +TEST_CASE("SLIP-10 path of 255 levels round-trips, 256 is refused") { + // BIP-32 serialises a key's depth in one byte. std::string path = "m"; - for (int i = 0; i < 256; ++i) path += "/0'"; + for (int i = 0; i < 255; ++i) path += "/0'"; auto dp = derivation_path::parse(derivation_type::slip10, path); - EXPECT_EQ(dp.levels().size(), 256u); + EXPECT_EQ(dp.levels().size(), 255u); EXPECT_EQ(dp.str(), path); + path += "/0'"; + EXPECT_THROW_CODE(derivation_path::parse(derivation_type::slip10, path), + error_code::invalid_derivation_path); + EXPECT_FALSE(validate_derivation_path(derivation_type::slip10, path)); } TEST_CASE("very long chain_id (4 KiB) round-trips") { @@ -310,19 +359,19 @@ TEST_CASE("self-assignment via reference leaves address unchanged") { EXPECT_EQ(a.str(), before); } -TEST_CASE("from_levels with zero levels for SLIP10 produces empty path") { - auto dp = derivation_path::from_levels(derivation_type::slip10, {}); - EXPECT_EQ(dp.str(), std::string{"m"}); - EXPECT_FALSE(dp.has_index()); +TEST_CASE("from_levels with zero levels for SLIP10 is refused") { + // "m" alone is no SLIP-10 path: the parser refuses it, so does from_levels. + EXPECT_THROW_CODE(derivation_path::from_levels(derivation_type::slip10, {}), + error_code::invalid_derivation_path); } -TEST_CASE("from_levels under-populated for BIP44 leaves shortcuts at zero") { - auto dp = derivation_path::from_levels(derivation_type::bip44, { - {44, true}, {60, true}, // only 2 levels, well below the 5 expected - }); - EXPECT_EQ(dp.coin(), 0u); - EXPECT_EQ(dp.account(), 0u); - EXPECT_FALSE(dp.has_index()); +TEST_CASE("from_levels under-populated for BIP44 is refused") { + // Two levels are no BIP-44 path; str() would print the zero template + // m/44'/0'/0'/0/0 while levels() held 44'/60'. + EXPECT_THROW_CODE(derivation_path::from_levels(derivation_type::bip44, { + {44, true}, {60, true}, + }), + error_code::invalid_derivation_path); } TEST_CASE("hash and nss_hash do not crash on uninitialised address") { diff --git a/tests/test_chain.cpp b/tests/test_chain.cpp index dc78bde..39171c7 100644 --- a/tests/test_chain.cpp +++ b/tests/test_chain.cpp @@ -120,3 +120,12 @@ TEST_CASE("chain equality is the (nt, ci) identity") { chain other{network_type::ethereum_vm, "10"}; EXPECT_TRUE(a != other); } + +// '?' and '#' open the RFC 8141 r/q/f components; a chain key or NSS carrying +// one in a value would be truncated once embedded in a URN. +TEST_CASE("chain parsers refuse r/q/f delimiters") { + for (const char* s : {"nt:evm:ci:1#a", "nt:evm:ci:1?=q", "nt:evm:ci:a?b"}) { + EXPECT_THROW_CODE(chain::from_key(s), error_code::invalid_nss); + EXPECT_THROW_CODE(chain::from_nss(s), error_code::invalid_nss); + } +} diff --git a/tests/test_derivation_path.cpp b/tests/test_derivation_path.cpp index 9c54c9f..870ea5e 100644 --- a/tests/test_derivation_path.cpp +++ b/tests/test_derivation_path.cpp @@ -58,6 +58,30 @@ TEST_CASE("CIP-1852 levels exposed correctly") { for (std::size_t i = 0; i < want.size(); ++i) EXPECT_EQ(got[i], want[i]); } +// The CIP-11 charge and the CIP-1852 role take any level index. A value above +// 255 must keep its full width: truncated to a byte, role 256 would name the +// role-0 key. +TEST_CASE("CIP-11 charge and CIP-1852 role keep their full width") { + struct row { std::string urn; std::uint32_t want; }; + std::vector cases = { + {"urn:mhda:nt:cardano:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/0'/256/0", 256}, + {"urn:mhda:nt:cardano:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/0'/2147483647/0", 2147483647}, + {"urn:mhda:nt:cosmos:ci:cosmoshub:dt:cip11:dp:m/44'/118'/0'/257/0", 257}, + {"urn:mhda:nt:cosmos:ci:cosmoshub:dt:cip11:dp:m/44'/118'/0'/65536/0", 65536}, + }; + for (const auto& c : cases) { + auto addr = parse_urn_strict(c.urn); + EXPECT_EQ(addr.str(), c.urn); + EXPECT_EQ(std::uint32_t(addr.path()->charge()), c.want); + EXPECT_EQ(addr.path()->levels()[3], (address_index{c.want, false})); + } + + const std::uint32_t role = 300; + derivation_path dp{derivation_type::cip1852, coins::ada, 0, charge_type(role), + address_index{1, false}}; + EXPECT_EQ(dp.str(), std::string{"m/1852'/1815'/0'/300/1"}); +} + TEST_CASE("SLIP-10 mixed hardening round-trip") { 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); diff --git a/tests/test_edge.cpp b/tests/test_edge.cpp index db0bf68..37fe5bd 100644 --- a/tests/test_edge.cpp +++ b/tests/test_edge.cpp @@ -53,14 +53,14 @@ TEST_CASE("unmarshal_text rejection leaves receiver intact") { EXPECT_EQ(a.str(), before); // strong exception guarantee } -TEST_CASE("validate rejects unknown network even with valid algo/fmt") { - address a; +TEST_CASE("an unregistered network cannot be set on a chain") { + // set_network validates, so an address can no longer carry an + // unregistered network for validate() to meet. chain c; - c.set_network(network_type{"polkadot"}); // not registered - c.set_coin(354); - c.set_chain_id("mainnet"); - a = address{c, std::nullopt}; - EXPECT_THROW_CODE(a.validate(), error_code::incompatible); + EXPECT_THROW_CODE(c.set_network(network_type{"polkadot"}), error_code::invalid_network_type); + EXPECT_TRUE(c.network().empty()); + address a{c, std::nullopt}; + EXPECT_THROW_CODE(a.validate(), error_code::uninitialized_address); } TEST_CASE("validate of zero-value address yields uninitialized_address") { diff --git a/tests/test_fuzz.cpp b/tests/test_fuzz.cpp index 6f3227b..96e03d2 100644 --- a/tests/test_fuzz.cpp +++ b/tests/test_fuzz.cpp @@ -56,7 +56,7 @@ const std::vector kSeedURNs = { const std::vector> kSeedPaths = { {"bip32", "m/0'/0/0"}, - {"bip32", "m/2147483647'/1/4294967295'"}, + {"bip32", "m/2147483647'/1/2147483647'"}, // largest index, 2^31-1 {"bip44", "m/44'/60'/0'/0/0"}, {"bip44", "m/44'/0'/0'/0/0'"}, {"bip49", "m/49'/0'/0'/0/0"}, @@ -81,6 +81,8 @@ const std::vector> kSeedPaths = { {"slip10", "m"}, {"slip10", "m/"}, {"slip10", "m/99999999999999999999"}, + {"bip32", "m/2147483648'/1/0"}, // index 2^31 + {"bip44", "m/44'/60'/0'/0/4294967295"}, // index 2^32-1 {"unknown", "m/0/0/0"}, }; @@ -142,6 +144,12 @@ TEST_CASE("FuzzParseURN: no panic, idempotent on success") { std::string{"not idempotent: once="} + once + " twice=" + twice.str() + " input=" + src}); } + // The levels a wallet derives from must survive the round trip. + if (addr.path().has_value() != twice.path().has_value() || + (addr.path() && addr.path()->levels() != twice.path()->levels())) { + mhda_failures.push_back({__FILE__, __LINE__, + std::string{"path changed on re-parse: "} + once + " input=" + src}); + } } catch (const parse_error&) { // Rejected inputs are fine; the contract is just "no crash". } catch (const std::exception& e) { @@ -159,14 +167,24 @@ TEST_CASE("FuzzParseURN: no panic, idempotent on success") { } } -TEST_CASE("FuzzParseNSS: no panic, no half-state on success") { +TEST_CASE("FuzzParseNSS: no panic, emitted URN re-parses to itself") { std::mt19937_64 rng(0xDECADEULL); constexpr int kIterations = 4000; auto check = [&](const std::string& src) { try { auto addr = parse_nss(src); - (void)addr.str(); - (void)addr.nss(); + const std::string once = addr.str(); + if (once != std::string{kPrefix} + addr.nss()) { + mhda_failures.push_back({__FILE__, __LINE__, + std::string{"str() is not the prefix plus nss(): "} + once}); + return; + } + auto twice = parse_urn(once); + if (twice.str() != once) { + mhda_failures.push_back({__FILE__, __LINE__, + std::string{"not idempotent: once="} + once + + " twice=" + twice.str() + " input=" + src}); + } } catch (const parse_error&) { // Rejection is fine. } catch (const std::exception& e) { @@ -184,6 +202,8 @@ TEST_CASE("FuzzParseNSS: no panic, no half-state on success") { const std::vector bare_seeds = { "", "n", "nt", "nt:", "nt:evm", "nt:evm:ci:1:ct:60", "nt:evm:ci:1", "nt:evm:ci:1:wt:web3:wi:5f2a8c31", + "nt:evm:ci:1?=q:dt:bip44:dp:m/44'/60'/0'/0/0", + "nt:evm:ci:1:wi:a#b", }; for (const auto& s : bare_seeds) check(s); for (int i = 0; i < kIterations; ++i) { @@ -208,7 +228,20 @@ TEST_CASE("FuzzDerivationPath: no panic, idempotent on success") { + " twice=" + dp2.str() + " input dt=" + dt_str + " path=" + path}); } - (void)dp.levels(); + // str() and levels() describe the same path. + if (dp2.levels() != dp.levels()) { + mhda_failures.push_back({__FILE__, __LINE__, + std::string{"path changed on re-parse: "} + once + + " input dt=" + dt_str + " path=" + path}); + } + // No accepted level may carry an index of 2^31 or more. + for (const auto& lvl : dp.levels()) { + if (lvl.index > 0x7FFFFFFFu) { + mhda_failures.push_back({__FILE__, __LINE__, + std::string{"level above 2^31-1: input dt="} + dt_str + + " path=" + path}); + } + } } catch (const parse_error&) { // Rejection is fine. } catch (const std::invalid_argument&) { diff --git a/tests/test_hardening.cpp b/tests/test_hardening.cpp index c1d590d..8bffa88 100644 --- a/tests/test_hardening.cpp +++ b/tests/test_hardening.cpp @@ -171,3 +171,154 @@ TEST_CASE("explicit ct:0 round-trips at URN level") { if (addr.get_chain().coin()) EXPECT_EQ(*addr.get_chain().coin(), 0u); EXPECT_EQ(addr.str(), in); } + +// The network type, the chain id and the derivation type are written +// verbatim into every URN. A value carrying ':' would inject components on +// re-parse (a root address whose URN reads back as a bip44 path), '?' / '#' +// would truncate it. Constructors and setters throw parse_error on such a +// value; a failed setter keeps the old value. Mirrors go-mhda. +TEST_CASE("programmatic values cannot inject components") { + struct row { network_type nt; std::string ci; error_code want; }; + const std::vector rows = { + {network_type::ethereum_vm, "1:dt:bip44:dp:m/44'/60'/0'/0/666", error_code::invalid_value}, + {network_type::ethereum_vm, "1:wi:attacker", error_code::invalid_value}, + {network_type::ethereum_vm, "1?=q", error_code::invalid_value}, + {network_type::ethereum_vm, "1#f", error_code::invalid_value}, + {network_type::ethereum_vm, "a b", error_code::invalid_value}, + {network_type::ethereum_vm, "\xc2\xa0" "1", error_code::invalid_value}, + {network_type::ethereum_vm, "", error_code::missing_chain_id}, + {network_type::ethereum_vm, " \t", error_code::missing_chain_id}, + {network_type{"evm:ci:1:dt:bip44"}, "1", error_code::invalid_network_type}, + {network_type{"polkadot"}, "1", error_code::invalid_network_type}, + {network_type{"EVM"}, "1", error_code::invalid_network_type}, + {network_type{}, "1", error_code::invalid_network_type}, + }; + for (const auto& r : rows) { + EXPECT_THROW_CODE((chain{r.nt, r.ci}), r.want); + } + + const chain trimmed{network_type::ethereum_vm, " 0x1 "}; + EXPECT_EQ(trimmed.id(), std::string{"0x1"}); + + chain ch{network_type::ethereum_vm, "1"}; + EXPECT_THROW_CODE(ch.set_chain_id("1:dt:bip44"), error_code::invalid_value); + EXPECT_THROW_CODE(ch.set_chain_id(""), error_code::missing_chain_id); + EXPECT_THROW_CODE(ch.set_network(network_type{"evm:x"}), error_code::invalid_network_type); + EXPECT_EQ(ch.str(), std::string{"nt:evm:ci:1"}); + EXPECT_NO_THROW(ch.set_chain_id("56")); + EXPECT_NO_THROW(ch.set_network(network_type::bitcoin)); + EXPECT_EQ(ch.str(), std::string{"nt:bitcoin:ci:56"}); + + // The mutable accessor goes through the same setters. + auto a = parse_urn("urn:mhda:nt:evm:ci:1"); + EXPECT_THROW_CODE(a.get_chain().set_chain_id("1:dt:bip44:dp:m/44'/60'/0'/0/666"), + error_code::invalid_value); + EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1"}); + + for (const char* dt : {"bip44:wi:x", "bogus", "BIP44", ""}) { + EXPECT_THROW_CODE((derivation_path{derivation_type{dt}, 60, 0, 0, address_index{}}), + error_code::invalid_derivation_type); + EXPECT_THROW_CODE(derivation_path::from_levels(derivation_type{dt}, {}), + error_code::invalid_derivation_type); + derivation_path p; + EXPECT_THROW_CODE(p.set_type(derivation_type{dt}), error_code::invalid_derivation_type); + } +} + +// SPEC §12: zip32 parses leniently, but no network registers it, so strict +// parsing refuses it on every network. Mirrors go-mhda. +TEST_CASE("zip32 has no network") { + for (const auto& nt : {network_type::bitcoin, network_type::ethereum_vm, + network_type::avalanche_vm, network_type::tron_vm, + network_type::cosmos, network_type::solana, + network_type::xrp_ledger, network_type::stellar, + network_type::near_protocol, network_type::aptos, + network_type::sui, network_type::cardano, + network_type::algorand, network_type::toncoin}) { + const std::string urn = "urn:mhda:nt:" + nt.str() + ":ci:x:dt:zip32:dp:m/32'/133'/0'"; + EXPECT_NO_THROW(parse_urn(urn)); + EXPECT_THROW_CODE(parse_urn_strict(urn), error_code::incompatible); + } +} + +// Error messages quote the offending input. A caller that logs what() must +// not receive the input's raw bytes: a newline or an ANSI escape in a +// client-supplied value would forge or colour log lines. Like Go's %q, +// what() escapes quotes, backslashes, control and non-ASCII bytes. +TEST_CASE("error messages escape the input they quote") { + const std::vector> calls = { + [] { address a; a.set_wallet_id("x\n[INFO] ok"); }, + [] { address a; a.set_address_algorithm("r\x1b[31msa"); }, + [] { address a{chain{network_type::ethereum_vm, "1"}, std::nullopt}; + a.set_coin_type("6\x1b""0"); }, + [] { (void)derivation_type_from_string("bip\n44"); }, + [] { (void)derivation_path::parse(derivation_type::bip44, "m/44'\n/\"x\\"); }, + [] { (void)derivation_path::parse(derivation_type{"bip\x7f"}, "m/0"); }, + [] { (void)chain::from_key("nt:evm:ci:1:\x1b"); }, + [] { (void)chain{network_type{"ev\nm"}, "1"}; }, + [] { (void)parse_urn("urn:mhda:nt:evm:ci:1:dt:bip\xc2\xa0" "44:dp:m/0"); }, + }; + for (std::size_t i = 0; i < calls.size(); ++i) { + std::string what; + try { + calls[i](); + } catch (const parse_error& e) { + what = e.what(); + } + if (what.empty()) { + mhda_failures.push_back({__FILE__, __LINE__, + "call " + std::to_string(i) + " did not throw parse_error"}); + continue; + } + for (char c : what) { + const auto b = static_cast(c); + if (b < 0x20 || b > 0x7e) { + mhda_failures.push_back({__FILE__, __LINE__, + "call " + std::to_string(i) + ": raw byte in what(): " + what}); + break; + } + } + } + try { + address a; + a.set_wallet_id("x\n\"y\\"); + } catch (const parse_error& e) { + EXPECT_TRUE(std::string{e.what()}.find(R"("x\n\"y\\")") != std::string::npos); + } +} + +// ASCII whitespace is trimmed around the whole URN (and around an NSS or +// chain key given on its own, and before an r/q/f component), never around a +// key or value inside it. Mirrors go-mhda's TestNoWhitespaceInsideNSS. +TEST_CASE("no whitespace inside the NSS") { + for (const char* urn : {"urn:mhda:nt:evm:ci: 1", + "urn:mhda:nt:evm:ci:1 :dt:bip44:dp:m/44'/60'/0'/0/0", + "urn:mhda:nt: evm:ci:1", + "urn:mhda:nt:evm:ci:1:dt: bip44 :dp:m/44'/60'/0'/0/0", + "urn:mhda: nt:evm:ci:1", + "urn:mhda:nt:evm:ci:1\t:wt:x"}) { + EXPECT_THROW_CODE(parse_urn(urn), error_code::invalid_nss); + } + const std::vector> accepted = { + {" urn:mhda:nt:evm:ci:1\t", "urn:mhda:nt:evm:ci:1"}, + {"urn:mhda:nt:evm:ci:0 #frag", "urn:mhda:nt:evm:ci:0"}, + {"urn:mhda:nt:evm:ci:1 ?=q", "urn:mhda:nt:evm:ci:1"}, + }; + for (const auto& a : accepted) { + EXPECT_EQ(parse_urn(a.first).str(), a.second); + } + EXPECT_NO_THROW(parse_nss(" nt:evm:ci:1 ")); + EXPECT_NO_THROW(chain::from_nss("\tnt:evm:ci:1 ")); + EXPECT_THROW_CODE(chain::from_nss("nt:evm:ci: 1"), error_code::invalid_nss); +} + +// Values set in code obey the same RFC 3986 byte set as parsed ones. +// Mirrors go-mhda's TestFreeFormValuesUseTheNSSCharset. +TEST_CASE("free-form values use the NSS byte set") { + auto a = parse_urn("urn:mhda:nt:evm:ci:1"); + for (const char* v : {"x|y", "a%41", "", "a\"b", "a{b}", "a\\b"}) { + EXPECT_THROW_CODE(a.set_wallet_id(v), error_code::invalid_value); + EXPECT_THROW_CODE((chain{network_type::ethereum_vm, v}), error_code::invalid_value); + } + EXPECT_NO_THROW(a.set_wallet_id("c0a8f2d4-3b6e_x.y~z@w")); +} diff --git a/tests/test_hash.cpp b/tests/test_hash.cpp index d01c73f..bfcfaaf 100644 --- a/tests/test_hash.cpp +++ b/tests/test_hash.cpp @@ -1,3 +1,7 @@ +#include +#include +#include + #include "mhda/mhda.hpp" #include "ostream_helpers.hpp" #include "test_framework.hpp" @@ -36,3 +40,61 @@ TEST_CASE("Hash reference values") { EXPECT_EQ(addr.nss_hash256(), std::string{"828f1579eb41e06dcf1a4b32a0223ce57f4be481d79e62735ca64c4a3696a5a2"}); } + +// Digests at the SHA-1/SHA-256 block and padding boundaries: str() of 55, +// 56, 63, 64, 119, 120, 128 and 1000 bytes (the NSS is 9 bytes shorter, so +// it covers 55 too). Computed with Python's hashlib; go-mhda pins the same +// values, so both implementations produce the same identifiers across every +// padding case. +TEST_CASE("Hash reference values at block boundaries") { + struct row { + std::size_t size; + const char* hash; + const char* hash256; + const char* nss_hash; + const char* nss_hash256; + }; + const std::vector rows = { + {55, "7230ec9cac1176541d5e454a5977547849806ef3", + "4bcf74edcd630246d1cb1f03be5dc2deb7cc850270a2d3f279d68166778e2065", + "3c1704aa5f179b7d21a9f726e20473ac2b5786e1", + "c85613e0a6562581d353224545cbebc3710ddb496d4b03baddd435fa311b7bbb"}, + {56, "7c08382a5672e118ef014b54c8d1b42e7b6cb90b", + "a176aa8ff4ccdcb7b05e3e39440cc40b8b17219910f3081c062045abbf331255", + "8f17a0079072eb355e316ebdbb73aa4328762ff7", + "242f19fedc19e36ab9c4378b45922e731269ea04c325b58c4a90d4389ffce482"}, + {63, "6b6034fd2e44f4ad8ea9da946a6915f017966ead", + "0be4d717499d1b2a081c60cf64f0c59b5e09a1a0da1e65c497c83e839caef655", + "4b8360e09c0db0f1c423342d03db990ee4795695", + "0bd7a04f6ca2b30eca17273961fa78db6f345e654ed80ac78a85d780d73d0267"}, + {64, "c5b9fb4f88ee7f77e14566483838fe03eb5f722a", + "be3e95721333ec68988eedb3b6c37c7c48ee30211c3bef96900609e8f1dd3546", + "fba8bdfdfb761ca75bc1a38886a7219cba376adc", + "713ea1b363b46219865aded589c56341e7d0e63c9278e7e11ae154f05883d1c5"}, + {119, "91f3a5706cdf32541f6c2d45ea4280399da43847", + "d04d2a8a7f6df456cada8d263a746f0d21e4b600ef7bdcea4f6e645c17d94914", + "94285bd9afc8f49eb2059992f3660aee22e76063", + "d3398acc7f987d3d3c90acd2e4ef958bd7053a8a88e4b2d51b5774b0fb03cfdc"}, + {120, "8f7e2ab8a8a0c101e299154982f0e262bf43b7d0", + "a4ed7ff3b82d6f7fa7a322ca72971a2b1e6474a5d08475599bc965282acb49c7", + "7eb1dc21b8685d3b9dbc9c2cbb9240acc90a2660", + "aba04d6e4a96a9f662bb9ac433ba50f2c34d7991aee8cb2787ab91bc4228e88a"}, + {128, "56e36e13d0581067945cd12955e3ce4b21967a48", + "b6fb21cc83b07ab98648ec0b1bb04a3d79cd31d231cc07303d24ce73145a46ed", + "c792663db1797615b2433d56b96bf73d859a3e6f", + "2c04bb624a604bd96c5b0e4b9e1006bbe898f134b60a40a1ed81d63e03897ea7"}, + {1000, "f3acba196b25a975d6989beef6693337b5987f26", + "7d57f53b9e4785b1735151906fd63ccb70547a8a2292986b613cf23db7b0f3d7", + "d16eb7a3991c6ace27d58b3e80e617567cfd242b", + "2e7dd698a37e3fb7660b19bcad20b60677bd1e7f01d79870daa09a8a1f7269d8"}, + }; + for (const auto& r : rows) { + const std::string urn = "urn:mhda:nt:evm:ci:" + std::string(r.size - 19, 'x'); + const auto addr = parse_urn(urn); + EXPECT_EQ(addr.str().size(), r.size); + EXPECT_EQ(addr.hash(), std::string{r.hash}); + EXPECT_EQ(addr.hash256(), std::string{r.hash256}); + EXPECT_EQ(addr.nss_hash(), std::string{r.nss_hash}); + EXPECT_EQ(addr.nss_hash256(), std::string{r.nss_hash256}); + } +} diff --git a/tests/test_index_range.cpp b/tests/test_index_range.cpp new file mode 100644 index 0000000..cd484d7 --- /dev/null +++ b/tests/test_index_range.cpp @@ -0,0 +1,142 @@ +// Derivation-path level index range, mirroring index_range_test.go of the Go +// reference: a level index of 2^31 or more is refused at every level of every +// derivation type, and the conformance table shared with go-mhda +// (tests/data/dp_conformance.txt) runs through both the standalone and the +// URN parser. + +#include +#include +#include +#include +#include +#include + +#include "mhda/mhda.hpp" +#include "ostream_helpers.hpp" +#include "test_framework.hpp" + +using namespace mhda; + +namespace { + +constexpr std::uint32_t kMaxLevelIndex = 0x7FFFFFFFu; + +} // namespace + +// An index of 2^31 or more collides with the hardened bit of a BIP-32 child +// number: m/44'/60'/2147483648'/0/0 would derive the key of m/44'/60'/0'/0/0, +// and an unhardened 2147483648 would ask for a hardened child through the +// public-key formula. +TEST_CASE("level index of 2^31 or more is refused") { + const std::vector urns = { + "urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/2147483648'/0/0", + "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/2147483648'/0/0", + "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/2147483648", + "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/4294967295", + "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/2147483708'/0'/0/0", + "urn:mhda:nt:solana:ci:mainnet:dt:slip10:dp:m/44'/501'/2147483648'/0'", + }; + for (const auto& urn : urns) { + EXPECT_THROW_CODE(parse_urn(urn), error_code::invalid_derivation_path); + EXPECT_THROW_CODE(parse_urn_strict(urn), error_code::invalid_derivation_path); + } + + const std::string max = "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/2147483647'/0/2147483647'"; + auto a = parse_urn_strict(max); + EXPECT_EQ(a.str(), max); + EXPECT_EQ(a.path()->account(), kMaxLevelIndex); + EXPECT_EQ(a.path()->index(), (address_index{kMaxLevelIndex, true})); +} + +// The ct component is SLIP-44 metadata, not a path level, and keeps the full +// 32-bit range. +TEST_CASE("ct is not a path level and keeps 32 bits") { + const std::vector> rows = { + {"urn:mhda:nt:evm:ci:1:ct:2147483648:dt:bip44:dp:m/44'/60'/0'/0/0", 2147483648u}, + {"urn:mhda:nt:evm:ci:1:ct:4294967295:dt:bip44:dp:m/44'/60'/0'/0/0", 4294967295u}, + }; + for (const auto& r : rows) { + auto a = parse_urn(r.first); + EXPECT_TRUE(a.get_chain().coin().has_value()); + EXPECT_EQ(*a.get_chain().coin(), r.second); + EXPECT_EQ(a.str(), r.first); + } +} + +// Runs the derivation-path table shared verbatim with go-mhda +// (testdata/dp_conformance.txt there). Every row is parsed standalone, through +// validate_derivation_path and inside a URN; all three must agree with the row. +TEST_CASE("derivation-path conformance table shared with go-mhda") { + std::ifstream in(MHDA_TEST_DATA_DIR "/dp_conformance.txt"); + if (!in) { + mhda_failures.push_back({__FILE__, __LINE__, + "cannot open " MHDA_TEST_DATA_DIR "/dp_conformance.txt"}); + return; + } + + int rows = 0; + std::string text; + for (int line = 1; std::getline(in, text); ++line) { + if (text.empty() || text[0] == '#') continue; + std::istringstream fields(text); + std::string verdict, dt_str, path, canonical, extra; + fields >> verdict >> dt_str >> path >> canonical >> extra; + const bool accept = verdict == "accept"; + if (!extra.empty() || (accept && canonical.empty()) || + (!accept && (verdict != "refuse" || !canonical.empty()))) { + mhda_failures.push_back({__FILE__, __LINE__, + "dp_conformance.txt:" + std::to_string(line) + ": malformed row " + text}); + return; + } + ++rows; + const std::string where = "dp_conformance.txt:" + std::to_string(line) + ": " + text; + const derivation_type dt{dt_str}; + + if (validate_derivation_path(dt, path) != accept) { + mhda_failures.push_back({__FILE__, __LINE__, "validate_derivation_path disagrees, " + where}); + } + + const std::string urn = "urn:mhda:nt:evm:ci:1:dt:" + dt_str + ":dp:" + path; + try { + auto dp = derivation_path::parse(dt, path); + if (!accept) { + mhda_failures.push_back({__FILE__, __LINE__, "parse accepted, " + where}); + } else { + if (dp.str() != canonical) { + mhda_failures.push_back({__FILE__, __LINE__, + "parse str() = " + dp.str() + ", " + where}); + } + for (const auto& lvl : dp.levels()) { + if (lvl.index > kMaxLevelIndex) { + mhda_failures.push_back({__FILE__, __LINE__, + "level above 2^31-1, " + where}); + } + } + } + } catch (const parse_error& e) { + if (accept || e.code() != error_code::invalid_derivation_path) { + mhda_failures.push_back({__FILE__, __LINE__, + std::string{"parse threw "} + e.what() + ", " + where}); + } + } + + try { + auto a = parse_urn(urn); + if (!accept) { + mhda_failures.push_back({__FILE__, __LINE__, "parse_urn accepted, " + where}); + } else if (a.str() != "urn:mhda:nt:evm:ci:1:dt:" + dt_str + ":dp:" + canonical) { + mhda_failures.push_back({__FILE__, __LINE__, + "parse_urn str() = " + a.str() + ", " + where}); + } + } catch (const parse_error& e) { + if (accept || e.code() != error_code::invalid_derivation_path) { + mhda_failures.push_back({__FILE__, __LINE__, + std::string{"parse_urn threw "} + e.what() + ", " + where}); + } + } + } + if (rows < 300) { + mhda_failures.push_back({__FILE__, __LINE__, + "dp_conformance.txt: " + std::to_string(rows) + " rows, the table is truncated"}); + } +} diff --git a/tests/test_parse.cpp b/tests/test_parse.cpp index 82fc257..ebb7878 100644 --- a/tests/test_parse.cpp +++ b/tests/test_parse.cpp @@ -169,9 +169,13 @@ TEST_CASE("set prefix/suffix reset") { EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1"}); } -TEST_CASE("set_derivation_path is no-op for ROOT") { +TEST_CASE("set_derivation_path refuses a path under ROOT") { + // Silently ignoring it would make the address name the root key instead + // of the path given. 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_THROW_CODE(a.set_derivation_path("m/44'/0'/0'/0/0"), + error_code::invalid_derivation_path); + EXPECT_NO_THROW(a.set_derivation_path("")); EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1"}); } diff --git a/tests/test_state.cpp b/tests/test_state.cpp new file mode 100644 index 0000000..3fc8249 --- /dev/null +++ b/tests/test_state.cpp @@ -0,0 +1,124 @@ +// State of derivation_path and address across construction, re-parsing and +// type changes, mirroring state_test.go of the Go reference: a constructed +// path is exactly what the parser gives back for its own str(), parsing +// replaces a path as a whole, and a type change drops the old path. + +#include +#include +#include +#include +#include + +#include "mhda/mhda.hpp" +#include "ostream_helpers.hpp" +#include "test_framework.hpp" + +using namespace mhda; + +TEST_CASE("constructors make only parseable paths") { + const std::vector>> refused = { + {"bip44 purpose 49", [] { derivation_path::from_levels(derivation_type::bip44, + {{49, true}, {60, true}, {0, true}, {0, false}, {0, false}}); }}, + {"bip44 soft account", [] { derivation_path::from_levels(derivation_type::bip44, + {{44, true}, {60, true}, {0, false}, {0, false}, {0, false}}); }}, + {"bip44 two levels", [] { derivation_path::from_levels(derivation_type::bip44, + {{44, true}, {60, true}}); }}, + {"slip10 no levels", [] { derivation_path::from_levels(derivation_type::slip10, {}); }}, + {"slip10 index 2^31", [] { derivation_path::from_levels(derivation_type::slip10, + {{0x80000000u, false}}); }}, + {"root with a level", [] { derivation_path::from_levels(derivation_type::root, + {{0, true}}); }}, + {"bip44 account 2^31", [] { derivation_path(derivation_type::bip44, 60, 0x80000000u, 0, + address_index{}); }}, + {"bip44 charge 7", [] { derivation_path(derivation_type::bip44, 60, 0, 7, address_index{}); }}, + {"bip32 index 4294967295", [] { derivation_path(derivation_type::bip32, 0, 0, 0, + address_index{4294967295u, false}); }}, + }; + for (const auto& r : refused) { + bool code_ok = false; + try { + r.second(); + } catch (const parse_error& e) { + code_ok = e.code() == error_code::invalid_derivation_path; + } + if (!code_ok) { + mhda_failures.push_back({__FILE__, __LINE__, + r.first + ": expected parse_error(invalid_derivation_path)"}); + } + } + + const auto dp = derivation_path::from_levels(derivation_type::bip44, + {{44, true}, {60, true}, {0, true}, {0, false}, {5, false}}); + const auto parsed = derivation_path::parse(derivation_type::bip44, "m/44'/60'/0'/0/5"); + EXPECT_EQ(dp.str(), parsed.str()); + EXPECT_TRUE(dp.levels() == parsed.levels()); + EXPECT_EQ(dp.coin(), parsed.coin()); + EXPECT_EQ(dp.account(), parsed.account()); + EXPECT_EQ(dp.charge(), parsed.charge()); + EXPECT_EQ(dp.index(), parsed.index()); + // BIP-32 has no coin level: the constructor drops the coin like the parser. + EXPECT_EQ(derivation_path(derivation_type::bip32, 60, 0, 0, address_index{}).coin(), 0u); +} + +TEST_CASE("re-parsing leaves no stale state") { + // ZIP-32: a 3-level path after a 4-level one has no index left over. + auto dp = derivation_path::parse(derivation_type::zip32, "m/32'/133'/0'/5"); + dp.parse_path("m/32'/133'/1'"); + EXPECT_EQ(dp.str(), std::string{"m/32'/133'/1'"}); + EXPECT_EQ(dp.levels().size(), 3u); + EXPECT_EQ(dp.index(), address_index{}); + + // SLIP-10 after BIP-44: the type change drops the BIP-44 shortcuts. + auto sl = derivation_path::parse(derivation_type::bip44, "m/44'/60'/5'/1/9"); + sl.set_type(derivation_type::slip10); + EXPECT_TRUE(sl.levels().empty()); + EXPECT_EQ(sl.str(), std::string{}); + sl.parse_path("m/0'"); + EXPECT_EQ(sl.coin(), 0u); + EXPECT_EQ(sl.account(), 0u); + EXPECT_EQ(sl.charge(), 0u); + EXPECT_EQ(sl.index(), address_index{}); + + // The same type keeps the path; a failed parse changes nothing. + auto keep = derivation_path::parse(derivation_type::bip44, "m/44'/60'/3'/1/9"); + keep.set_type(derivation_type::bip44); + EXPECT_THROW_CODE(keep.parse_path("m/44'/60'/4'/0/2147483648"), + error_code::invalid_derivation_path); + EXPECT_EQ(keep.str(), std::string{"m/44'/60'/3'/1/9"}); + EXPECT_EQ(keep.account(), 3u); +} + +// A new derivation type invalidates the old path. Until a path is set the +// address names no path at all: its URN carries dt without dp (and so does +// not parse), and validate / marshal_text refuse it. +TEST_CASE("a derivation type change drops the path") { + const std::string bip44 = "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/3'/1/9"; + auto a = parse_urn(bip44); + a.set_derivation_type("zip32"); + EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1:dt:zip32"}); + EXPECT_TRUE(a.path()->levels().empty()); + EXPECT_THROW_CODE(a.validate(), error_code::invalid_derivation_path); + EXPECT_THROW_CODE(a.marshal_text(), error_code::invalid_derivation_path); + EXPECT_THROW_CODE(parse_urn(a.str()), error_code::invalid_derivation_path); + a.set_derivation_path("m/32'/133'/7'"); + EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1:dt:zip32:dp:m/32'/133'/7'"}); + + // The same type, in any spelling, keeps the path. + auto b = parse_urn(bip44); + b.set_derivation_type(" BIP44 "); + EXPECT_EQ(b.str(), bip44); +} + +// set_derivation validates the type and the path before changing anything. +TEST_CASE("set_derivation is atomic") { + const std::string bip44 = "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/3'/1/9"; + auto a = parse_urn(bip44); + EXPECT_THROW_CODE(a.set_derivation("slip10", "garbage"), error_code::invalid_derivation_path); + EXPECT_THROW_CODE(a.set_derivation("bogus", "m/0'"), error_code::invalid_derivation_type); + EXPECT_EQ(a.str(), bip44); + a.set_derivation("slip10", "m/0'/1'"); + EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1:dt:slip10:dp:m/0'/1'"}); + EXPECT_EQ(a.path()->account(), 0u); + a.set_derivation("", ""); + EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1"}); +} diff --git a/tests/test_static_init.cpp b/tests/test_static_init.cpp new file mode 100644 index 0000000..f3eca12 --- /dev/null +++ b/tests/test_static_init.cpp @@ -0,0 +1,66 @@ +// mhda used from a consumer's namespace-scope objects: while the program is +// initialised (a built-in chain table, a URN validated up front, a lookup) +// and while it is torn down (a destructor that still parses). The named +// constants and the lookup tables must already exist in the first case and +// still exist in the second. + +#include +#include +#include +#include + +#include "mhda/mhda.hpp" +#include "ostream_helpers.hpp" +#include "test_framework.hpp" + +using namespace mhda; + +namespace { + +constexpr const char* kURN = "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0"; + +// Defined before g_init, so it is constructed before g_init builds the +// lookup tables and destroyed after them, unless the tables outlive static +// destruction. A failure here can only end the process: exit code 3. +struct exit_parser { + ~exit_parser() { + try { + (void)parse_urn_strict(kURN); + } catch (const std::exception& e) { + std::fprintf(stderr, "parse_urn_strict at static destruction threw: %s\n", e.what()); + std::_Exit(3); + } + } +}; +exit_parser g_exit; + +struct init_probe { + std::string btc_key; + std::string evm_lookup; + std::string strict_error = "not run"; + + init_probe() { + try { + btc_key = chain{network_type::bitcoin, "0"}.key(); + auto nt = network_type_from_string("evm"); + evm_lookup = nt ? nt->str() : std::string{}; + (void)parse_urn_strict(kURN); + strict_error.clear(); + } catch (const std::exception& e) { + strict_error = e.what(); + } + } +}; +const init_probe g_init; + +} // namespace + +TEST_CASE("mhda works from a consumer's static initialisers") { + EXPECT_EQ(g_init.btc_key, std::string{"nt:bitcoin:ci:0"}); + EXPECT_EQ(g_init.evm_lookup, std::string{"evm"}); + EXPECT_EQ(g_init.strict_error, std::string{}); + // Tables first built during static initialisation stay right for the + // rest of the process. + EXPECT_NO_THROW(parse_urn_strict(kURN)); + EXPECT_NO_THROW(parse_urn_strict("urn:mhda:nt:bitcoin:ci:0:dt:bip84:dp:m/84'/0'/0'/0/0:af:p2wpkh")); +} diff --git a/tests/test_urn_conformance.cpp b/tests/test_urn_conformance.cpp new file mode 100644 index 0000000..25b2cd6 --- /dev/null +++ b/tests/test_urn_conformance.cpp @@ -0,0 +1,102 @@ +// URN-level conformance table shared verbatim with go-mhda +// (testdata/urn_conformance.txt there): an input one implementation refuses, +// the other refuses too, with the same error. + +#include +#include +#include +#include + +#include "mhda/mhda.hpp" +#include "ostream_helpers.hpp" +#include "test_framework.hpp" + +using namespace mhda; + +namespace { + +const std::map& conformance_errors() { + static const std::map m = { + {"invalid_urn", error_code::invalid_urn}, + {"invalid_nss", error_code::invalid_nss}, + {"missing_network_type", error_code::missing_network_type}, + {"invalid_network_type", error_code::invalid_network_type}, + {"invalid_coin_type", error_code::invalid_coin_type}, + {"missing_chain_id", error_code::missing_chain_id}, + {"invalid_derivation_type", error_code::invalid_derivation_type}, + {"invalid_derivation_path", error_code::invalid_derivation_path}, + {"invalid_algorithm", error_code::invalid_algorithm}, + {"invalid_format", error_code::invalid_format}, + {"invalid_value", error_code::invalid_value}, + {"incompatible", error_code::incompatible}, + }; + return m; +} + +} // namespace + +TEST_CASE("URN conformance table shared with go-mhda") { + std::ifstream in(MHDA_TEST_DATA_DIR "/urn_conformance.txt"); + if (!in) { + mhda_failures.push_back({__FILE__, __LINE__, + "cannot open " MHDA_TEST_DATA_DIR "/urn_conformance.txt"}); + return; + } + + int rows = 0; + std::string text; + for (int line = 1; std::getline(in, text); ++line) { + if (text.empty() || text[0] == '#') continue; + std::istringstream fields(text); + std::string verdict, kind, a, b, extra; + fields >> verdict >> kind >> a >> b >> extra; + const std::string where = "urn_conformance.txt:" + std::to_string(line) + ": " + text; + if (!extra.empty() || b.empty() || (kind != "urn" && kind != "nss" && kind != "strict") || + (verdict != "accept" && verdict != "refuse")) { + mhda_failures.push_back({__FILE__, __LINE__, "malformed row, " + where}); + return; + } + ++rows; + const bool accept = verdict == "accept"; + const std::string& input = accept ? a : b; + error_code want{}; + if (!accept) { + auto it = conformance_errors().find(a); + if (it == conformance_errors().end()) { + mhda_failures.push_back({__FILE__, __LINE__, "unknown error name, " + where}); + return; + } + want = it->second; + } + + try { + const address addr = kind == "urn" ? parse_urn(input) + : kind == "strict" ? parse_urn_strict(input) + : parse_nss(input); + if (!accept) { + mhda_failures.push_back({__FILE__, __LINE__, "accepted, " + where}); + } else if (addr.str() != b) { + mhda_failures.push_back({__FILE__, __LINE__, + "str() = " + addr.str() + ", " + where}); + } + } catch (const parse_error& e) { + if (accept || e.code() != want) { + mhda_failures.push_back({__FILE__, __LINE__, + std::string{"threw "} + e.what() + ", " + where}); + } + } + } + if (rows == 0) { + mhda_failures.push_back({__FILE__, __LINE__, "urn_conformance.txt: no rows"}); + } +} + +// The setter refuses a path while the type is root, and accepts an empty one. +TEST_CASE("set_derivation_path under root") { + auto addr = parse_urn("urn:mhda:nt:evm:ci:1"); + EXPECT_THROW_CODE(addr.set_derivation_path("m/44'/60'/0'/0/0"), + error_code::invalid_derivation_path); + EXPECT_NO_THROW(addr.set_derivation_path("")); + EXPECT_NO_THROW(addr.set_derivation_path(" ")); + EXPECT_EQ(addr.str(), std::string{"urn:mhda:nt:evm:ci:1"}); +} diff --git a/tests/test_validate.cpp b/tests/test_validate.cpp index 68c09aa..7e917f1 100644 --- a/tests/test_validate.cpp +++ b/tests/test_validate.cpp @@ -71,9 +71,10 @@ TEST_CASE("derivation compatibility leg of strict mode") { TEST_CASE("Bitcoin all script formats") { const std::vector urns = { "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:bip49:dp:m/49'/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", + // P2WSH has no single-key purpose (multisig is BIP-48): plain bip32. + "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip32:dp:m/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", @@ -124,7 +125,7 @@ TEST_CASE("README examples round-trip strictly") { "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", + "urn:mhda:nt:xrpl:ci:mainnet:ct:144:aa:ed25519", // Stellar "urn:mhda:nt:stellar:ci:mainnet:dt:slip10:dp:m/44'/148'/0'", // NEAR diff --git a/tests/test_windows_macros.cpp b/tests/test_windows_macros.cpp new file mode 100644 index 0000000..9a5fce5 --- /dev/null +++ b/tests/test_windows_macros.cpp @@ -0,0 +1,18 @@ +// (minwindef.h) defines `near` and `far` as empty macros. The +// public headers must compile with them in effect, as they are in any Windows +// translation unit that includes first. +#define near +#define far + +#include "mhda/mhda.hpp" +#include "ostream_helpers.hpp" +#include "test_framework.hpp" + +TEST_CASE("headers compile under the near/far macros") { + // coins::near itself is unusable while the macro is defined; its alias + // near_protocol (after network_type::near_protocol) is not. + EXPECT_EQ(mhda::coins::near_protocol, mhda::coin_type{397}); +} + +#undef near +#undef far