diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a85e1b4..5caec9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,11 @@ jobs: below_threshold="$(jq -r ' .data[0].files[] | select(.filename | contains("/src/")) + # Vendored submodules carry code from another repository, whose + # coverage is not this one to enforce. They only entered the report + # when the module crate made tinybus part of the build graph. + # (No apostrophes here: the whole jq program is single-quoted.) + | select(.filename | contains("/vendor/") | not) | select(.summary.lines.percent < 90) | "\(.filename): \(.summary.lines.percent)%" ' "$report")" @@ -74,6 +79,31 @@ jobs: exit 1 fi + + module-e2e: + name: TinyBus module E2E + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + submodules: true + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Build the loadable module + run: cargo build --locked --release --package tinywallet-module + + # The only test that exercises the real cdylib, the ABI and manifest + # gates, the dynamic loader and a broker routing frames. It asserts that + # signing through the module equals signing in-process, byte for byte. + - name: Load the module and sign on every chain + env: + TINYWALLET_TEST_MODULE: ${{ github.workspace }}/target/release/libtinywallet_module.so + run: cargo test --locked --release --package tinywallet-module --test module_e2e -- --ignored + docs: name: Docs runs-on: ubuntu-latest @@ -119,8 +149,13 @@ jobs: - uses: Swatinem/rust-cache@v2 + # Scoped to the published library on purpose. `tinywallet-module` is + # `publish = false` and depends on tinybus, whose macros use let-chains + # and so need a newer compiler than this crate promises its consumers. + # Holding the module to the library's MSRV would either fail here or + # force the library's MSRV up for a crate nobody depends on. - name: Build with the declared MSRV - run: cargo build --all-targets --all-features + run: cargo build --package tinywallet --all-targets --all-features supply-chain: name: Supply chain diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c60ed4..b5eaf0a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,10 @@ jobs: if: ${{ github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest environment: Production + outputs: + crate_name: ${{ steps.version.outputs.crate_name }} + next_version: ${{ steps.version.outputs.next_version }} + tag: ${{ steps.version.outputs.tag }} steps: - uses: actions/checkout@v7 with: @@ -107,8 +111,19 @@ jobs: NEXT_VERSION: ${{ steps.version.outputs.next_version }} run: | set -euo pipefail - perl -0pi -e 's/(\[package\][\s\S]*?\nversion = ")[^"]+(")/$1$ENV{NEXT_VERSION}$2/' Cargo.toml - cargo update -p "$CRATE_NAME" --precise "$NEXT_VERSION" + bump() { + perl -0pi -e 's/(\[package\][\s\S]*?\nversion = ")[^"]+(")/$1$ENV{NEXT_VERSION}$2/' "$1" + } + bump Cargo.toml + # The module crate is bumped in lockstep, not left behind. Its + # `cdylib` ships in an archive named for the *root* crate's version, + # so a drift here would publish `tinywallet-module-0.2.0-*.tar.gz` + # containing a library that reports 0.1.0 — with nothing to catch it. + bump crates/tinywallet-module/Cargo.toml + # Refreshes both workspace members in the lockfile. `cargo update -p` + # cannot do this: these are path dependencies, and the later + # `--locked` steps fail against a stale lock. + cargo update --workspace - name: Commit version bump and tag env: @@ -117,7 +132,7 @@ jobs: set -euo pipefail git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Cargo.toml Cargo.lock + git add Cargo.toml Cargo.lock crates/tinywallet-module/Cargo.toml git commit -m "Release ${RELEASE_TAG}" git tag -a "${RELEASE_TAG}" -m "Release ${RELEASE_TAG}" @@ -136,3 +151,205 @@ jobs: run: cargo publish --locked env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + native-bundles: + name: Module bundle (${{ matrix.id }}) + needs: publish + strategy: + fail-fast: false + matrix: + include: + - id: ubuntu-22.04-x86_64 + os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + - id: ubuntu-22.04-arm64 + os: ubuntu-22.04-arm + target: aarch64-unknown-linux-gnu + - id: ubuntu-24.04-x86_64 + os: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + - id: ubuntu-24.04-arm64 + os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + - id: macos-15-x86_64 + os: macos-15-intel + target: x86_64-apple-darwin + - id: macos-15-arm64 + os: macos-15 + target: aarch64-apple-darwin + - id: macos-26-x86_64 + os: macos-26-intel + target: x86_64-apple-darwin + - id: macos-26-arm64 + os: macos-26 + target: aarch64-apple-darwin + - id: windows-2022-x86_64 + os: windows-2022 + target: x86_64-pc-windows-msvc + - id: windows-2025-x86_64 + os: windows-2025 + target: x86_64-pc-windows-msvc + - id: windows-11-arm64 + os: windows-11-arm + target: aarch64-pc-windows-msvc + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.publish.outputs.tag }} + persist-credentials: false + submodules: true + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Verify native Rust target + shell: bash + env: + EXPECTED_TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + actual_target="$(rustc -vV | sed -n 's/^host: //p')" + [[ "$actual_target" == "$EXPECTED_TARGET" ]] + + - name: Build installable module + run: cargo build --locked --release --package tinywallet-module + + - name: Assemble Unix module package + if: ${{ runner.os != 'Windows' }} + id: unix_package + shell: bash + env: + BUNDLE_ID: ${{ matrix.id }} + VERSION: ${{ needs.publish.outputs.next_version }} + run: | + set -euo pipefail + + library_name="tinywallet_module" + case "$RUNNER_OS" in + Linux) module="target/release/lib${library_name}.so" ;; + macOS) module="target/release/lib${library_name}.dylib" ;; + *) echo "unsupported Unix runner: ${RUNNER_OS}" >&2; exit 1 ;; + esac + package_name="tinywallet-module-${VERSION}-${BUNDLE_ID}" + package_root="dist/${package_name}" + mkdir -p "$package_root" + install -m 755 "$module" "$package_root/" + install -m 644 LICENSE README.md docs/specs/tinybus-module.md "$package_root/" + module_name="$(basename "$module")" + module_hash="$(sha256sum "$package_root/$module_name" | awk '{print $1}')" + printf '"%s" = "%s"\n' "$module_name" "$module_hash" \ + > "$package_root/modules.toml" + tar -C "$package_root" -czf "dist/${package_name}.tar.gz" . + echo "archive=dist/${package_name}.tar.gz" >> "$GITHUB_OUTPUT" + + - name: Assemble Windows module package + if: ${{ runner.os == 'Windows' }} + id: windows_package + shell: pwsh + env: + BUNDLE_ID: ${{ matrix.id }} + VERSION: ${{ needs.publish.outputs.next_version }} + run: | + $ErrorActionPreference = 'Stop' + $libraryName = 'tinywallet_module' + $module = "target/release/$libraryName.dll" + $packageName = "tinywallet-module-$env:VERSION-$env:BUNDLE_ID" + $packageRoot = "dist/$packageName" + New-Item -ItemType Directory -Force $packageRoot | Out-Null + Copy-Item -LiteralPath $module, 'LICENSE', 'README.md' -Destination $packageRoot + $hash = (Get-FileHash -LiteralPath $module -Algorithm SHA256).Hash.ToLowerInvariant() + $moduleName = Split-Path -Leaf $module + "`"$moduleName`" = `"$hash`"`n" | + Set-Content -Path "$packageRoot/modules.toml" -Encoding utf8NoBOM + Compress-Archive -Path "$packageRoot/*" -DestinationPath "dist/$packageName.zip" + "archive=dist/$packageName.zip" >> $env:GITHUB_OUTPUT + + - name: Upload Unix module package + if: ${{ runner.os != 'Windows' }} + uses: actions/upload-artifact@v7 + with: + name: tinywallet-module-${{ matrix.id }} + path: ${{ steps.unix_package.outputs.archive }} + if-no-files-found: error + + - name: Upload Windows module package + if: ${{ runner.os == 'Windows' }} + uses: actions/upload-artifact@v7 + with: + name: tinywallet-module-${{ matrix.id }} + path: ${{ steps.windows_package.outputs.archive }} + if-no-files-found: error + + github-release: + name: Create GitHub release + needs: + - publish + - native-bundles + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.publish.outputs.tag }} + persist-credentials: false + submodules: true + + - name: Download workflow artifacts + uses: actions/download-artifact@v8 + with: + pattern: tinywallet-module-* + path: release-assets + merge-multiple: true + + - uses: dtolnay/rust-toolchain@stable + + - name: Create release checksum manifest with TinyBus + shell: bash + run: | + set -euo pipefail + mapfile -t assets < <( + find release-assets -type f \ + \( -name '*.tar.gz' -o -name '*.zip' \) \ + | sort + ) + if [[ ${#assets[@]} -ne 11 ]]; then + printf 'expected 11 module archives, found %s:\n' "${#assets[@]}" >&2 + find release-assets -type f -print >&2 || true + exit 1 + fi + checksum_args=() + for asset in "${assets[@]}"; do checksum_args+=(--path "$asset"); done + cargo run --manifest-path vendor/tinybus/Cargo.toml --locked \ + --package tinybus --all-features --bin tinybus -- \ + modules checksum "${checksum_args[@]}" --output release-assets/checksum.toml + + - name: Create release and upload assets + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.publish.outputs.tag }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + mapfile -t release_files < <(find release-assets -type f | sort) + gh release create "$RELEASE_TAG" "${release_files[@]}" \ + --repo "$REPOSITORY" \ + --verify-tag \ + --title "$RELEASE_TAG" \ + --generate-notes + + - name: Verify the published module through TinyBus + shell: bash + env: + RELEASE_TAG: ${{ needs.publish.outputs.tag }} + REPOSITORY: ${{ github.repository }} + VERSION: ${{ needs.publish.outputs.next_version }} + run: | + set -euo pipefail + archive="tinywallet-module-${VERSION}-ubuntu-24.04-x86_64.tar.gz" + release_url="https://github.com/${REPOSITORY}/releases/tag/${RELEASE_TAG}" + sha256="$(sed -n "s/^\"${archive}\" = \"\([0-9a-f]\{64\}\)\"$/\1/p" release-assets/checksum.toml)" + test -n "$sha256" + cargo run --manifest-path vendor/tinybus/Cargo.toml --locked \ + --package tinybus --all-features --example github_module_host -- \ + "$release_url" "$archive" "$sha256" diff --git a/Cargo.lock b/Cargo.lock index a77b057..85586ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arrayvec" version = "0.7.8" @@ -40,6 +55,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -119,6 +140,12 @@ dependencies = [ "hex-conservative 0.2.2", ] +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + [[package]] name = "bitvec" version = "1.1.1" @@ -150,6 +177,18 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cc" version = "1.4.2" @@ -204,7 +243,7 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5286a0843c21f8367f7be734f89df9b822e0321d8bcce8d6e735aadff7d74979" dependencies = [ - "base64", + "base64 0.21.7", "bech32 0.9.1", "bs58", "digest", @@ -233,6 +272,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -292,6 +346,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -304,6 +369,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "ecdsa" version = "0.16.9" @@ -361,6 +437,28 @@ dependencies = [ "zeroize", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "ff" version = "0.13.1" @@ -377,12 +475,32 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "funty" version = "2.0.0" @@ -411,6 +529,17 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "group" version = "0.13.0" @@ -422,6 +551,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hex" version = "0.4.3" @@ -461,6 +596,32 @@ dependencies = [ "digest", ] +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "itoa" version = "1.0.18" @@ -496,12 +657,34 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -518,6 +701,12 @@ dependencies = [ "hmac", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -561,6 +750,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -594,7 +789,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] [[package]] @@ -607,6 +802,20 @@ dependencies = [ "subtle", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "ripemd" version = "0.1.3" @@ -625,6 +834,54 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "sec1" version = "0.7.3" @@ -707,6 +964,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "sha2" version = "0.10.9" @@ -744,6 +1010,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "spki" version = "0.7.3" @@ -788,6 +1060,30 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -828,6 +1124,46 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "tinybus" +version = "0.1.0" +dependencies = [ + "async-trait", + "flate2", + "serde", + "serde_json", + "tar", + "tempfile", + "thiserror 2.0.20", + "tinybus-macros", + "tokio", + "toml", + "tracing", + "ureq", + "zip", +] + +[[package]] +name = "tinybus-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tinybus-module" +version = "0.1.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "tinybus", + "tokio", + "tracing", +] + [[package]] name = "tinyvec" version = "1.12.0" @@ -848,12 +1184,15 @@ name = "tinywallet" version = "0.1.0" dependencies = [ "async-trait", + "bech32 0.11.1", "bitcoin", "bs58", + "coins-bip32", "coins-bip39", "ed25519-dalek", "hex", "hmac", + "ripemd", "serde", "serde_json", "sha2", @@ -863,12 +1202,28 @@ dependencies = [ "zeroize", ] +[[package]] +name = "tinywallet-module" +version = "0.1.0" +dependencies = [ + "bitcoin", + "bs58", + "ed25519-dalek", + "serde", + "serde_json", + "tinybus", + "tinybus-module", + "tinywallet", + "tokio", +] + [[package]] name = "tokio" version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ + "bytes", "pin-project-lite", "tokio-macros", ] @@ -884,6 +1239,78 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "typenum" version = "1.20.1" @@ -896,6 +1323,47 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "version_check" version = "0.9.5" @@ -908,6 +1376,112 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wyz" version = "0.5.1" @@ -917,6 +1491,16 @@ dependencies = [ "tap", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "zerocopy" version = "0.8.56" @@ -943,8 +1527,37 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.20", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index 86a7588..9a2eb15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,14 @@ exclude = [ "deny.toml", ] +[workspace] +members = ["crates/tinywallet-module"] +default-members = [".", "crates/tinywallet-module"] +# The vendored bus is a submodule with its own workspace; it must not be pulled +# into this one. +exclude = ["vendor/tinybus"] +resolver = "3" + [dependencies] # Derive macros for the crate-wide error type in `src/error/mod.rs`. Every # dependency entry should carry a comment like this one saying why it is here. @@ -30,10 +38,25 @@ thiserror = "2" bs58 = { version = "0.5", features = ["check"], optional = true } # Hex codec for the Tron hex-address form the TronGrid API speaks. hex = { version = "0.4", optional = true } -# Full Bitcoin address parsing — base58check, bech32, and the script-type -# introspection that distinguishes a P2WPKH sender from any other address -# type. Not worth hand-rolling; `no-std` off, `std` on. +# PSBT construction and signing for Bitcoin (`src/tx/btc.rs`) — the one place +# a full Bitcoin implementation is still worth its weight. Deliberately NOT +# used for address parsing or key derivation any more: it carries `secp256k1` +# and therefore a native C build, which every consumer paid for even when all +# it wanted was to check an address. See `src/address/btc.rs` for why parsing +# is now owned directly and derivation still is not. bitcoin = { version = "0.32", default-features = false, features = ["std", "secp-recovery"], optional = true } +# bech32/bech32m segwit address encoding and decoding, checksum variant +# selection and witness-program length rules included. Replaces the parsing +# half of what `bitcoin` used to do here. +bech32 = { version = "0.11", optional = true } +# RIPEMD-160, the second half of Bitcoin's HASH160. Only needed to turn a +# public key into a P2WPKH witness program. +ripemd = { version = "0.1", optional = true } +# BIP-32 on secp256k1, backed by the pure-Rust `k256` rather than the +# `secp256k1` C library. Deriving a key wrong is silent and unrecoverable, so +# this stays delegated to a vetted implementation — see `src/key/bip32.rs`. +# Already in the graph beneath `coins-bip39`, so it costs nothing. +coins-bip32 = { version = "0.8", optional = true } # Keccak-256, the hash EIP-55 checksums are defined over. Only needed for the # EVM checksum helpers, so it rides its own gate rather than `evm`'s — the # common case (validate an address) stays dependency-free. @@ -80,9 +103,17 @@ name = "public_api" required-features = ["btc", "evm", "solana", "tron", "keccak"] [features] -default = ["btc", "evm", "solana", "tron", "keccak", "net", "key", "asset", "client", "tx", "x402"] -# Bitcoin address parsing and validation. -btc = ["dep:bitcoin"] +default = ["btc", "evm", "solana", "tron", "keccak", "net", "key", "asset", "client", "tx", "x402", "wire", "eip712", "abi"] +# Bitcoin address parsing and validation. Dependency-light on purpose: base58 +# via `bs58` (already here for Solana and Tron) and segwit via `bech32`. The +# `bitcoin` crate is deliberately NOT pulled in by this gate — only by `tx`, +# which needs a full PSBT implementation. That split is what lets a host +# validate addresses and derive keys without a native secp256k1 C build. +btc = ["dep:bs58", "dep:bech32", "dep:ripemd", "dep:sha2"] +# Serde derives on the crate's core types (today `Chain`). A named gate rather +# than relying on `dep:serde` being implied, because `#[cfg(feature = "serde")]` +# on the enum has to name something that actually exists. +serde = ["dep:serde"] # EVM (Ethereum and compatible chains) address validation. Dependency-free: # an EVM address is 20 hex-encoded bytes, so the whole format is spelled out # in `src/address/evm.rs` rather than pulling in a chain client for it. @@ -104,16 +135,48 @@ asset = ["btc", "evm", "solana", "tron"] # Chain queries over the `Transport` seam (`tinywallet::client`). Needs the # seam plus the reference data that names the networks. client = ["net", "asset", "tx", "serde/derive"] -# Transaction building and signing (`tinywallet::tx`). Needs secp256k1 -# recoverable signing (via `bitcoin`) and Keccak-256 (via `keccak`). -x402 = ["dep:serde", "dep:serde_json", "serde/derive"] -tx = ["btc", "evm", "keccak", "solana", "tron", "key", "dep:ed25519-dalek", "dep:bs58", "dep:sha2", "dep:hex"] +# EIP-712 typed-data hashing (`tinywallet::eip712`) and the EIP-3009 +# authorization x402 signs. Pure keccak over a fixed byte layout — no chain +# client, no bignum, no signer — so the payment path costs `sha3` and nothing +# else. This is the gate that lets a host drop `ethers-core` entirely. +eip712 = ["dep:sha3"] +# ERC-20 `transfer` calldata (`tinywallet::abi`). Outside `tx` because calldata +# is an input to building a transaction, so a host that builds elsewhere still +# needs it — and should not pay a bus round trip for keccak over 68 bytes. +abi = ["evm", "keccak", "eip712"] +x402 = ["dep:serde", "dep:serde_json", "serde/derive", "eip712"] +# The host/backend wire contract (`tinywallet::wire`). Deliberately outside +# every chain gate and dependency-free beyond serde, so a host that has moved +# transaction building into a loadable module can take this crate with +# `default-features = false`, share one definition of the contract, and link no +# chain library at all. Same carve-out `tinydocs::spec` makes. +wire = ["serde", "serde/derive"] +# Transaction building and signing (`tinywallet::tx`). This is the only gate +# that pulls the `bitcoin` crate, for PSBT construction and secp256k1 +# recoverable signing. Everything else — addresses, derivation, reference data +# — is deliberately reachable without it, so a host that has moved signing into +# a loadable module sheds `bitcoin` and its native build entirely. +tx = [ + "btc", + "evm", + "keccak", + "solana", + "tron", + "key", + "dep:bitcoin", + "dep:ed25519-dalek", + "dep:bs58", + "dep:sha2", + "dep:hex", +] # Deterministic key derivation from a BIP-39 mnemonic (`tinywallet::key`). # Needs every chain gate it derives for; `keccak` covers the EVM/Tron address -# hash and `btc` brings the secp256k1 BIP-32 walk the three curve-sharing -# chains use. +# hash and `coins-bip32` the secp256k1 BIP-32 walk the three curve-sharing +# chains share. `btc` is here for the P2WPKH address encoding, which is now +# bech32 rather than the `bitcoin` crate. key = [ "dep:coins-bip39", + "dep:coins-bip32", "dep:hmac", "dep:sha2", "dep:ed25519-dalek", @@ -122,16 +185,20 @@ key = [ "keccak", ] -# Lints apply to the whole crate and to every target. CI runs clippy with -# `-D warnings`, so anything set to "warn" here fails the build in CI. -[lints.rust] +# Lints are defined at the workspace level and inherited by every member, so +# the module crate is held to exactly the same bar as the library. CI runs +# clippy with `-D warnings`, so anything set to "warn" here fails the build. +[lints] +workspace = true + +[workspace.lints.rust] unsafe_code = "forbid" missing_docs = "warn" missing_debug_implementations = "warn" unreachable_pub = "warn" rust_2018_idioms = { level = "warn", priority = -1 } -[lints.clippy] +[workspace.lints.clippy] all = { level = "warn", priority = -1 } pedantic = { level = "warn", priority = -1 } # Library code must not panic on its own; tests and examples may. @@ -148,7 +215,7 @@ doc_markdown = "warn" # `#[must_use]` on pure public functions. must_use_candidate = "warn" -[lints.rustdoc] +[workspace.lints.rustdoc] broken_intra_doc_links = "warn" private_intra_doc_links = "warn" diff --git a/crates/tinywallet-module/Cargo.toml b/crates/tinywallet-module/Cargo.toml new file mode 100644 index 0000000..807db35 --- /dev/null +++ b/crates/tinywallet-module/Cargo.toml @@ -0,0 +1,60 @@ +[package] +name = "tinywallet-module" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +license = "GPL-3.0-only" +description = "Trusted TinyBus module adapter for TinyWallet." +repository = "https://github.com/tinyhumansai/tinywallet" +publish = false + +[lib] +crate-type = ["rlib", "cdylib"] + +[dependencies] +# The pure wallet library stays independently publishable and bus-agnostic. +# Every chain feature is on here: carrying them is the entire point of the +# module, since it is what lets the host drop them. +tinywallet = { path = "../..", default-features = false, features = [ + "btc", + "evm", + "solana", + "tron", + "keccak", + "key", + "tx", + "wire", + "eip712", +] } +# TinyBus provides the typed service interface and the dynamic module host ABI. +tinybus = { version = "0.1.0", path = "../../vendor/tinybus/crates/tinybus", default-features = false, features = [ + "macros", + "modules", +] } +# The module-side SDK owns its runtime and exports the stable C entrypoints. +tinybus-module = { version = "0.1.0", path = "../../vendor/tinybus/crates/tinybus-module" } +# The interface macro requires every method to be `async fn`, so a runtime has +# to exist even though nothing here awaits I/O. +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +# Method arguments and returns are the `tinywallet::wire` contract, decoded from +# and encoded to JSON frames. +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Base58 for a Solana transaction id, which is its 64-byte signature. The +# library's own encoder takes a 32-byte address, so it does not fit here. +bs58 = "0.5" + +[lints] +workspace = true + +[dev-dependencies] +# The loader E2E builds request documents directly rather than through the wire +# types, so a rename on either side shows up as the contract change it is. +serde_json = "1" +# The service tests sign the way a host does — with the recoverable secp256k1 +# API directly — so they exercise the real boundary rather than a helper. +bitcoin = { version = "0.32", default-features = false, features = ["std", "secp-recovery"] } +# The E2E signs a Solana transfer the way a host would, with its own ed25519 key. +ed25519-dalek = { version = "2", default-features = false, features = ["std"] } +# The E2E drives a real broker and a real loader. +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } diff --git a/crates/tinywallet-module/src/lib.rs b/crates/tinywallet-module/src/lib.rs new file mode 100644 index 0000000..43b9e0f --- /dev/null +++ b/crates/tinywallet-module/src/lib.rs @@ -0,0 +1,18 @@ +//! Loadable `TinyBus` module adapter for `TinyWallet`. +//! +//! This private workspace crate keeps the vendored `TinyBus` dependency out of +//! the independently published `tinywallet` crate. Its `cdylib` output is the +//! target-specific binary distributed in GitHub releases. +//! +//! What it carries is the point: `bitcoin` and its native `secp256k1` build, +//! plus every chain's transaction encoder. A host that loads this module runs +//! the wallet without linking any of them. +//! +//! It does **not** carry a key, and no method it exports accepts one. The +//! two-call split that makes that possible is documented in +//! `docs/specs/tinybus-module.md`; the interface itself lives in the private +//! `service` module, whose docs are visible with `cargo doc --document-private-items`. + +mod service; + +pub use service::{BUS_NAME, OBJECT_PATH}; diff --git a/crates/tinywallet-module/src/service/mod.rs b/crates/tinywallet-module/src/service/mod.rs new file mode 100644 index 0000000..ba47068 --- /dev/null +++ b/crates/tinywallet-module/src/service/mod.rs @@ -0,0 +1,539 @@ +//! `TinyBus` service boundary for the wallet surface. +//! +//! One object, `/ai/tinyhumans/tinywallet/Wallet`, exporting two methods: +//! +//! ```text +//! BuildUnsigned(SigningRequest) -> UnsignedTransaction +//! AttachSignature(AttachRequest) -> SignedTransaction +//! ``` +//! +//! # Two methods, and no state between them +//! +//! The host holds the key. It asks what needs signing, signs it locally, and +//! hands back only a signature — so no method here takes key material, and +//! there is nothing in this module a leak could disclose. +//! +//! `AttachSignature` re-sends the transaction fields rather than a handle to +//! something remembered from `BuildUnsigned`. A module that held half-built +//! transactions between calls would need a store, a bound on it, and an expiry +//! for callers that never return — the whole apparatus `tinydocs` needs for +//! produced documents. Rebuilding avoids all of it, and is safe because +//! building is deterministic: the same fields yield the transaction the digests +//! were computed over. +//! +//! # Everything travels inline +//! +//! This is the significant difference from the `tinydocs` module, and it is +//! what makes this one small. A `TinyBus` frame is JSON capped at 16 MiB, where +//! a byte array costs about 3.5 bytes per byte — a real constraint for a +//! generated `.docx`, and irrelevant here. The largest thing that crosses is a +//! Bitcoin spend's UTXO list; a wallet with a thousand of them is still tens of +//! kilobytes. So there are no streams, no chunking, and no held outputs. +//! +//! # Errors are named, and the names are the contract +//! +//! A host maps them onto what a user or a model can act on: a rejected input it +//! can fix, against a failure it cannot. Anything unrecognised must be treated +//! as the second — telling a model its input was wrong when it was not sends it +//! into a rewrite loop over something that was already correct. + +use tinybus::{Connection, Error as BusError, Result as BusResult}; +use tinywallet::wire::{ + AttachRequest, Scheme, Signature, SignedTransaction, SigningPayload, SigningRequest, + TransactionSpec, UnsignedTransaction, +}; +use tinywallet::{Chain, tx}; + +/// Well-known name and interface exported by the `TinyWallet` module. +pub const BUS_NAME: &str = "ai.tinyhumans.tinywallet.Wallet"; + +/// Object path exported by the `TinyWallet` module. +pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinywallet/Wallet"; + +/// The request was malformed or internally inconsistent. A caller can fix it. +const INVALID_INPUT_ERROR: &str = "ai.tinyhumans.tinywallet.Error.InvalidInput"; +/// Building or assembling the transaction failed. A caller cannot fix it. +const BUILD_FAILED_ERROR: &str = "ai.tinyhumans.tinywallet.Error.BuildFailed"; +/// The chain named is not compiled into this module. +const UNSUPPORTED_CHAIN_ERROR: &str = "ai.tinyhumans.tinywallet.Error.UnsupportedChain"; + +/// The served object. Holds nothing: every call is self-contained. +struct Wallet; + +// The interface macro rejects a non-async method, so both methods are async +// because the dispatch contract says so, not because they await anything. This +// module performs no I/O at all. +#[allow( + clippy::unused_async, + reason = "tinybus::interface requires every method to be `async fn`" +)] +#[tinybus::interface(name = "ai.tinyhumans.tinywallet.Wallet")] +impl Wallet { + /// Report the bytes a caller must sign for `request`. + async fn build_unsigned(&self, request: SigningRequest) -> BusResult { + build_unsigned(&request).map_err(into_bus_error) + } + + /// Assemble the broadcast-ready transaction from the caller's signatures. + async fn attach_signature(&self, request: AttachRequest) -> BusResult { + attach_signature(&request).map_err(into_bus_error) + } +} + +/// Why a call failed, before it becomes a wire error name. +#[derive(Debug)] +enum Failure { + /// The caller's request was wrong. + InvalidInput(String), + /// Building or assembling failed for a reason the caller did not cause. + BuildFailed(String), + /// The chain is not in this build. + UnsupportedChain(Chain), +} + +/// Map a failure onto the wire name a host matches on. +fn into_bus_error(failure: Failure) -> BusError { + let (name, message) = match failure { + Failure::InvalidInput(message) => (INVALID_INPUT_ERROR, message), + Failure::BuildFailed(message) => (BUILD_FAILED_ERROR, message), + Failure::UnsupportedChain(chain) => ( + UNSUPPORTED_CHAIN_ERROR, + format!("this build has no support for {chain}"), + ), + }; + BusError::MethodFailed { + name: name.to_string(), + message, + } +} + +/// Compute the signing payloads for `request`. +fn build_unsigned(request: &SigningRequest) -> Result { + let payloads = match (&request.transaction, request.chain) { + ( + TransactionSpec::Btc { + from, + to, + amount_sat, + fee_rate_sat_vb, + utxos, + }, + Chain::Btc, + ) => { + let transfer = btc_transfer(from, to, *amount_sat, *fee_rate_sat_vb); + let public = compressed_public_key(&request.public_key.key_hex)?; + let (_, digests) = transfer + .sighashes(&btc_utxos(utxos), &public) + .map_err(|e| build_failed(&e))?; + digests.into_iter().map(secp256k1_payload).collect() + } + (spec @ TransactionSpec::Evm { .. }, Chain::Evm) => { + vec![secp256k1_payload( + evm_transaction(spec)? + .digest() + .map_err(|e| build_failed(&e))?, + )] + } + (spec @ TransactionSpec::Solana { .. }, Chain::Solana) => { + // ed25519 signs the message itself — there is nothing to pre-hash, + // so this payload is the whole serialized message, not a digest. + vec![SigningPayload { + bytes_hex: hex(&solana_transfer(spec)? + .message() + .map_err(|e| build_failed(&e))?), + scheme: Scheme::Ed25519, + }] + } + ( + TransactionSpec::Tron { + raw_data_hex, + expected_to, + expected_txid, + }, + Chain::Tron, + ) => { + // Tron's node builds the transaction, so the only defence against a + // compromised endpoint is checking that what came back is what was + // asked for — before signing it, which is here. + tx::tron::verify_transfer(raw_data_hex, expected_to, expected_txid) + .map_err(|e| Failure::InvalidInput(e.to_string()))?; + vec![secp256k1_payload( + tx::tron::digest(raw_data_hex).map_err(|e| build_failed(&e))?, + )] + } + (spec, chain) => return Err(mismatched(spec, chain)), + }; + Ok(UnsignedTransaction { payloads }) +} + +/// Assemble the signed transaction for `request`. +fn attach_signature(request: &AttachRequest) -> Result { + match (&request.transaction, request.chain) { + ( + TransactionSpec::Btc { + from, + to, + amount_sat, + fee_rate_sat_vb, + utxos, + }, + Chain::Btc, + ) => { + let transfer = btc_transfer(from, to, *amount_sat, *fee_rate_sat_vb); + let public = compressed_public_key(&request.public_key.key_hex)?; + let signatures = request + .signatures + .iter() + .map(secp256k1_rs) + .collect::, _>>()?; + let raw = transfer + .attach_signatures(&btc_utxos(utxos), &public, &signatures) + .map_err(|e| build_failed(&e))?; + Ok(SignedTransaction { + // A Bitcoin txid is the hash of the serialized transaction, but + // reporting it would mean hashing here and in the host; the + // node returns it on broadcast, so it is left unset rather than + // computed twice. + txid: None, + raw, + }) + } + (spec @ TransactionSpec::Evm { .. }, Chain::Evm) => { + let (rs, recovery) = single_secp256k1(&request.signatures)?; + let signed = evm_transaction(spec)? + .attach_signature(&rs, recovery) + .map_err(|e| build_failed(&e))?; + Ok(SignedTransaction { + txid: Some(tx::evm::LegacyTransaction::hash_of(&signed)), + raw: format!("0x{}", hex(&signed)), + }) + } + (spec @ TransactionSpec::Solana { .. }, Chain::Solana) => { + let signature = single_ed25519(&request.signatures)?; + let signed = solana_transfer(spec)? + .attach_signature(&signature) + .map_err(|e| build_failed(&e))?; + Ok(SignedTransaction { + // Solana's signature *is* its id, base58-encoded. Encoded + // here rather than through `address::solana::encode`, which + // takes a 32-byte address — a signature is 64. + txid: Some(bs58::encode(signature).into_string()), + raw: base64(&signed), + }) + } + ( + TransactionSpec::Tron { + raw_data_hex, + expected_to, + expected_txid, + }, + Chain::Tron, + ) => { + // Verified again rather than trusted from the first call: the two + // requests are independent, and a host could reach this one with + // different bytes than the digest was computed over. + tx::tron::verify_transfer(raw_data_hex, expected_to, expected_txid) + .map_err(|e| Failure::InvalidInput(e.to_string()))?; + let (rs, recovery) = single_secp256k1(&request.signatures)?; + let signature = + tx::tron::attach_signature(&rs, recovery).map_err(|e| build_failed(&e))?; + Ok(SignedTransaction { + txid: Some(expected_txid.clone()), + raw: tx::tron::signature_hex(&signature), + }) + } + (spec, chain) => Err(mismatched(spec, chain)), + } +} + +/// A `chain` tag that does not agree with the transaction it carries. +fn mismatched(spec: &TransactionSpec, chain: Chain) -> Failure { + let named = match spec { + TransactionSpec::Btc { .. } => Chain::Btc, + TransactionSpec::Evm { .. } => Chain::Evm, + TransactionSpec::Solana { .. } => Chain::Solana, + TransactionSpec::Tron { .. } => Chain::Tron, + // `TransactionSpec` is `#[non_exhaustive]`, so a variant added later + // must land here rather than failing to compile in a crate that cannot + // see it. Refusing is the safe direction: never sign an unknown shape. + _ => { + return Failure::InvalidInput( + "this build does not understand that transaction kind".to_string(), + ); + } + }; + if named == chain { + // Same chain on both sides, so the pairing failed for the only other + // reason: this build does not carry it. + return Failure::UnsupportedChain(chain); + } + Failure::InvalidInput(format!( + "the request names {chain} but carries a {named} transaction" + )) +} + +/// Collapse a `tinywallet` build error, which is never the caller's fault by +/// the time it is reached — inputs are checked before building. +fn build_failed(error: &tx::Error) -> Failure { + Failure::BuildFailed(error.to_string()) +} + +/// A secp256k1 payload over an already-computed digest. +fn secp256k1_payload(digest: [u8; 32]) -> SigningPayload { + SigningPayload { + bytes_hex: hex(&digest), + scheme: Scheme::Secp256k1Prehash, + } +} + +/// The one secp256k1 signature a single-signature chain expects. +fn single_secp256k1(signatures: &[Signature]) -> Result<([u8; 64], u8), Failure> { + let [only] = signatures else { + return Err(Failure::InvalidInput(format!( + "expected exactly one signature, got {}", + signatures.len() + ))); + }; + secp256k1_rs(only).map(|rs| (rs, recovery_of(only))) +} + +/// The 64-byte `r ‖ s` of a secp256k1 signature. +fn secp256k1_rs(signature: &Signature) -> Result<[u8; 64], Failure> { + match signature { + Signature::Secp256k1 { rs_hex, .. } => fixed_hex::<64>(rs_hex, "signature"), + Signature::Ed25519 { .. } => Err(Failure::InvalidInput( + "expected a secp256k1 signature, got an ed25519 one".to_string(), + )), + // `Signature` is `#[non_exhaustive]`: a scheme this build has never + // heard of cannot be reassembled, and guessing would produce a + // well-formed transaction carrying nonsense. + _ => Err(Failure::InvalidInput( + "unrecognised signature scheme".to_string(), + )), + } +} + +/// The recovery id of a secp256k1 signature, or zero for the wrong variant. +/// +/// Only ever called after [`secp256k1_rs`] has confirmed the variant, so the +/// fallback is unreachable; it exists so this cannot panic inside a wallet. +fn recovery_of(signature: &Signature) -> u8 { + match signature { + Signature::Secp256k1 { recovery_id, .. } => *recovery_id, + Signature::Ed25519 { .. } | _ => 0, + } +} + +/// The one ed25519 signature Solana expects. +fn single_ed25519(signatures: &[Signature]) -> Result<[u8; 64], Failure> { + let [Signature::Ed25519 { signature_hex }] = signatures else { + return Err(Failure::InvalidInput( + "expected exactly one ed25519 signature".to_string(), + )); + }; + fixed_hex::<64>(signature_hex, "signature") +} + +/// A compressed SEC1 public key from its hex. +fn compressed_public_key(key_hex: &str) -> Result<[u8; 33], Failure> { + fixed_hex::<33>(key_hex, "public key") +} + +/// Decode hex into exactly `N` bytes. +fn fixed_hex(value: &str, what: &str) -> Result<[u8; N], Failure> { + let body = value.strip_prefix("0x").unwrap_or(value); + if body.len() != N * 2 { + return Err(Failure::InvalidInput(format!( + "{what} must be {N} bytes, got {} hex characters", + body.len() + ))); + } + let mut out = [0u8; N]; + for (index, slot) in out.iter_mut().enumerate() { + let pair = body + .get(index * 2..index * 2 + 2) + .ok_or_else(|| Failure::InvalidInput(format!("{what} is truncated")))?; + *slot = u8::from_str_radix(pair, 16) + .map_err(|_| Failure::InvalidInput(format!("{what} is not hex")))?; + } + Ok(out) +} + +/// Rebuild the Bitcoin transfer from its wire fields. +fn btc_transfer(from: &str, to: &str, amount_sat: u64, fee_sat: u64) -> tx::btc::Transfer { + tx::btc::Transfer { + from: from.to_string(), + to: to.to_string(), + amount: amount_sat, + fee: fee_sat, + } +} + +/// Rebuild the UTXO set from its wire form. +fn btc_utxos(utxos: &[tinywallet::wire::Utxo]) -> Vec { + utxos + .iter() + .map(|utxo| tx::btc::Utxo { + txid: utxo.txid.clone(), + vout: utxo.vout, + value: utxo.value, + }) + .collect() +} + +/// Rebuild the EVM transaction from its wire fields. +fn evm_transaction(spec: &TransactionSpec) -> Result { + let TransactionSpec::Evm { + to, + value_wei, + data_hex, + nonce, + gas_limit, + gas_price_wei, + chain_id, + } = spec + else { + return Err(Failure::InvalidInput( + "expected an EVM transaction".to_string(), + )); + }; + + Ok(tx::evm::LegacyTransaction { + nonce: u128::from(*nonce), + gas_price: decimal_u128(gas_price_wei, "gas_price_wei")?, + gas_limit: u128::from(*gas_limit), + // An empty recipient is contract creation, which a wallet transfer + // never is — but the field models it, so an empty string maps to it + // rather than being silently treated as an address. + to: if to.trim().is_empty() { + None + } else { + Some(to.clone()) + }, + value: decimal_u128(value_wei, "value_wei")?, + data: decode_hex(data_hex)?, + chain_id: *chain_id, + }) +} + +/// Rebuild the Solana transfer from its wire fields. +fn solana_transfer(spec: &TransactionSpec) -> Result { + let TransactionSpec::Solana { + from, + to, + lamports, + recent_blockhash, + } = spec + else { + return Err(Failure::InvalidInput( + "expected a Solana transaction".to_string(), + )); + }; + Ok(tx::solana::NativeTransfer { + from: from.clone(), + to: to.clone(), + lamports: *lamports, + recent_blockhash: recent_blockhash.clone(), + }) +} + +/// Parse a base-10 wei amount. +/// +/// `u128` rather than a 256-bit type because that is what +/// `LegacyTransaction` takes: wei amounts and gas prices live far below 2^128, +/// and the RLP encoder is written against it. +fn decimal_u128(value: &str, field: &str) -> Result { + value.trim().parse().map_err(|_| { + Failure::InvalidInput(format!( + "{field} is not a base-10 integer that fits in 128 bits" + )) + }) +} + +/// Decode optionally-`0x`-prefixed hex of any length. +fn decode_hex(value: &str) -> Result, Failure> { + let body = value.strip_prefix("0x").unwrap_or(value).trim(); + if body.is_empty() { + return Ok(Vec::new()); + } + if body.len() % 2 != 0 { + return Err(Failure::InvalidInput( + "call data has an odd number of hex characters".to_string(), + )); + } + (0..body.len()) + .step_by(2) + .map(|index| { + u8::from_str_radix(&body[index..index + 2], 16) + .map_err(|_| Failure::InvalidInput("call data is not hex".to_string())) + }) + .collect() +} + +/// Lowercase hex, unprefixed. +fn hex(bytes: &[u8]) -> String { + bytes.iter().fold(String::new(), |mut out, byte| { + use std::fmt::Write as _; + let _ = write!(out, "{byte:02x}"); + out + }) +} + +/// Standard base64, which is what Solana's `sendTransaction` takes. +/// +/// Hand-rolled rather than pulled in: it is one table and a three-byte loop, +/// and this module has no other use for an encoding crate. +fn base64(bytes: &[u8]) -> String { + const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let b0 = u32::from(chunk[0]); + let b1 = chunk.get(1).copied().map_or(0, u32::from); + let b2 = chunk.get(2).copied().map_or(0, u32::from); + let triple = (b0 << 16) | (b1 << 8) | b2; + + out.push(char::from(TABLE[((triple >> 18) & 0x3f) as usize])); + out.push(char::from(TABLE[((triple >> 12) & 0x3f) as usize])); + out.push(if chunk.len() > 1 { + char::from(TABLE[((triple >> 6) & 0x3f) as usize]) + } else { + '=' + }); + out.push(if chunk.len() > 2 { + char::from(TABLE[(triple & 0x3f) as usize]) + } else { + '=' + }); + } + out +} + +async fn setup(connection: Connection) -> BusResult<()> { + connection.serve_at(OBJECT_PATH.try_into()?, Wallet).await?; + connection.request_name(BUS_NAME).await?; + Ok(()) +} + +// Isolate the generated public C symbols so the lint exception cannot hide +// undocumented Rust API. Their contract is TinyBus ABI v1, and none is a +// Rust-callable export from this private module. +#[allow( + missing_docs, + unreachable_pub, + reason = "generated C ABI symbols are documented by the TinyBus module SDK" +)] +mod exports { + tinybus_module::module_export! { + setup = super::setup, + worker_threads = 2, + provides = ["ai.tinyhumans.tinywallet.Wallet"], + methods = ["BuildUnsigned", "AttachSignature"], + signals = [], + requires = [], + optional = [], + lazy = false, + } +} + +#[cfg(test)] +mod test; diff --git a/crates/tinywallet-module/src/service/test.rs b/crates/tinywallet-module/src/service/test.rs new file mode 100644 index 0000000..c4e6532 --- /dev/null +++ b/crates/tinywallet-module/src/service/test.rs @@ -0,0 +1,285 @@ +//! Tests for the wallet service boundary. +//! +//! These drive `build_unsigned` and `attach_signature` directly rather than +//! over a bus. What they are checking is the translation layer — wire types in, +//! `tinywallet` calls out, wire types back — and a broker in between would only +//! add a runtime to every case. The real loader round trip lives in +//! `tests/module_e2e.rs`. +//! +//! The load-bearing test is [`the_split_path_reproduces_a_one_shot_signature`]: +//! whatever this module returns must equal what the library produces signing in +//! one step, or moving signing out of the host has quietly changed what gets +//! broadcast. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use tinywallet::wire::{ + AttachRequest, PublicKey, Scheme, Signature, SigningRequest, TransactionSpec, Utxo, +}; +use tinywallet::{Chain, tx}; + +use super::{attach_signature, build_unsigned, hex}; + +/// The BIP-39 test vector mnemonic. Never use it for real funds. +const VECTOR: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + +fn evm_key() -> Vec { + tinywallet::key::derive(Chain::Evm, VECTOR, "m/44'/60'/0'/0/0") + .unwrap() + .secret_bytes() + .to_vec() +} + +/// The compressed SEC1 public key for a secret. +fn compressed_public(secret: &[u8]) -> String { + use bitcoin::secp256k1::{PublicKey as SecpPublic, Secp256k1, SecretKey}; + let secret = SecretKey::from_slice(secret).unwrap(); + hex(&SecpPublic::from_secret_key(&Secp256k1::new(), &secret).serialize()) +} + +fn evm_spec() -> TransactionSpec { + TransactionSpec::Evm { + to: "0x3535353535353535353535353535353535353535".to_string(), + value_wei: "1000000000000000000".to_string(), + data_hex: "0x".to_string(), + nonce: 9, + gas_limit: 21_000, + gas_price_wei: "20000000000".to_string(), + chain_id: 1, + } +} + +/// Sign a prehashed digest the way a host would. +fn host_sign(digest_hex: &str, key: &[u8]) -> Signature { + use bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; + let mut digest = [0u8; 32]; + for (index, slot) in digest.iter_mut().enumerate() { + *slot = u8::from_str_radix(&digest_hex[index * 2..index * 2 + 2], 16).unwrap(); + } + let secret = SecretKey::from_slice(key).unwrap(); + let recoverable = + Secp256k1::signing_only().sign_ecdsa_recoverable(&Message::from_digest(digest), &secret); + let (recovery_id, compact) = recoverable.serialize_compact(); + Signature::Secp256k1 { + rs_hex: hex(&compact), + recovery_id: u8::try_from(recovery_id.to_i32()).unwrap(), + } +} + +#[test] +fn the_split_path_reproduces_a_one_shot_signature() { + // The whole justification for the module: signing through it must produce + // exactly what signing in-process produces. + let key = evm_key(); + let spec = evm_spec(); + + let unsigned = build_unsigned(&SigningRequest { + chain: Chain::Evm, + transaction: spec.clone(), + public_key: PublicKey { + key_hex: compressed_public(&key), + }, + }) + .unwrap(); + assert_eq!(unsigned.payloads.len(), 1); + assert_eq!(unsigned.payloads[0].scheme, Scheme::Secp256k1Prehash); + + let signature = host_sign(&unsigned.payloads[0].bytes_hex, &key); + let signed = attach_signature(&AttachRequest { + chain: Chain::Evm, + transaction: spec, + public_key: PublicKey { + key_hex: compressed_public(&key), + }, + signatures: vec![signature], + }) + .unwrap(); + + let expected = tx::evm::LegacyTransaction { + nonce: 9, + gas_price: 20_000_000_000, + gas_limit: 21_000, + to: Some("0x3535353535353535353535353535353535353535".to_string()), + value: 1_000_000_000_000_000_000, + data: Vec::new(), + chain_id: 1, + } + .sign(&key) + .unwrap(); + + assert_eq!(signed.raw, format!("0x{}", hex(&expected))); + assert_eq!( + signed.txid, + Some(tx::evm::LegacyTransaction::hash_of(&expected)) + ); +} + +#[test] +fn a_bitcoin_request_returns_one_payload_per_selected_input() { + // Bitcoin is the only chain needing more than one signature, and the + // count and order are the contract the host signs against. + let key = tinywallet::key::derive(Chain::Btc, VECTOR, "m/84'/0'/0'/0/0").unwrap(); + let spec = TransactionSpec::Btc { + from: key.address().to_string(), + to: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string(), + amount_sat: 150_000, + fee_rate_sat_vb: 2_000, + utxos: vec![ + Utxo { + txid: "7f3b662ea8b6ff2e0e1a1f9bd0f1c39a6b8ba51e1b0f0e0d0c0b0a0908070605" + .to_string(), + vout: 0, + value: 60_000, + }, + Utxo { + txid: "7f3b662ea8b6ff2e0e1a1f9bd0f1c39a6b8ba51e1b0f0e0d0c0b0a0908070605" + .to_string(), + vout: 1, + value: 70_000, + }, + Utxo { + txid: "7f3b662ea8b6ff2e0e1a1f9bd0f1c39a6b8ba51e1b0f0e0d0c0b0a0908070605" + .to_string(), + vout: 2, + value: 80_000, + }, + ], + }; + + let unsigned = build_unsigned(&SigningRequest { + chain: Chain::Btc, + transaction: spec, + public_key: PublicKey { + key_hex: compressed_public(key.secret_bytes()), + }, + }) + .unwrap(); + + assert!( + unsigned.payloads.len() > 1, + "the fixture must select several inputs" + ); + for payload in &unsigned.payloads { + assert_eq!(payload.scheme, Scheme::Secp256k1Prehash); + assert_eq!(payload.bytes_hex.len(), 64, "a sighash is 32 bytes"); + } +} + +#[test] +fn a_solana_payload_is_the_message_not_a_digest() { + // ed25519 hashes internally. A host that pre-hashes produces a signature + // the network rejects, so the scheme tag has to say so. + let key = tinywallet::key::derive(Chain::Solana, VECTOR, "m/44'/501'/0'/0'").unwrap(); + let unsigned = build_unsigned(&SigningRequest { + chain: Chain::Solana, + transaction: TransactionSpec::Solana { + from: key.address().to_string(), + to: "11111111111111111111111111111111".to_string(), + lamports: 1_000_000_000, + recent_blockhash: "11111111111111111111111111111111".to_string(), + }, + public_key: PublicKey { + key_hex: hex(key.secret_bytes()), + }, + }) + .unwrap(); + + assert_eq!(unsigned.payloads[0].scheme, Scheme::Ed25519); + assert!( + unsigned.payloads[0].bytes_hex.len() > 64, + "the payload is the whole message, not a 32-byte digest" + ); +} + +#[test] +fn a_chain_tag_that_disagrees_with_its_transaction_is_refused() { + // The tag and the fields are independent on the wire, so the mismatch is + // reachable and must not be resolved by guessing which one is right. + let error = build_unsigned(&SigningRequest { + chain: Chain::Btc, + transaction: evm_spec(), + public_key: PublicKey { + key_hex: compressed_public(&evm_key()), + }, + }) + .unwrap_err(); + + let rendered = format!("{error:?}"); + assert!(rendered.contains("InvalidInput"), "{rendered}"); +} + +#[test] +fn an_ed25519_signature_is_refused_for_a_secp256k1_chain() { + let error = attach_signature(&AttachRequest { + chain: Chain::Evm, + transaction: evm_spec(), + public_key: PublicKey { + key_hex: compressed_public(&evm_key()), + }, + signatures: vec![Signature::Ed25519 { + signature_hex: "ab".repeat(64), + }], + }) + .unwrap_err(); + + let rendered = format!("{error:?}"); + assert!(rendered.contains("InvalidInput"), "{rendered}"); +} + +#[test] +fn a_wrong_signature_count_is_refused_rather_than_truncated() { + let error = attach_signature(&AttachRequest { + chain: Chain::Evm, + transaction: evm_spec(), + public_key: PublicKey { + key_hex: compressed_public(&evm_key()), + }, + signatures: vec![], + }) + .unwrap_err(); + + let rendered = format!("{error:?}"); + assert!(rendered.contains("InvalidInput"), "{rendered}"); +} + +#[test] +fn a_tron_transaction_whose_txid_does_not_match_its_bytes_is_refused() { + // The defence against a compromised node: it must not be possible to get a + // signature over bytes whose recomputed id disagrees with what was claimed. + let error = build_unsigned(&SigningRequest { + chain: Chain::Tron, + transaction: TransactionSpec::Tron { + raw_data_hex: "0a02b1f12208".to_string() + &"ab".repeat(64), + expected_to: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".to_string(), + expected_txid: "00".repeat(32), + }, + public_key: PublicKey { + key_hex: compressed_public(&evm_key()), + }, + }) + .unwrap_err(); + + let rendered = format!("{error:?}"); + assert!(rendered.contains("InvalidInput"), "{rendered}"); +} + +#[test] +fn the_exported_names_are_the_published_ones() { + // A host resolves the module by these strings; changing either is a + // breaking change that no type system catches. + assert_eq!(super::BUS_NAME, "ai.tinyhumans.tinywallet.Wallet"); + assert_eq!(super::OBJECT_PATH, "/ai/tinyhumans/tinywallet/Wallet"); +} + +#[test] +fn base64_matches_its_specification_for_every_padding_case() { + // Hand-rolled, so the three chunk remainders each need a vector. + assert_eq!(super::base64(b""), ""); + assert_eq!(super::base64(b"f"), "Zg=="); + assert_eq!(super::base64(b"fo"), "Zm8="); + assert_eq!(super::base64(b"foo"), "Zm9v"); + assert_eq!(super::base64(b"foob"), "Zm9vYg=="); + assert_eq!(super::base64(b"fooba"), "Zm9vYmE="); + assert_eq!(super::base64(b"foobar"), "Zm9vYmFy"); +} diff --git a/crates/tinywallet-module/tests/module_e2e.rs b/crates/tinywallet-module/tests/module_e2e.rs new file mode 100644 index 0000000..39168ec --- /dev/null +++ b/crates/tinywallet-module/tests/module_e2e.rs @@ -0,0 +1,394 @@ +//! End-to-end test for loading the built `TinyWallet` module into `TinyBus`. +//! +//! The only test that exercises the real thing: the built `cdylib`, the ABI +//! descriptor, manifest admission, the dynamic loader, and a broker routing +//! actual frames. Everything else in this crate calls Rust functions directly +//! and would keep passing if the artifact stopped loading altogether. +//! +//! What it proves that a unit test cannot: a transaction signed *through the +//! loaded module* is byte-for-byte the transaction the library produces signing +//! in one step. That is the whole claim of moving signing out of a host — if it +//! were false, the host would broadcast something other than what it built. +//! +//! It also demonstrates the property the design exists for: **no method call +//! below carries a private key.** The key never leaves this process, and the +//! module never sees one. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::time::Duration; + +use bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; +use tinybus::Connection; +use tinybus::broker::Broker; +use tinybus::module::{ModuleHost, ModuleState}; +use tinybus::transport::memory::MemoryBus; +use tinywallet::wire::{ + AttachRequest, PublicKey, Scheme, Signature, SignedTransaction, SigningRequest, + TransactionSpec, UnsignedTransaction, +}; +use tinywallet::{Chain, tx}; +use tinywallet_module::{BUS_NAME, OBJECT_PATH}; + +/// Every method the manifest must declare, in order. +const EXPECTED_METHODS: &[&str] = &["BuildUnsigned", "AttachSignature"]; + +/// The BIP-39 test vector mnemonic. Never use it for real funds. +const VECTOR: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires TINYWALLET_TEST_MODULE to point at the built cdylib"] +async fn the_built_module_signs_every_chain_over_a_real_broker() { + // One test rather than four: TinyBus never unloads a module, and a second + // load of the same artifact would collide on the well-known name, so every + // chain is exercised against the one admitted instance. + let (client, modules, broker_task) = admit_module(); + let client = client.await; + wait_until_serving(&client).await; + + let proxy = client.proxy(BUS_NAME, OBJECT_PATH, BUS_NAME).unwrap(); + + signs_an_evm_transfer_identically_to_the_library(&proxy).await; + signs_a_multi_input_bitcoin_spend(&proxy).await; + signs_a_solana_transfer(&proxy).await; + refuses_a_chain_tag_that_contradicts_its_transaction(&proxy).await; + + assert!(matches!(modules.list()[0].state, ModuleState::Ready)); + broker_task.abort(); +} + +/// Load the built artifact and check its manifest against the interface. +fn admit_module() -> ( + impl std::future::Future, + ModuleHost, + tokio::task::JoinHandle>, +) { + let artifact = + std::env::var_os("TINYWALLET_TEST_MODULE").expect("TINYWALLET_TEST_MODULE must be set"); + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let modules = ModuleHost::new(broker); + + let loaded = modules.load_file(artifact).expect("module should load"); + assert_eq!(loaded.name, "tinywallet-module"); + assert_eq!(loaded.manifest.bus_name.as_str(), BUS_NAME); + assert_eq!(loaded.manifest.object_path.as_str(), OBJECT_PATH); + + let declared: Vec<&str> = loaded + .manifest + .provides + .iter() + .flat_map(|interface| interface.methods.iter()) + .map(tinybus::MemberName::as_str) + .collect(); + assert_eq!( + declared, EXPECTED_METHODS, + "manifest methods drifted from the interface" + ); + + let connect = async move { + Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap() + }; + (connect, modules, broker_task) +} + +/// Wait for the module to claim its well-known name. +async fn wait_until_serving(client: &Connection) { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if client + .list_names() + .await + .unwrap() + .iter() + .any(|name| name.as_str() == BUS_NAME) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("module should become ready"); +} + +/// The load-bearing case: through the bus must equal in-process. +async fn signs_an_evm_transfer_identically_to_the_library(proxy: &tinybus::Proxy) { + let secret = derive(Chain::Evm, "m/44'/60'/0'/0/0"); + let spec = TransactionSpec::Evm { + to: "0x3535353535353535353535353535353535353535".to_string(), + value_wei: "1000000000000000000".to_string(), + data_hex: "0x".to_string(), + nonce: 9, + gas_limit: 21_000, + gas_price_wei: "20000000000".to_string(), + chain_id: 1, + }; + + let signed = round_trip(proxy, Chain::Evm, &spec, &secret).await; + + let expected = tx::evm::LegacyTransaction { + nonce: 9, + gas_price: 20_000_000_000, + gas_limit: 21_000, + to: Some("0x3535353535353535353535353535353535353535".to_string()), + value: 1_000_000_000_000_000_000, + data: Vec::new(), + chain_id: 1, + } + .sign(&secret) + .unwrap(); + + assert_eq!( + signed.raw, + format!("0x{}", hex(&expected)), + "a transaction signed through the module diverged from the library" + ); + assert_eq!( + signed.txid, + Some(tx::evm::LegacyTransaction::hash_of(&expected)) + ); +} + +/// Several inputs, so the multi-signature ordering crosses the bus for real. +async fn signs_a_multi_input_bitcoin_spend(proxy: &tinybus::Proxy) { + let derived = tinywallet::key::derive(Chain::Btc, VECTOR, "m/84'/0'/0'/0/0").unwrap(); + let secret = derived.secret_bytes().to_vec(); + let txid = "7f3b662ea8b6ff2e0e1a1f9bd0f1c39a6b8ba51e1b0f0e0d0c0b0a0908070605"; + let spec = TransactionSpec::Btc { + from: derived.address().to_string(), + to: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string(), + amount_sat: 150_000, + fee_rate_sat_vb: 2_000, + utxos: (0..3) + .map(|vout| tinywallet::wire::Utxo { + txid: txid.to_string(), + vout, + value: 60_000 + u64::from(vout) * 10_000, + }) + .collect(), + }; + + let signed = round_trip(proxy, Chain::Btc, &spec, &secret).await; + + let expected = tx::btc::Transfer { + from: derived.address().to_string(), + to: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string(), + amount: 150_000, + fee: 2_000, + } + .sign( + &(0..3) + .map(|vout| tx::btc::Utxo { + txid: txid.to_string(), + vout, + value: 60_000 + u64::from(vout) * 10_000, + }) + .collect::>(), + &secret, + ) + .unwrap(); + + assert_eq!(signed.raw, expected); +} + +/// ed25519 rather than a prehashed digest, so the other signing scheme is covered. +async fn signs_a_solana_transfer(proxy: &tinybus::Proxy) { + use ed25519_dalek::{Signer as _, SigningKey}; + + let derived = tinywallet::key::derive(Chain::Solana, VECTOR, "m/44'/501'/0'/0'").unwrap(); + let spec = TransactionSpec::Solana { + from: derived.address().to_string(), + to: "11111111111111111111111111111111".to_string(), + lamports: 1_000_000_000, + recent_blockhash: "11111111111111111111111111111111".to_string(), + }; + let public = PublicKey { + key_hex: hex(derived.secret_bytes()), + }; + + let unsigned: UnsignedTransaction = proxy + .call( + "BuildUnsigned", + (SigningRequest { + chain: Chain::Solana, + transaction: spec.clone(), + public_key: public.clone(), + },), + ) + .await + .unwrap(); + assert_eq!(unsigned.payloads[0].scheme, Scheme::Ed25519); + + // Signed here, in this process. The module is never given the key. + let key: [u8; 32] = derived.secret_bytes().try_into().unwrap(); + let signature = SigningKey::from_bytes(&key) + .sign(&unhex(&unsigned.payloads[0].bytes_hex)) + .to_bytes(); + + let signed: SignedTransaction = proxy + .call( + "AttachSignature", + (AttachRequest { + chain: Chain::Solana, + transaction: spec.clone(), + public_key: public, + signatures: vec![Signature::Ed25519 { + signature_hex: hex(&signature), + }], + },), + ) + .await + .unwrap(); + + let expected = tx::solana::NativeTransfer { + from: derived.address().to_string(), + to: "11111111111111111111111111111111".to_string(), + lamports: 1_000_000_000, + recent_blockhash: "11111111111111111111111111111111".to_string(), + } + .sign(derived.secret_bytes()) + .unwrap(); + + assert_eq!(signed.raw, base64(&expected)); +} + +/// A malformed request must come back as a named error, not a signature. +async fn refuses_a_chain_tag_that_contradicts_its_transaction(proxy: &tinybus::Proxy) { + let result: tinybus::Result = proxy + .call( + "BuildUnsigned", + (SigningRequest { + chain: Chain::Btc, + transaction: TransactionSpec::Solana { + from: "11111111111111111111111111111112".to_string(), + to: "11111111111111111111111111111113".to_string(), + lamports: 1, + recent_blockhash: "11111111111111111111111111111114".to_string(), + }, + public_key: PublicKey { + key_hex: "02".repeat(33), + }, + },), + ) + .await; + + let error = result.expect_err("a contradictory request must not produce a signature"); + assert_eq!( + error.wire_name(), + "ai.tinyhumans.tinywallet.Error.InvalidInput", + "the wire error name is the contract a host matches on" + ); +} + +/// Drive both calls for a secp256k1 chain, signing locally in between. +async fn round_trip( + proxy: &tinybus::Proxy, + chain: Chain, + spec: &TransactionSpec, + secret: &[u8], +) -> SignedTransaction { + let public_key = PublicKey { + key_hex: hex(&compressed_public(secret)), + }; + + let unsigned: UnsignedTransaction = proxy + .call( + "BuildUnsigned", + (SigningRequest { + chain, + transaction: spec.clone(), + public_key: public_key.clone(), + },), + ) + .await + .unwrap(); + + // Every payload signed here, with a key the module has never seen. + let signatures = unsigned + .payloads + .iter() + .map(|payload| { + assert_eq!(payload.scheme, Scheme::Secp256k1Prehash); + let digest: [u8; 32] = unhex(&payload.bytes_hex).try_into().unwrap(); + let secret = SecretKey::from_slice(secret).unwrap(); + let recoverable = Secp256k1::signing_only() + .sign_ecdsa_recoverable(&Message::from_digest(digest), &secret); + let (recovery_id, compact) = recoverable.serialize_compact(); + Signature::Secp256k1 { + rs_hex: hex(&compact), + recovery_id: u8::try_from(recovery_id.to_i32()).unwrap(), + } + }) + .collect(); + + proxy + .call( + "AttachSignature", + (AttachRequest { + chain, + transaction: spec.clone(), + public_key, + signatures, + },), + ) + .await + .unwrap() +} + +fn derive(chain: Chain, path: &str) -> Vec { + tinywallet::key::derive(chain, VECTOR, path) + .unwrap() + .secret_bytes() + .to_vec() +} + +fn compressed_public(secret: &[u8]) -> [u8; 33] { + use bitcoin::secp256k1::PublicKey as SecpPublic; + let secret = SecretKey::from_slice(secret).unwrap(); + SecpPublic::from_secret_key(&Secp256k1::new(), &secret).serialize() +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().fold(String::new(), |mut out, byte| { + use std::fmt::Write as _; + let _ = write!(out, "{byte:02x}"); + out + }) +} + +fn unhex(value: &str) -> Vec { + (0..value.len()) + .step_by(2) + .map(|index| u8::from_str_radix(&value[index..index + 2], 16).unwrap()) + .collect() +} + +/// Standard base64, matching what the module emits for Solana. +fn base64(bytes: &[u8]) -> String { + const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let b0 = u32::from(chunk[0]); + let b1 = chunk.get(1).copied().map_or(0, u32::from); + let b2 = chunk.get(2).copied().map_or(0, u32::from); + let triple = (b0 << 16) | (b1 << 8) | b2; + out.push(char::from(TABLE[((triple >> 18) & 0x3f) as usize])); + out.push(char::from(TABLE[((triple >> 12) & 0x3f) as usize])); + out.push(if chunk.len() > 1 { + char::from(TABLE[((triple >> 6) & 0x3f) as usize]) + } else { + '=' + }); + out.push(if chunk.len() > 2 { + char::from(TABLE[(triple & 0x3f) as usize]) + } else { + '=' + }); + } + out +} diff --git a/docs/specs/tinybus-module.md b/docs/specs/tinybus-module.md new file mode 100644 index 0000000..91c9da0 --- /dev/null +++ b/docs/specs/tinybus-module.md @@ -0,0 +1,117 @@ +# The TinyWallet TinyBus module + +Status: Implemented + +## Problem + +A wallet's transaction encoders are heavy and a host mostly does something +else. Signing four chains costs `bitcoin` (and therefore a native `secp256k1` +C build), plus a full Ethereum stack for EIP-712 — measured at **51 crates** in +one embedding host, for a capability most sessions never invoke. + +Gating them helps the builds that turn them off and does nothing for the build +that ships, which turns them on. What is needed is a boundary that survives +compilation: the capability present, the dependencies absent. + +## Goals + +- Run transaction building as a compiled artifact loaded at runtime. +- Keep the host's private keys **in the host**, always. +- Produce byte-identical transactions to the in-process library. +- Leave the published `tinywallet` crate bus-agnostic and free of tinybus. + +## Non-goals + +- Running untrusted code. A module is first-party code that ships separately. +- Key storage, key transport, or any form of remote signing. +- Unloading. tinybus never unloads a library. + +## Interface + +`ai.tinyhumans.tinywallet.Wallet` at `/ai/tinyhumans/tinywallet/Wallet`: + +```text +BuildUnsigned(SigningRequest) -> UnsignedTransaction +AttachSignature(AttachRequest) -> SignedTransaction +``` + +Both argument and return types are `tinywallet::wire`, which is outside every +chain gate and depends on nothing but `serde` — so a host takes the crate with +`default-features = false`, shares one definition of the contract, and links no +chain library. + +## The two-call split is the security property + +The host holds the key. It asks what needs signing, signs locally, and hands +back only a signature. **No method accepts key material**, so there is nothing +in the module a leak could disclose. + +`AttachSignature` re-sends the transaction fields rather than a handle, so the +module keeps no state between calls — no store, no bound on it, no expiry for +callers that never return. Rebuilding is safe because building is +deterministic: the same fields yield the transaction the digests were computed +over. + +A loaded module shares the host's address space, so this is not a hard +isolation boundary and is not claimed as one. It is a refusal to widen what +crosses a boundary that already exists, which is worth doing on its own terms. + +## Signing schemes + +| Chain | Payload | Scheme | +| --- | --- | --- | +| Bitcoin | one BIP-143 sighash **per selected input**, in input order | secp256k1 prehash | +| EVM | keccak of the EIP-155 signing payload | secp256k1 prehash | +| Tron | `sha256(raw_data)`, which is also the `txID` | secp256k1 prehash | +| Solana | the **whole serialized message** | ed25519 | + +Two rules a host must not get wrong, both named in the wire types: + +- A `secp256k1_prehash` payload is **already hashed**. Sign it with a prehash + entry point; hashing again produces a valid signature over the wrong thing. +- An `ed25519` payload is **not** hashed — ed25519 hashes internally. + +Signatures must be low-`s` normalized. Bitcoin enforces it as relay policy +(BIP-146) and Ethereum as consensus (EIP-2), so a high-`s` signature yields a +transaction that is rejected rather than merely unusual. `k256` and `secp256k1` +both normalize by default; the Bitcoin path normalizes again on reassembly. + +## Everything travels inline + +Unlike the `tinydocs` module, there are no streams, no chunking and no held +outputs. A tinybus frame is JSON capped at 16 MiB where a byte array costs +about 3.5 bytes per byte — a real constraint for a generated document, and +irrelevant here. The largest payload is a Bitcoin spend's UTXO list; a wallet +with a thousand of them is still tens of kilobytes. + +## Errors + +| Wire name | Meaning | +| --- | --- | +| `…Error.InvalidInput` | The request was wrong. A caller can fix it. | +| `…Error.BuildFailed` | Building or assembling failed. A caller cannot. | +| `…Error.UnsupportedChain` | The chain is not in this build. | + +An unrecognised name must be treated as `BuildFailed`. Telling a model its +input was wrong when it was not sends it into a rewrite loop over something +already correct. + +## Verification + +`crates/tinywallet-module/tests/module_e2e.rs` loads the built `cdylib` through +the real loader and broker and asserts that a transaction signed through the +module equals the library's own output byte-for-byte, on EVM, a multi-input +Bitcoin spend, and Solana. That equivalence is the claim the module rests on. + +The test is `#[ignore]`d and needs `TINYWALLET_TEST_MODULE` pointing at the +artifact, because tinybus never unloads a module and a second load of the same +artifact would collide on the well-known name. + +## Open questions + +**A reply-stream seam in tinybus** would matter if a future method ever +returned something large. Nothing here does. + +**Per-interface method lists in `module_export!`** would let one module serve +several fully-declared interfaces; today the macro attaches its method list to +the first entry in `provides`. diff --git a/src/abi/mod.rs b/src/abi/mod.rs new file mode 100644 index 0000000..7a7ec60 --- /dev/null +++ b/src/abi/mod.rs @@ -0,0 +1,152 @@ +//! The sliver of Ethereum ABI encoding a wallet actually needs. +//! +//! Exactly one call is encoded here — ERC-20 `transfer(address,uint256)` — and +//! that is deliberate. A general ABI encoder is a parser for a type grammar; a +//! token transfer is a four-byte selector followed by two 32-byte words. Taking +//! a full Ethereum library for the second is how a wallet ends up carrying the +//! first, along with a bignum type and a signer stack. +//! +//! This lives outside the `tx` gate on purpose. Calldata is an *input* to +//! building a transaction, so a host that has moved building into a loadable +//! module still needs to produce it — and would otherwise have to pay a bus +//! round trip for keccak over 68 bytes, or link the chain library it just spent +//! the effort removing. + +use crate::eip712::u256_from_decimal; + +/// `keccak256("transfer(address,uint256)")[..4]`. +/// +/// Pinned, and re-derived from the signature in the tests below. Every ERC-20 +/// transfer on every EVM chain starts with these four bytes; getting them wrong +/// produces a call that either reverts or, on a contract with a colliding +/// selector, does something else entirely. +const TRANSFER_SELECTOR: [u8; 4] = [0xa9, 0x05, 0x9c, 0xbb]; + +/// The signature the selector is taken from, kept beside it for the test. +#[cfg(test)] +const TRANSFER_SIGNATURE: &[u8] = b"transfer(address,uint256)"; + +/// Why calldata could not be encoded. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The recipient is not a valid EVM address. + #[error("invalid recipient: {reason}")] + InvalidRecipient { + /// What was wrong with it. + reason: String, + }, + + /// The amount is not a base-10 integer, or overflows 256 bits. + #[error("invalid amount: {reason}")] + InvalidAmount { + /// What was wrong with it. + reason: String, + }, +} + +/// Result alias for this module. +pub type Result = std::result::Result; + +/// ABI-encode an ERC-20 `transfer(address,uint256)` call. +/// +/// `amount` is a base-10 string rather than an integer because token amounts +/// are denominated in the token's own smallest unit: an 18-decimal token puts +/// ordinary balances past `u64`, and a caller almost always has the value as +/// text from an RPC or a user. See [`u256_from_decimal`]. +/// +/// Returns `0x`-prefixed hex, which is what `eth_call` and a transaction's +/// `data` field both take. +/// +/// # Errors +/// +/// [`Error::InvalidRecipient`] or [`Error::InvalidAmount`]. +/// +/// # Examples +/// +/// ``` +/// # #[cfg(all(feature = "evm", feature = "keccak", feature = "eip712"))] { +/// use tinywallet::abi; +/// +/// let data = abi::encode_erc20_transfer( +/// "0x1111111111111111111111111111111111111111", +/// "1000000", +/// )?; +/// assert!(data.starts_with("0xa9059cbb")); +/// // Selector plus two 32-byte words, hex-encoded, plus the `0x`. +/// assert_eq!(data.len(), 2 + 8 + 128); +/// # } +/// # Ok::<(), tinywallet::abi::Error>(()) +/// ``` +pub fn encode_erc20_transfer(to: &str, amount: &str) -> Result { + let recipient = crate::address::evm::validate(to).map_err(|e| Error::InvalidRecipient { + reason: e.to_string(), + })?; + let bytes = decode_evm_address(&recipient)?; + let value = u256_from_decimal(amount).map_err(|e| Error::InvalidAmount { + reason: e.to_string(), + })?; + + let mut out = String::with_capacity(2 + 8 + 128); + out.push_str("0x"); + for byte in TRANSFER_SELECTOR { + push_hex(&mut out, byte); + } + // Both arguments are static types, so each is one 32-byte word in order — + // no head/tail offsets, which is the entire reason this can be 20 lines. + for byte in left_pad_address(bytes) { + push_hex(&mut out, byte); + } + for byte in value { + push_hex(&mut out, byte); + } + Ok(out) +} + +/// The 20 raw bytes of an already-validated `0x`-prefixed EVM address. +fn decode_evm_address(address: &str) -> Result<[u8; 20]> { + let body = address.strip_prefix("0x").unwrap_or(address); + let mut out = [0u8; 20]; + for (index, slot) in out.iter_mut().enumerate() { + let pair = body.get(index * 2..index * 2 + 2).ok_or_else(|| { + // Unreachable via `encode_erc20_transfer`, which validates first. + // Mapped rather than unwrapped so a future caller cannot turn a + // malformed address into a panic inside a wallet. + Error::InvalidRecipient { + reason: "address is shorter than 20 bytes".to_string(), + } + })?; + *slot = u8::from_str_radix(pair, 16).map_err(|_| Error::InvalidRecipient { + reason: "address is not hex".to_string(), + })?; + } + Ok(out) +} + +/// An address as the left-padded 32-byte word the ABI encodes it as. +fn left_pad_address(address: [u8; 20]) -> [u8; 32] { + let mut out = [0u8; 32]; + out[12..].copy_from_slice(&address); + out +} + +/// Append one byte as two lowercase hex digits. +fn push_hex(out: &mut String, byte: u8) { + use std::fmt::Write as _; + // Writing into a String cannot fail; discarded rather than unwrapped so + // this stays panic-free. + let _ = write!(out, "{byte:02x}"); +} + +/// Keccak-256, used only by the selector test. +/// +/// Scoped to tests because production code uses the pinned +/// [`TRANSFER_SELECTOR`] rather than hashing the signature on every call. +#[cfg(test)] +fn keccak(bytes: &[u8]) -> [u8; 32] { + use sha3::{Digest as _, Keccak256}; + Keccak256::digest(bytes).into() +} + +#[cfg(test)] +mod test; diff --git a/src/abi/test.rs b/src/abi/test.rs new file mode 100644 index 0000000..6deae0e --- /dev/null +++ b/src/abi/test.rs @@ -0,0 +1,142 @@ +//! Tests for ERC-20 calldata encoding. +//! +//! Calldata is signed, then executed by a contract that will do exactly what +//! the bytes say. A wrong recipient word or a wrong amount word produces a +//! transaction that succeeds and moves the wrong money, so the encoding is +//! checked against the ABI specification's layout rather than against itself. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::{Error, TRANSFER_SELECTOR, TRANSFER_SIGNATURE, encode_erc20_transfer, keccak}; + +const RECIPIENT: &str = "0x1111111111111111111111111111111111111111"; + +#[test] +fn the_pinned_selector_matches_its_signature() { + // The constant is pinned so the hash is not recomputed per call; this is + // the test that makes pinning safe rather than a place for a typo to hide. + assert_eq!(keccak(TRANSFER_SIGNATURE)[..4], TRANSFER_SELECTOR); +} + +#[test] +fn the_selector_is_the_published_erc20_transfer_selector() { + assert_eq!(TRANSFER_SELECTOR, [0xa9, 0x05, 0x9c, 0xbb]); +} + +#[test] +fn the_encoding_is_a_selector_and_two_left_padded_words() { + let data = encode_erc20_transfer(RECIPIENT, "1000000").unwrap(); + + // 0x + 4-byte selector + 2 x 32-byte words, hex. + assert_eq!(data.len(), 2 + 8 + 128); + assert_eq!( + data, + "0xa9059cbb\ + 0000000000000000000000001111111111111111111111111111111111111111\ + 00000000000000000000000000000000000000000000000000000000000f4240" + ); +} + +#[test] +fn the_recipient_is_right_aligned_in_its_word() { + // Left-padding is the ABI rule for `address`. Getting it backwards yields + // a well-formed call paying an address nobody controls. + let data = encode_erc20_transfer(RECIPIENT, "1").unwrap(); + let recipient_word = &data[10..74]; + assert!(recipient_word.starts_with(&"0".repeat(24))); + assert!(recipient_word.ends_with(&"11".repeat(20))); +} + +#[test] +fn an_amount_beyond_u64_encodes_exactly() { + // The reason the amount is a string: an 18-decimal token puts ordinary + // balances past u64, and truncating would silently transfer the wrong sum. + let data = encode_erc20_transfer(RECIPIENT, "340282366920938463463374607431768211456").unwrap(); + assert!(data.ends_with("0000000000000000000000000000000100000000000000000000000000000000")); +} + +#[test] +fn the_largest_representable_amount_is_accepted() { + let max = "115792089237316195423570985008687907853269984665640564039457584007913129639935"; + let data = encode_erc20_transfer(RECIPIENT, max).unwrap(); + assert!(data.ends_with(&"f".repeat(64))); +} + +#[test] +fn a_zero_amount_encodes_as_a_zero_word_not_an_empty_one() { + // Static types are always a full word; an empty encoding would shift the + // call's shape and make it unparseable by the contract. + let data = encode_erc20_transfer(RECIPIENT, "0").unwrap(); + assert_eq!(data.len(), 2 + 8 + 128); + assert!(data.ends_with(&"0".repeat(64))); +} + +#[test] +fn a_checksummed_recipient_encodes_the_same_as_a_lowercase_one() { + // EIP-55 casing is display metadata, not part of the address. + let lower = encode_erc20_transfer("0xab5801a7d398351b8be11c439e05c5b3259aec9b", "5").unwrap(); + let checksummed = + encode_erc20_transfer("0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B", "5").unwrap(); + assert_eq!(lower, checksummed); +} + +#[test] +fn an_invalid_recipient_is_refused() { + for bad in [ + "", + "0x", + "not-an-address", + "0x111", + &format!("0x{}", "1".repeat(41)), + ] { + assert!( + matches!( + encode_erc20_transfer(bad, "1"), + Err(Error::InvalidRecipient { .. }) + ), + "{bad:?} should be refused" + ); + } +} + +#[test] +fn a_non_numeric_or_overflowing_amount_is_refused() { + for bad in [ + "", + "12a", + "-1", + "1.5", + "0x10", + // 2^256 exactly: one past the top. + "115792089237316195423570985008687907853269984665640564039457584007913129639936", + ] { + assert!( + matches!( + encode_erc20_transfer(RECIPIENT, bad), + Err(Error::InvalidAmount { .. }) + ), + "{bad:?} should be refused" + ); + } +} + +#[test] +fn the_address_decoder_refuses_malformed_input_rather_than_panicking() { + // `encode_erc20_transfer` validates before calling this, so these arms are + // defensive — but defensive code that is never exercised is code nobody + // knows works, and the failure mode it guards against is a panic inside a + // wallet. Tested directly because the public path cannot reach it. + use super::decode_evm_address; + + assert!(matches!( + decode_evm_address("0x1111"), + Err(Error::InvalidRecipient { .. }) + )); + assert!(matches!( + decode_evm_address(&format!("0x{}", "zz".repeat(20))), + Err(Error::InvalidRecipient { .. }) + )); + + // The happy path, unprefixed, to pin that the `0x` is optional here. + assert_eq!(decode_evm_address(&"11".repeat(20)).unwrap(), [0x11u8; 20]); +} diff --git a/src/address/btc.rs b/src/address/btc.rs index fa43b33..987b9af 100644 --- a/src/address/btc.rs +++ b/src/address/btc.rs @@ -15,14 +15,69 @@ //! time, after a transaction has been assembled. The two are separate //! functions rather than a boolean flag so that mistake reads wrong at the //! call site. - -use std::str::FromStr; - -use bitcoin::{Address, Network}; +//! +//! # Why this does not use the `bitcoin` crate +//! +//! It used to. The crate is excellent and this module is a strictly smaller +//! thing than what it offers — but it carries `secp256k1`, and therefore a +//! native C build, into every consumer that only ever wanted to check whether a +//! string is a well-formed address. That cost is invisible in a full wallet and +//! dominant in a host that has moved signing elsewhere. +//! +//! Address *parsing* is a safe thing to own directly, unlike the BIP-32 walk in +//! [`crate::key`], which deliberately still delegates. The distinction is +//! failure mode, not difficulty: a parser that is wrong rejects a good address +//! or accepts a malformed one, and both are caught immediately by the vectors +//! below. A derivation that is wrong returns a *valid key for the wrong +//! account* — silently, and unrecoverably. So this module is hand-rolled +//! against the published BIP-173 and BIP-350 vectors, and key derivation is +//! not. +//! +//! The four mainnet forms, in full: +//! +//! | Type | Encoding | Prefix / witness version | Program length | +//! | --- | --- | --- | --- | +//! | P2PKH | base58check | version byte `0x00` | 20 | +//! | P2SH | base58check | version byte `0x05` | 20 | +//! | P2WPKH | bech32 | `bc`, v0 | 20 | +//! | P2WSH | bech32 | `bc`, v0 | 32 | +//! | P2TR | bech32m | `bc`, v1 | 32 | +//! +//! Witness versions 2..=16 are accepted as recipients with a 2..=40 byte +//! program, per BIP-350. Refusing them would make this crate reject addresses +//! that are valid today and spendable by their owners, purely because a future +//! output type had not been invented when it was written. use crate::chain::Chain; use crate::{Error, Result}; +/// Human-readable part of a Bitcoin **mainnet** bech32 address. +const MAINNET_HRP: &str = "bc"; + +/// Base58check version byte for P2PKH. +const P2PKH_VERSION: u8 = 0x00; + +/// Base58check version byte for P2SH. +const P2SH_VERSION: u8 = 0x05; + +/// Base58check version bytes belonging to Bitcoin test networks. +const TEST_VERSIONS: [u8; 2] = [0x6f, 0xc4]; + +/// What a well-formed mainnet address turned out to be. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Kind { + /// Pay to public key hash — legacy, base58. + P2pkh, + /// Pay to script hash — base58. + P2sh, + /// Pay to witness public key hash — the only spendable-from type here. + P2wpkh, + /// Pay to witness script hash. + P2wsh, + /// A segwit output that is none of the above: taproot, or a future version. + OtherWitness, +} + /// Validate a Bitcoin **mainnet** address of any type, returning it trimmed. /// /// Use this for transaction recipients. @@ -47,25 +102,8 @@ use crate::{Error, Result}; /// assert!(btc::validate("tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx").is_err()); /// ``` pub fn validate(address: &str) -> Result { - let trimmed = address.trim(); - if trimmed.is_empty() { - return Err(Error::EmptyAddress { chain: Chain::Btc }); - } - - Address::from_str(trimmed) - .map_err(|e| Error::InvalidAddress { - chain: Chain::Btc, - address: trimmed.to_string(), - reason: e.to_string(), - })? - .require_network(Network::Bitcoin) - .map_err(|e| Error::WrongNetwork { - chain: Chain::Btc, - address: trimmed.to_string(), - expected: "mainnet".to_string(), - reason: e.to_string(), - })?; - + let trimmed = trimmed_non_empty(address)?; + parse(trimmed)?; Ok(trimmed.to_string()) } @@ -94,33 +132,160 @@ pub fn validate(address: &str) -> Result { /// assert!(btc::validate_sender("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2").is_err()); /// ``` pub fn validate_sender(address: &str) -> Result { + let trimmed = trimmed_non_empty(address)?; + // Deliberately ordered: a malformed or wrong-network address is reported as + // such, never as an unsupported *type*, which would point at the wrong fix. + if parse(trimmed)? != Kind::P2wpkh { + return Err(Error::UnsupportedAddressType { + chain: Chain::Btc, + address: trimmed.to_string(), + reason: "only P2WPKH (bc1q… native segwit) can be signed for".to_string(), + }); + } + Ok(trimmed.to_string()) +} + +/// Encode a 20-byte public key hash as a mainnet P2WPKH (`bc1q…`) address. +/// +/// The counterpart to parsing: [`crate::key`] derives a public key and needs +/// its address, and doing that here keeps the bech32 encoding in the module +/// that also decodes it. +/// +/// # Errors +/// +/// [`Error::InvalidAddress`] only if bech32 encoding fails, which for a +/// fixed-length v0 program and a constant HRP it cannot. +pub(crate) fn encode_p2wpkh(pubkey_hash: &[u8; 20]) -> Result { + // `hrp::BC` rather than parsing `MAINNET_HRP`: the parse could not fail for + // a two-letter constant, and an error arm that cannot fire is one nothing + // can test. + bech32::segwit::encode_v0(bech32::hrp::BC, pubkey_hash).map_err(|e| Error::InvalidAddress { + chain: Chain::Btc, + address: String::new(), + reason: e.to_string(), + }) +} + +/// Trim `address` and reject it if nothing is left. +fn trimmed_non_empty(address: &str) -> Result<&str> { let trimmed = address.trim(); if trimmed.is_empty() { return Err(Error::EmptyAddress { chain: Chain::Btc }); } + Ok(trimmed) +} - let parsed = Address::from_str(trimmed) - .map_err(|e| Error::InvalidAddress { +/// Identify a mainnet address, or say why it is not one. +/// +/// Dispatch is on *shape*, not on a list of known prefixes. A bech32 string is +/// an all-letter human-readable part, a `1` separator, then a data part drawn +/// from an alphabet that excludes `1` — so the last `1` is the separator, and +/// what precedes it is the HRP. +/// +/// Routing every bech32-shaped string to [`parse_bech32`], rather than only +/// those starting `bc1`, is what lets a testnet or foreign-chain address be +/// reported as the wrong network instead of as malformed base58. Matching on a +/// hardcoded prefix list left that check unreachable and gave a Litecoin +/// address a base58 error message. +fn parse(address: &str) -> Result { + let lower = address.to_ascii_lowercase(); + if let Some(separator) = lower.rfind('1') { + let hrp = &lower[..separator]; + if !hrp.is_empty() && hrp.chars().all(|c| c.is_ascii_lowercase()) { + return parse_bech32(address); + } + } + parse_base58(address) +} + +/// Decode a bech32 or bech32m segwit address. +/// +/// One call does the whole job: `bech32::segwit::decode` rejects a witness +/// version above 16, selects the checksum algorithm the version requires +/// (bech32 for v0, bech32m for v1+, per BIP-350), rejects mixed case, and +/// enforces the program-length rules — 20 or 32 bytes at v0, 2..=40 above it. +/// Re-checking any of that here would be a second, drifting implementation of +/// rules the crate already owns. +fn parse_bech32(address: &str) -> Result { + let (hrp, version, program) = + bech32::segwit::decode(address).map_err(|e| Error::InvalidAddress { chain: Chain::Btc, - address: trimmed.to_string(), + address: address.to_string(), reason: e.to_string(), - })? - .require_network(Network::Bitcoin) - .map_err(|e| Error::WrongNetwork { + })?; + + if hrp.as_str() != MAINNET_HRP { + return Err(wrong_network(address, "a non-mainnet human-readable part")); + } + + // Only v0 needs discriminating, because only P2WPKH is spendable here. + // Taproot and every future version are payable recipients and nothing more, + // so they share one arm rather than each earning a variant that no caller + // would branch on. + if version.to_u8() != 0 { + return Ok(Kind::OtherWitness); + } + match program.len() { + 20 => Ok(Kind::P2wpkh), + // Guaranteed 32 by the length validation above; spelled out rather than + // wildcarded so a future relaxation upstream cannot silently land here + // as "P2WSH". + 32 => Ok(Kind::P2wsh), + other => Err(Error::InvalidAddress { chain: Chain::Btc, - address: trimmed.to_string(), - expected: "mainnet".to_string(), + address: address.to_string(), + reason: format!("witness v0 program must be 20 or 32 bytes, got {other}"), + }), + } +} + +/// Decode a base58check P2PKH or P2SH address. +fn parse_base58(address: &str) -> Result { + let decoded = bs58::decode(address) + .with_check(None) + .into_vec() + .map_err(|e| Error::InvalidAddress { + chain: Chain::Btc, + address: address.to_string(), reason: e.to_string(), })?; - if !parsed.script_pubkey().is_p2wpkh() { - return Err(Error::UnsupportedAddressType { + // base58check strips the 4-byte checksum, leaving version || payload. + let (version, payload) = decoded.split_first().ok_or_else(|| Error::InvalidAddress { + chain: Chain::Btc, + address: address.to_string(), + reason: "empty base58check payload".to_string(), + })?; + + if TEST_VERSIONS.contains(version) { + return Err(wrong_network(address, "a test network version byte")); + } + if payload.len() != 20 { + return Err(Error::InvalidAddress { chain: Chain::Btc, - address: trimmed.to_string(), - reason: "only P2WPKH (bc1q… native segwit) can be signed for".to_string(), + address: address.to_string(), + reason: format!("hash must be 20 bytes, got {}", payload.len()), }); } - Ok(trimmed.to_string()) + match *version { + P2PKH_VERSION => Ok(Kind::P2pkh), + P2SH_VERSION => Ok(Kind::P2sh), + other => Err(Error::InvalidAddress { + chain: Chain::Btc, + address: address.to_string(), + reason: format!("unknown base58check version byte {other:#04x}"), + }), + } +} + +/// A well-formed address that belongs to another network. +fn wrong_network(address: &str, reason: &str) -> Error { + Error::WrongNetwork { + chain: Chain::Btc, + address: address.to_string(), + expected: "mainnet".to_string(), + reason: reason.to_string(), + } } #[cfg(test)] diff --git a/src/address/btc/test.rs b/src/address/btc/test.rs index de14cf1..59d7970 100644 --- a/src/address/btc/test.rs +++ b/src/address/btc/test.rs @@ -117,3 +117,172 @@ fn sender_validation_still_reports_the_underlying_failure_first() { Error::EmptyAddress { .. } )); } + +// --------------------------------------------------------------------------- +// Branch coverage for the hand-rolled parser. +// +// These are the paths that only exist because this module stopped delegating +// to the `bitcoin` crate. Each one is a rejection, and a rejection that never +// fires is indistinguishable from one that is wrong — so every arm gets a +// vector, drawn from BIP-173 and BIP-350 where they publish one. +// --------------------------------------------------------------------------- + +/// P2TR — taproot, witness v1, bech32m. A valid recipient, not a sender. +const P2TR: &str = "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0"; + +#[test] +fn accepts_taproot_as_a_recipient_but_not_as_a_sender() { + // Witness v1 uses bech32m rather than bech32; accepting it proves the + // checksum variant is selected by version rather than assumed. + assert_eq!(validate(P2TR).unwrap(), P2TR); + assert!(matches!( + validate_sender(P2TR).unwrap_err(), + Error::UnsupportedAddressType { .. } + )); +} + +#[test] +fn rejects_a_v0_address_carrying_a_bech32m_checksum() { + // BIP-350's central rule. Both strings below are well-formed bech32-ish; + // what separates them is which checksum constant they were built with, and + // accepting the wrong one would accept addresses no other wallet does. + let v0_with_bech32m = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kemeawh"; + assert!(validate(v0_with_bech32m).is_err()); +} + +#[test] +fn rejects_a_taproot_address_carrying_a_bech32_checksum() { + // The mirror of the case above: v1 must be bech32m. + let v1_with_bech32 = "bc1p38j9r5y49hruaue7wxjce0updqjuyyx0kh56v8s25huc6995vvpql3jow4"; + assert!(validate(v1_with_bech32).is_err()); +} + +#[test] +fn rejects_a_witness_program_of_the_wrong_length_for_version_zero() { + // BIP-173: a v0 program is 20 or 32 bytes and nothing else. + let v0_16_bytes = "bc1rw5uspcuh"; + assert!(validate(v0_16_bytes).is_err()); +} + +#[test] +fn rejects_a_mixed_case_bech32_address() { + // Mixed case is invalid per BIP-173 because it breaks the checksum's + // case-folding guarantee. + let mixed = "bc1QW508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"; + assert!(validate(mixed).is_err()); +} + +#[test] +fn reports_a_testnet_base58_address_as_the_wrong_network_not_as_malformed() { + // A testnet P2PKH is perfectly well-formed; naming it correctly is the + // difference between a user fixing their address and thinking it is broken. + let testnet_p2pkh = "mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn"; + assert!(matches!( + validate(testnet_p2pkh).unwrap_err(), + Error::WrongNetwork { .. } + )); +} + +#[test] +fn reports_a_regtest_bech32_address_as_the_wrong_network() { + let regtest = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080"; + assert!(matches!( + validate(regtest).unwrap_err(), + Error::WrongNetwork { .. } + )); +} + +#[test] +fn rejects_a_base58_address_with_an_unknown_version_byte() { + // Valid base58check, valid length, but a version byte that is neither + // P2PKH nor P2SH on mainnet — a namecoin address, for instance. + let unknown_version = "NCXn6ZQTr8GN5T4bB1oSHnLRcNPQXswcpv"; + match validate(unknown_version) { + Err(Error::InvalidAddress { .. } | Error::WrongNetwork { .. }) => {} + other => panic!("expected a rejection, got {other:?}"), + } +} + +#[test] +fn rejects_a_bech32_address_for_another_coin() { + // Well-formed bech32 with a human-readable part that is not Bitcoin's. + let not_bitcoin = "ltc1qw508d6qejxtdg4y5r3zarvary0c5xw7kgmn4n9"; + assert!(validate(not_bitcoin).is_err()); +} + +#[test] +fn encodes_a_p2wpkh_address_its_own_validator_accepts() { + // Closes the loop: what `key::btc` produces must parse back here, and the + // encoder is the only part of this module the validators do not exercise. + let pubkey_hash = [0x75u8; 20]; + let encoded = super::encode_p2wpkh(&pubkey_hash).unwrap(); + assert!(encoded.starts_with("bc1q")); + assert_eq!(validate_sender(&encoded).unwrap(), encoded); +} + +/// Encode `version || payload` as base58check, the way a real address is built. +/// +/// Constructed rather than copied from a block explorer because these vectors +/// have to be *valid* base58check that is wrong in one specific way — a +/// hand-typed string would fail its checksum first and never reach the rule +/// under test. +fn base58check(version: u8, payload: &[u8]) -> String { + let mut body = Vec::with_capacity(1 + payload.len()); + body.push(version); + body.extend_from_slice(payload); + bs58::encode(body).with_check().into_string() +} + +#[test] +fn rejects_a_base58_address_with_an_unrecognised_version_byte() { + // Valid checksum, 20-byte hash, but a version that is neither P2PKH (0x00) + // nor P2SH (0x05) on mainnet — a Litecoin P2PKH, for instance. + let litecoin = base58check(0x30, &[0x11; 20]); + match validate(&litecoin).unwrap_err() { + Error::InvalidAddress { reason, .. } => assert!(reason.contains("version"), "{reason}"), + other => panic!("expected InvalidAddress, got {other:?}"), + } +} + +#[test] +fn rejects_a_base58_address_whose_hash_is_the_wrong_length() { + // A well-formed base58check envelope around a 19-byte hash. Accepting it + // would build a transaction paying a script nobody can spend. + let short = base58check(0x00, &[0x11; 19]); + match validate(&short).unwrap_err() { + Error::InvalidAddress { reason, .. } => assert!(reason.contains("20 bytes"), "{reason}"), + other => panic!("expected InvalidAddress, got {other:?}"), + } +} + +#[test] +fn rejects_an_empty_base58check_payload() { + // Checksum over nothing at all: there is no version byte to read. + let empty = bs58::encode(Vec::::new()).with_check().into_string(); + assert!(validate(&empty).is_err()); +} + +#[test] +fn reports_a_testnet_p2sh_version_as_the_wrong_network() { + // 0xc4 is testnet P2SH. The sibling 0x6f (testnet P2PKH) is covered above + // by a real address; this one completes the pair. + let testnet_p2sh = base58check(0xc4, &[0x11; 20]); + assert!(matches!( + validate(&testnet_p2sh).unwrap_err(), + Error::WrongNetwork { .. } + )); +} + +#[test] +fn reports_a_foreign_bech32_chain_as_the_wrong_network_not_as_bad_base58() { + // The check this exercises was unreachable when dispatch matched on a + // hardcoded `bc1` prefix: a Litecoin bech32 address fell through to the + // base58 parser and came back with a nonsensical error. + let litecoin = "ltc1qw508d6qejxtdg4y5r3zarvary0c5xw7kgmn4n9"; + match validate(litecoin).unwrap_err() { + Error::WrongNetwork { reason, .. } => { + assert!(reason.contains("human-readable part"), "{reason}"); + } + other => panic!("expected WrongNetwork, got {other:?}"), + } +} diff --git a/src/address/mod.rs b/src/address/mod.rs index 955d833..7143456 100644 --- a/src/address/mod.rs +++ b/src/address/mod.rs @@ -60,10 +60,12 @@ pub mod tron; /// # Examples /// /// ``` +/// # #[cfg(feature = "solana")] { /// use tinywallet::{address, chain::Chain}; /// /// let addr = address::validate(Chain::Solana, "11111111111111111111111111111111")?; /// assert_eq!(addr, "11111111111111111111111111111111"); +/// # } /// # Ok::<(), tinywallet::Error>(()) /// ``` // With every chain gate off, only the `ChainNotCompiled` arm survives and diff --git a/src/chain/mod.rs b/src/chain/mod.rs index f9fc8da..7b59084 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -14,7 +14,16 @@ use std::fmt; use std::str::FromStr; /// A blockchain this crate has address support for. +/// +/// Serde support is conditional so the enum stays dependency-free in builds +/// that do not need it. The representation is the lowercase variant name +/// (`"btc"`, `"evm"`, …), matching [`FromStr`] and [`fmt::Display`] below, so a +/// value written by one and read by the other agrees — this type crosses a +/// host/backend boundary in [`crate::wire`], where a mismatch between the text +/// and JSON forms would be a runtime deserialization failure. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))] #[non_exhaustive] pub enum Chain { /// Bitcoin (mainnet). diff --git a/src/eip712/mod.rs b/src/eip712/mod.rs new file mode 100644 index 0000000..3808051 --- /dev/null +++ b/src/eip712/mod.rs @@ -0,0 +1,189 @@ +//! EIP-712 typed-data hashing, and the EIP-3009 payload x402 signs. +//! +//! # Why this is here rather than in a chain library +//! +//! EIP-712 is a hashing scheme, not a chain client. Everything below is +//! keccak-256 over a fixed byte layout — there is no RPC, no signing, and no +//! elliptic curve involved. Hosting it here means the x402 payment path needs +//! `sha3` and nothing else, where routing it through a full Ethereum library +//! costs an ABI encoder, a bignum type, a signer stack, and their tails. +//! +//! # Integers are big-endian `[u8; 32]`, deliberately +//! +//! EIP-712 encodes every `uint256` as a 32-byte big-endian word, so that is the +//! type this module takes. Introducing a bignum just to convert it back to the +//! same 32 bytes would add a dependency to this crate and force one on every +//! caller. [`u256_from_u64`] and [`u256_from_decimal`] cover the two ways a +//! caller actually has the value. +//! +//! # Nothing here signs +//! +//! [`signing_digest`] returns the 32 bytes to sign and stops. That is the same +//! split the rest of this crate makes — see [`crate::wire`] — and it is what +//! lets the payload be built somewhere the signing key is not. + +use sha3::{Digest, Keccak256}; + +/// `keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")`. +/// +/// Pinned rather than computed at each call: it is a published constant, and a +/// test below recomputes it, so a typo in the type string is caught here rather +/// than as a signature a contract silently rejects. +const DOMAIN_TYPE_HASH: [u8; 32] = [ + 0x8b, 0x73, 0xc3, 0xc6, 0x9b, 0xb8, 0xfe, 0x3d, 0x51, 0x2e, 0xcc, 0x4c, 0xf7, 0x59, 0xcc, 0x79, + 0x23, 0x9f, 0x7b, 0x17, 0x9b, 0x0f, 0xfa, 0xca, 0xa9, 0xa7, 0x5d, 0x52, 0x2b, 0x39, 0x40, 0x0f, +]; + +/// The EIP-712 type string for the EIP-3009 authorization x402 uses. +const TRANSFER_WITH_AUTHORIZATION_TYPE: &[u8] = b"TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"; + +/// The EIP-712 domain string, kept beside its pinned hash. +/// +/// Test-only, and that is the point: production code uses [`DOMAIN_TYPE_HASH`] +/// directly rather than hashing this on every call, and the test re-derives the +/// hash from this string to prove the two agree. Keeping the string here is +/// what makes pinning the hash safe instead of merely fast — a typo in either +/// one fails the test rather than silently changing every signature. +#[cfg(test)] +const DOMAIN_TYPE: &[u8] = + b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"; + +/// A 32-byte big-endian unsigned integer, as EIP-712 encodes `uint256`. +pub type U256Bytes = [u8; 32]; + +/// An EVM address as its raw 20 bytes. +pub type Address20 = [u8; 20]; + +/// Widen a `u64` into the 32-byte big-endian form EIP-712 wants. +#[must_use] +pub fn u256_from_u64(value: u64) -> U256Bytes { + let mut out = [0u8; 32]; + out[24..].copy_from_slice(&value.to_be_bytes()); + out +} + +/// Parse a base-10 integer string into the 32-byte big-endian form. +/// +/// Token amounts arrive as decimal strings — a `u64` cannot hold 18-decimal +/// values — so this does the widening without a bignum dependency, by long +/// multiplication over the 32 bytes. +/// +/// # Errors +/// +/// [`Error::InvalidAmount`] if `value` is empty, holds a non-digit, or does not +/// fit in 256 bits. +pub fn u256_from_decimal(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() || !trimmed.bytes().all(|b| b.is_ascii_digit()) { + return Err(Error::InvalidAmount { + reason: "expected a base-10 integer".to_string(), + }); + } + + let mut out = [0u8; 32]; + for digit in trimmed.bytes().map(|b| u32::from(b - b'0')) { + // out = out * 10 + digit, big-endian, carrying from the least + // significant byte upwards. + let mut carry = digit; + for byte in out.iter_mut().rev() { + let product = u32::from(*byte) * 10 + carry; + *byte = u8::try_from(product & 0xff).unwrap_or(0); + carry = product >> 8; + } + if carry != 0 { + return Err(Error::InvalidAmount { + reason: "value does not fit in 256 bits".to_string(), + }); + } + } + Ok(out) +} + +/// Why an EIP-712 payload could not be built. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// An amount was not a base-10 integer, or overflowed 256 bits. + #[error("invalid amount: {reason}")] + InvalidAmount { + /// What was wrong with it. + reason: String, + }, +} + +/// Result alias for this module. +pub type Result = std::result::Result; + +/// The EIP-712 domain separator. +/// +/// `name` and `version` are the token contract's, not the caller's choice: USDC +/// uses `("USD Coin", "2")`, but an x402 `extra` may name different ones, and a +/// mismatch produces a signature the contract rejects rather than an error +/// anything local can detect. +#[must_use] +pub fn domain_separator( + verifying_contract: Address20, + chain_id: u64, + name: &str, + version: &str, +) -> [u8; 32] { + let mut encoded = Vec::with_capacity(5 * 32); + encoded.extend_from_slice(&DOMAIN_TYPE_HASH); + encoded.extend_from_slice(&keccak(name.as_bytes())); + encoded.extend_from_slice(&keccak(version.as_bytes())); + encoded.extend_from_slice(&u256_from_u64(chain_id)); + encoded.extend_from_slice(&left_pad_address(verifying_contract)); + keccak(&encoded) +} + +/// The EIP-3009 `TransferWithAuthorization` struct hash. +#[must_use] +pub fn transfer_with_authorization_hash( + from: Address20, + to: Address20, + value: U256Bytes, + valid_after: U256Bytes, + valid_before: U256Bytes, + nonce: [u8; 32], +) -> [u8; 32] { + let mut encoded = Vec::with_capacity(7 * 32); + encoded.extend_from_slice(&keccak(TRANSFER_WITH_AUTHORIZATION_TYPE)); + encoded.extend_from_slice(&left_pad_address(from)); + encoded.extend_from_slice(&left_pad_address(to)); + encoded.extend_from_slice(&value); + encoded.extend_from_slice(&valid_after); + encoded.extend_from_slice(&valid_before); + encoded.extend_from_slice(&nonce); + keccak(&encoded) +} + +/// The 32 bytes a caller signs: `keccak256(0x19 0x01 ‖ domain ‖ struct)`. +/// +/// The `0x1901` prefix is what keeps a typed-data signature from ever being +/// replayable as a transaction signature — it makes the preimage impossible to +/// confuse with an RLP-encoded transaction. +/// +/// Already hashed: sign it with a "prehash" entry point, never by hashing again. +#[must_use] +pub fn signing_digest(domain_separator: [u8; 32], struct_hash: [u8; 32]) -> [u8; 32] { + let mut preimage = Vec::with_capacity(2 + 64); + preimage.extend_from_slice(&[0x19, 0x01]); + preimage.extend_from_slice(&domain_separator); + preimage.extend_from_slice(&struct_hash); + keccak(&preimage) +} + +/// Keccak-256. +fn keccak(bytes: &[u8]) -> [u8; 32] { + Keccak256::digest(bytes).into() +} + +/// An address as a left-padded 32-byte word, which is how EIP-712 encodes it. +fn left_pad_address(address: Address20) -> [u8; 32] { + let mut out = [0u8; 32]; + out[12..].copy_from_slice(&address); + out +} + +#[cfg(test)] +mod test; diff --git a/src/eip712/test.rs b/src/eip712/test.rs new file mode 100644 index 0000000..eeaf072 --- /dev/null +++ b/src/eip712/test.rs @@ -0,0 +1,207 @@ +//! Tests for EIP-712 hashing. +//! +//! A wrong hash here is not a crash — it is a well-formed signature over +//! something other than the intended payment, which the contract rejects with +//! no explanation, or worse, accepts. So the constants are checked against the +//! specifications rather than against this module's own output. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::{ + DOMAIN_TYPE, DOMAIN_TYPE_HASH, Error, TRANSFER_WITH_AUTHORIZATION_TYPE, domain_separator, + keccak, signing_digest, transfer_with_authorization_hash, u256_from_decimal, u256_from_u64, +}; + +fn hex(bytes: &[u8]) -> String { + bytes.iter().fold(String::new(), |mut out, b| { + use std::fmt::Write as _; + let _ = write!(out, "{b:02x}"); + out + }) +} + +#[test] +fn the_pinned_domain_type_hash_matches_its_type_string() { + // The constant is pinned so a typo in the type string cannot silently + // change every signature this module produces. This is the test that makes + // pinning safe rather than merely convenient. + assert_eq!(keccak(DOMAIN_TYPE), DOMAIN_TYPE_HASH); +} + +#[test] +fn the_domain_type_hash_is_the_published_constant() { + assert_eq!( + hex(&DOMAIN_TYPE_HASH), + "8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f" + ); +} + +#[test] +fn the_eip3009_type_hash_is_the_published_constant() { + // From EIP-3009. A wrong type hash produces a signature that every + // conforming token contract refuses. + assert_eq!( + hex(&keccak(TRANSFER_WITH_AUTHORIZATION_TYPE)), + "7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267" + ); +} + +#[test] +fn a_u64_widens_into_the_low_eight_bytes() { + let widened = u256_from_u64(1); + assert_eq!(widened[31], 1); + assert!(widened[..31].iter().all(|b| *b == 0)); + + assert_eq!( + hex(&u256_from_u64(u64::MAX)), + "000000000000000000000000000000000000000000000000ffffffffffffffff" + ); +} + +#[test] +fn a_decimal_string_widens_the_same_way_a_u64_does() { + // The two paths must agree wherever they overlap, or an amount's encoding + // would depend on which one the caller happened to use. + for value in [0u64, 1, 42, 1_000_000, u64::MAX] { + assert_eq!( + u256_from_decimal(&value.to_string()).unwrap(), + u256_from_u64(value), + "decimal and u64 widening disagree for {value}" + ); + } +} + +#[test] +fn a_decimal_string_carries_beyond_sixty_four_bits() { + // The reason the decimal path exists: an 18-decimal token amount does not + // fit in a u64. + let one_ether = "1000000000000000000000000000000000000000"; + let widened = u256_from_decimal(one_ether).unwrap(); + assert!( + widened[..8].iter().any(|b| *b != 0) || widened[8..24].iter().any(|b| *b != 0), + "a value past 2^64 must occupy the high bytes: {}", + hex(&widened) + ); + + // 2^128, checked exactly. + assert_eq!( + hex(&u256_from_decimal("340282366920938463463374607431768211456").unwrap()), + "0000000000000000000000000000000100000000000000000000000000000000" + ); +} + +#[test] +fn the_largest_representable_value_is_accepted_and_the_next_is_not() { + let max = "1157920892373161954235709850086879078532699846656405640394575840079131296399\ + 35"; + let max = max.replace(char::is_whitespace, ""); + assert_eq!(hex(&u256_from_decimal(&max).unwrap()), "ff".repeat(32)); + + // 2^256 exactly: one past the top. + let overflow = "115792089237316195423570985008687907853269984665640564039457584007913129639936"; + assert!(matches!( + u256_from_decimal(overflow).unwrap_err(), + Error::InvalidAmount { .. } + )); +} + +#[test] +fn a_non_numeric_amount_is_refused_rather_than_silently_zero() { + for bad in ["", " ", "12a", "-1", "1.5", "0x10"] { + assert!( + matches!(u256_from_decimal(bad), Err(Error::InvalidAmount { .. })), + "{bad:?} should be refused" + ); + } +} + +#[test] +fn the_domain_separator_depends_on_every_one_of_its_inputs() { + // Each field is part of the replay boundary: the same authorization must + // not verify on another chain, another contract, or another token. + let contract = [0x11u8; 20]; + let base = domain_separator(contract, 1, "USD Coin", "2"); + + assert_ne!(base, domain_separator([0x22u8; 20], 1, "USD Coin", "2")); + assert_ne!(base, domain_separator(contract, 8453, "USD Coin", "2")); + assert_ne!(base, domain_separator(contract, 1, "USDC", "2")); + assert_ne!(base, domain_separator(contract, 1, "USD Coin", "1")); +} + +#[test] +fn the_struct_hash_depends_on_every_one_of_its_inputs() { + let base = transfer_with_authorization_hash( + [0x11; 20], + [0x22; 20], + u256_from_u64(100), + u256_from_u64(0), + u256_from_u64(9_999), + [0x33; 32], + ); + + // Recipient and value especially: a hash insensitive to either would let a + // payment be redirected or resized after signing. + assert_ne!( + base, + transfer_with_authorization_hash( + [0x11; 20], + [0xaa; 20], + u256_from_u64(100), + u256_from_u64(0), + u256_from_u64(9_999), + [0x33; 32], + ) + ); + assert_ne!( + base, + transfer_with_authorization_hash( + [0x11; 20], + [0x22; 20], + u256_from_u64(101), + u256_from_u64(0), + u256_from_u64(9_999), + [0x33; 32], + ) + ); + assert_ne!( + base, + transfer_with_authorization_hash( + [0x11; 20], + [0x22; 20], + u256_from_u64(100), + u256_from_u64(0), + u256_from_u64(9_999), + [0x44; 32], + ) + ); +} + +#[test] +fn the_signing_digest_is_prefixed_so_it_cannot_be_a_transaction() { + // The 0x1901 prefix is the whole reason a typed-data signature cannot be + // replayed as a transaction signature. + let domain = [0x11u8; 32]; + let structure = [0x22u8; 32]; + + let mut preimage = vec![0x19, 0x01]; + preimage.extend_from_slice(&domain); + preimage.extend_from_slice(&structure); + + assert_eq!(signing_digest(domain, structure), keccak(&preimage)); + // And it must not be a bare hash of the concatenation. + assert_ne!( + signing_digest(domain, structure), + keccak(&[domain, structure].concat()) + ); +} + +#[test] +fn swapping_the_domain_and_struct_hashes_changes_the_digest() { + // Ordering inside the preimage is load-bearing and easy to get backwards. + let domain = [0x11u8; 32]; + let structure = [0x22u8; 32]; + assert_ne!( + signing_digest(domain, structure), + signing_digest(structure, domain) + ); +} diff --git a/src/key/bip32.rs b/src/key/bip32.rs index 33f9189..84e279d 100644 --- a/src/key/bip32.rs +++ b/src/key/bip32.rs @@ -3,24 +3,38 @@ //! All three chains use the same scheme and differ only in what they do with //! the resulting key, so the walk lives here once rather than three times. //! -//! Uses `bitcoin`'s `Xpriv`, which is a vetted BIP-32 implementation. Rolling -//! this by hand is possible — it is HMAC-SHA512 plus a scalar addition — but -//! an off-by-one in the hardened-index encoding produces a *valid key for the -//! wrong account*, which is silent, unrecoverable, and exactly the kind of bug -//! not worth risking to avoid a dependency the crate already has. +//! # This is delegated on purpose +//! +//! Rolling BIP-32 by hand is possible — it is HMAC-SHA512 plus a scalar +//! addition — but an off-by-one in the hardened-index encoding produces a +//! *valid key for the wrong account*, which is silent, unrecoverable, and +//! exactly the kind of bug not worth risking to avoid a dependency. +//! +//! That reasoning is unchanged from when this used `bitcoin`'s `Xpriv`. What +//! changed is which vetted implementation it delegates to: `coins-bip32`, whose +//! secp256k1 backend is the pure-Rust `k256` rather than the `secp256k1` C +//! library. The derived key is identical either way — BIP-32 is a specification, +//! not an implementation detail, and [`super::test`] pins the addresses against +//! the same fixed mnemonic as before the swap. `coins-bip32` is also the code +//! path `coins-bip39` already uses beneath [`super::seed_from_mnemonic`], so +//! this removes a native C build and a second elliptic-curve stack without +//! adding anything to the graph. +//! +//! Contrast [`crate::address::btc`], which *is* hand-rolled. The difference is +//! the failure mode, not the difficulty: a wrong parser is caught by the first +//! test vector, a wrong derivation is caught by nobody. use std::str::FromStr; -use bitcoin::Network; -use bitcoin::bip32::{DerivationPath, Xpriv}; -use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use coins_bip32::path::DerivationPath; +use coins_bip32::prelude::SigningKey; +use coins_bip32::xkeys::XPriv; use super::{Error, Result}; /// A secp256k1 key derived at a BIP-32 path. pub(super) struct Secp256k1Key { - pub(super) secret: SecretKey, - pub(super) public: PublicKey, + pub(super) secret: SigningKey, } impl Secp256k1Key { @@ -29,35 +43,72 @@ impl Secp256k1Key { /// EVM and Tron both hash this — minus the prefix byte — with Keccak-256 to /// form an address. pub(super) fn uncompressed_public(&self) -> [u8; 65] { - self.public.serialize_uncompressed() + let encoded = self.secret.verifying_key().to_encoded_point(false); + let mut out = [0u8; 65]; + // Uncompressed SEC1 is 65 bytes by definition, so this cannot be short. + out.copy_from_slice(encoded.as_bytes()); + out + } + + /// The 33-byte compressed SEC1 encoding. + /// + /// Bitcoin hashes this — not the uncompressed form — to form a P2WPKH + /// address. Using the wrong one yields a well-formed address for an account + /// nobody holds the key to, which is why the two encodings are separate + /// named methods rather than one with a boolean. + pub(super) fn compressed_public(&self) -> [u8; 33] { + let encoded = self.secret.verifying_key().to_encoded_point(true); + let mut out = [0u8; 33]; + out.copy_from_slice(encoded.as_bytes()); + out + } + + /// The 32-byte secret scalar. + pub(super) fn secret_bytes(&self) -> [u8; 32] { + self.secret.to_bytes().into() } } /// Walk `path` from the master key for `seed`. /// -/// `Network::Bitcoin` only selects the version bytes of the serialized -/// extended key, which is never serialized here — the derived secret is -/// identical on any network, so this is correct for EVM and Tron too. +/// The derived secret does not depend on a network: BIP-32 version bytes only +/// matter when an extended key is serialized, which never happens here. The +/// same walk is therefore correct for Bitcoin, EVM and Tron alike. pub(super) fn derive(seed: &[u8], path: &str) -> Result { - let master = map_derivation( - Xpriv::new_master(Network::Bitcoin, seed), - "BIP-32 master key", - )?; + let master = XPriv::root_from_seed(seed, None).map_err(|_| Error::Derivation { + step: "BIP-32 master key", + })?; let parsed = DerivationPath::from_str(path).map_err(|e| Error::InvalidPath { path: path.to_string(), reason: e.to_string(), })?; - let secp = Secp256k1::new(); - let child = map_derivation(master.derive_priv(&secp, &parsed), "BIP-32 child key")?; - let secret = child.private_key; - let public = secret.public_key(&secp); - Ok(Secp256k1Key { secret, public }) -} -/// Collapse BIP-32 implementation errors into the crate's stable error type. -pub(super) fn map_derivation( - result: std::result::Result, - step: &'static str, -) -> Result { - result.map_err(|_| Error::Derivation { step }) + // Depth is checked here rather than left to the backend, because + // `coins-bip32` does not check it: `derive_child` increments a `u8` depth + // unguarded, which panics in a debug build and **wraps silently in a + // release build** — deriving at a wrapped depth instead of refusing. The + // `bitcoin` implementation this replaced returned `MaximumDepthExceeded`, + // so without this the swap would have traded a clean error for a wrong key. + // + // The master node is depth 0, leaving 255 usable levels. No real path comes + // close; the bound exists so a hostile or generated one cannot get through. + if parsed.len() > usize::from(u8::MAX) { + return Err(Error::InvalidPath { + path: path.to_string(), + reason: format!( + "BIP-32 depth is limited to {} levels, got {}", + u8::MAX, + parsed.len() + ), + }); + } + + let child = master.derive_path(parsed).map_err(|_| Error::Derivation { + step: "BIP-32 child key", + })?; + + let secret: &SigningKey = child.as_ref(); + Ok(Secp256k1Key { + secret: secret.clone(), + }) } diff --git a/src/key/btc.rs b/src/key/btc.rs index 7e717c1..6972ff5 100644 --- a/src/key/btc.rs +++ b/src/key/btc.rs @@ -4,11 +4,20 @@ //! [`crate::address::btc::validate_sender`] — the only script type this crate's //! callers can sign for. Deriving a P2PKH or P2SH address here would hand back //! something that passes recipient validation and then fails at signing time. +//! +//! The address is assembled here rather than by the `bitcoin` crate, which this +//! module used to route through. A P2WPKH address is fully specified by BIP-141 +//! and BIP-173 as `bech32(hrp="bc", version=0, hash160(compressed_pubkey))`, and +//! both halves of that are owned elsewhere: the bech32 encoding by +//! [`crate::address::btc::encode_p2wpkh`], which also decodes it, and the +//! BIP-32 walk by [`super::bip32`], which still delegates to a vetted +//! implementation. -use bitcoin::key::{CompressedPublicKey, PrivateKey}; -use bitcoin::{Address, Network}; +use ripemd::Ripemd160; +use sha2::{Digest, Sha256}; use super::{DerivedKey, Error, Result, bip32, seed_from_mnemonic}; +use crate::address::btc::encode_p2wpkh; use crate::chain::Chain; /// Derive the Bitcoin signing key and P2WPKH address for `path`. @@ -16,25 +25,27 @@ pub(super) fn derive(mnemonic: &str, path: &str) -> Result { let seed = seed_from_mnemonic(mnemonic)?; let key = bip32::derive(&seed, path)?; - let private = PrivateKey::new(key.secret, Network::Bitcoin); - let compressed = map_compressed_public_key(CompressedPublicKey::from_private_key( - &bitcoin::secp256k1::Secp256k1::new(), - &private, - ))?; - let address = Address::p2wpkh(&compressed, Network::Bitcoin).to_string(); + // The *compressed* encoding: a P2WPKH witness program is defined over it, + // and hashing the uncompressed form instead produces a valid-looking + // address for an account holding no funds. + let address = + encode_p2wpkh(&hash160(&key.compressed_public())).map_err(|_| Error::Derivation { + step: "BTC P2WPKH address", + })?; Ok(DerivedKey::new( Chain::Btc, address, - key.secret.secret_bytes().to_vec(), + key.secret_bytes().to_vec(), )) } -/// Collapse an invalid compressed-key conversion into the crate's error type. -pub(super) fn map_compressed_public_key( - result: std::result::Result, -) -> Result { - result.map_err(|_| Error::Derivation { - step: "BTC compressed public key", - }) +/// `RIPEMD160(SHA256(data))` — Bitcoin's HASH160. +fn hash160(data: &[u8]) -> [u8; 20] { + let sha = Sha256::digest(data); + let ripemd = Ripemd160::digest(sha); + let mut out = [0u8; 20]; + // RIPEMD-160 is 20 bytes by definition. + out.copy_from_slice(&ripemd); + out } diff --git a/src/key/evm.rs b/src/key/evm.rs index 093e532..6b25cb2 100644 --- a/src/key/evm.rs +++ b/src/key/evm.rs @@ -13,7 +13,7 @@ pub(super) fn derive(mnemonic: &str, path: &str) -> Result { Ok(DerivedKey::new( Chain::Evm, address, - key.secret.secret_bytes().to_vec(), + key.secret_bytes().to_vec(), )) } diff --git a/src/key/test.rs b/src/key/test.rs index 945c33b..09cd20e 100644 --- a/src/key/test.rs +++ b/src/key/test.rs @@ -211,34 +211,47 @@ fn a_solana_path_with_a_non_numeric_segment_is_rejected() { #[test] fn derivation_backend_failures_remain_specific_without_leaking_inputs() { - let bip32 = super::bip32::map_derivation::<()>( - Err(bitcoin::bip32::Error::MaximumDepthExceeded), - "BIP-32 child key", - ) - .unwrap_err(); - assert_eq!( - bip32, - Error::Derivation { - step: "BIP-32 child key" - } - ); + // Drives the real derivation path rather than the backend's error mapper. + // The previous version of this test called two private helpers with a + // hand-built `bitcoin::bip32::Error`; both are gone, and one of them — + // the uncompressed-public-key mapper — no longer has a reachable failure + // mode at all, because the address is now encoded from the compressed + // SEC1 point directly. Asserting on behaviour instead means this test + // survives the next backend swap the way it did not survive this one. + // + // BIP-32 depth is a single byte, so a path past 255 levels cannot be + // walked. This must be a clean refusal: the `coins-bip32` backend + // increments its depth counter unguarded, so without tinywallet's own + // bound this input panics in debug and — far worse — silently wraps in + // release, deriving a real key at the wrong depth. + let too_deep = format!("m/{}", vec!["0"; 256].join("/")); + let error = derive(Chain::Btc, VECTOR, &too_deep).unwrap_err(); - let private = bitcoin::key::PrivateKey::new_uncompressed( - bitcoin::secp256k1::SecretKey::from_slice(&[1; 32]).unwrap(), - bitcoin::Network::Bitcoin, - ); - let compressed = - super::btc::map_compressed_public_key(bitcoin::key::CompressedPublicKey::from_private_key( - &bitcoin::secp256k1::Secp256k1::new(), - &private, - )) - .unwrap_err(); - assert_eq!( - compressed, - Error::Derivation { - step: "BTC compressed public key" + match &error { + Error::InvalidPath { path, reason } => { + assert_eq!(path, &too_deep); + assert!(reason.contains("255"), "{reason}"); } + other => panic!("expected InvalidPath for an over-deep path, got {other:?}"), + } + + // The depth just under the limit must still derive, so the bound is a + // guard rather than an off-by-one that rejects legitimate paths. + let deepest = format!("m/{}", vec!["0"; 255].join("/")); + assert!( + derive(Chain::Btc, VECTOR, &deepest).is_ok(), + "255 levels is the documented maximum and must still derive" ); + + // The whole point of collapsing backend errors into a fixed `step` string: + // the mnemonic and the path must not ride out inside the message. + let rendered = error.to_string(); + for secret in VECTOR.split_whitespace() { + assert!( + !rendered.contains(secret), + "derivation error leaked mnemonic word '{secret}': {rendered}" + ); + } } #[test] diff --git a/src/key/tron.rs b/src/key/tron.rs index 006b217..272e68c 100644 --- a/src/key/tron.rs +++ b/src/key/tron.rs @@ -20,7 +20,7 @@ pub(super) fn derive(mnemonic: &str, path: &str) -> Result { Ok(DerivedKey::new( Chain::Tron, address, - key.secret.secret_bytes().to_vec(), + key.secret_bytes().to_vec(), )) } diff --git a/src/lib.rs b/src/lib.rs index 34d3305..669f180 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ //! # Example //! //! ``` +//! # #[cfg(all(feature = "btc", feature = "tron"))] { //! use tinywallet::{address, chain::Chain}; //! //! // Chain-generic dispatch. @@ -29,6 +30,7 @@ //! // Or reach for a chain's own module when you need more than validation. //! let hex = address::tron::to_hex("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t")?; //! assert!(hex.starts_with("41")); +//! # } //! # Ok::<(), tinywallet::Error>(()) //! ``` //! @@ -53,18 +55,24 @@ mod error; +#[cfg(feature = "abi")] +pub mod abi; pub mod address; #[cfg(feature = "asset")] pub mod asset; pub mod chain; #[cfg(feature = "client")] pub mod client; +#[cfg(feature = "eip712")] +pub mod eip712; #[cfg(feature = "key")] pub mod key; #[cfg(feature = "net")] pub mod rpc; #[cfg(feature = "tx")] pub mod tx; +#[cfg(feature = "wire")] +pub mod wire; #[cfg(feature = "x402")] pub mod x402; diff --git a/src/tx/btc.rs b/src/tx/btc.rs index cb12c7d..022cc87 100644 --- a/src/tx/btc.rs +++ b/src/tx/btc.rs @@ -218,13 +218,51 @@ impl Transfer { }); } + let compressed = public.to_bytes(); + let signatures = self + .sighashes(utxos, &compressed)? + .1 + .into_iter() + .map(|sighash| { + let message = Message::from_digest(sighash); + secp.sign_ecdsa(&message, &private.inner) + .serialize_compact() + }) + .collect::>(); + + // Routed through the same reassembly the split-signing path uses, so + // witness layout and input ordering have exactly one implementation. + self.attach_signatures(utxos, &compressed, &signatures) + } + + /// The per-input digests to sign, without needing the key. + /// + /// Returns the coin selection alongside them because the caller needs to + /// know how many signatures to produce and in which order: **one per + /// selected input, in input order**. Bitcoin is the only chain here that + /// needs more than one signature for a single transaction. + /// + /// Each digest is a BIP-143 P2WPKH sighash, already hashed — sign it with a + /// "prehash" entry point. + /// + /// # Errors + /// + /// As [`Transfer::build`], plus [`Error::Signing`] if `public_key` does not + /// control `from`. + pub fn sighashes( + &self, + utxos: &[Utxo], + public_key: &[u8; 33], + ) -> Result<(Selection, Vec<[u8; 32]>)> { + self.check_controls_from(public_key)?; + let (mut tx, selection) = self.build(utxos)?; let from_spk = script_pubkey( &crate::address::btc::validate_sender(&self.from).map_err(Error::Address)?, )?; let mut cache = SighashCache::new(&mut tx); - let mut witnesses = Vec::with_capacity(selection.inputs.len()); + let mut digests = Vec::with_capacity(selection.inputs.len()); for (index, utxo) in selection.inputs.iter().enumerate() { // BIP-143 commits to this input's value — see the module docs. let sighash = cache @@ -237,22 +275,80 @@ impl Transfer { .map_err(|e| Error::Signing { reason: format!("sighash failed: {e}"), })?; - let message = Message::from_digest(sighash.to_byte_array()); - let signature = secp.sign_ecdsa(&message, &private.inner); + digests.push(sighash.to_byte_array()); + } + Ok((selection, digests)) + } + + /// Assemble the raw transaction from signatures over [`Self::sighashes`]. + /// + /// `signatures` must hold one 64-byte compact signature per selected input, + /// in the same order [`Self::sighashes`] returned the digests. + /// + /// # Errors + /// + /// As [`Transfer::build`], plus [`Error::Signing`] if `public_key` does not + /// control `from`, if the signature count does not match the input count, + /// or if a signature is not a valid secp256k1 `(r, s)` pair. + pub fn attach_signatures( + &self, + utxos: &[Utxo], + public_key: &[u8; 33], + signatures: &[[u8; 64]], + ) -> Result { + self.check_controls_from(public_key)?; + + let (mut tx, selection) = self.build(utxos)?; + if signatures.len() != selection.inputs.len() { + return Err(Error::Signing { + reason: format!( + "expected {} signatures for {} inputs, got {}", + selection.inputs.len(), + selection.inputs.len(), + signatures.len() + ), + }); + } + + for (input, compact) in tx.input.iter_mut().zip(signatures) { + let mut signature = bitcoin::secp256k1::ecdsa::Signature::from_compact(compact) + .map_err(|_| Error::Signing { + reason: "signature is not a valid secp256k1 (r, s) pair".to_string(), + })?; + // Bitcoin enforces low-`s` as a relay policy rule (BIP-146), so a + // high-`s` signature yields a transaction nodes refuse to relay. + // Normalizing here means a caller that signed with a library which + // does not normalize still produces a broadcastable transaction, + // and one that does is unaffected — the operation is idempotent. + signature.normalize_s(); let mut witness = Witness::new(); let mut der = signature.serialize_der().to_vec(); der.push(EcdsaSighashType::All as u8); witness.push(der); - witness.push(public.to_bytes()); - witnesses.push(witness); - } - for (input, witness) in tx.input.iter_mut().zip(witnesses) { + witness.push(public_key); input.witness = witness; } Ok(bitcoin::consensus::encode::serialize_hex(&tx)) } + + /// Refuse early if `public_key` does not control `from`. + /// + /// Caught here rather than after broadcasting an unspendable transaction — + /// which is unrecoverable, because the fee is paid either way. + fn check_controls_from(&self, public_key: &[u8; 33]) -> Result<()> { + let public = CompressedPublicKey::from_slice(public_key).map_err(|_| Error::Signing { + reason: "not a valid compressed secp256k1 public key".to_string(), + })?; + let derived = Address::p2wpkh(&public, Network::Bitcoin).to_string(); + if derived != self.from.trim() { + return Err(Error::Signing { + reason: "public key does not control the `from` address".to_string(), + }); + } + Ok(()) + } } /// The scriptPubKey that pays to `address`. @@ -476,4 +572,81 @@ mod test { let b = t.sign(&[utxo(60_000, 0)], &key()).unwrap(); assert_ne!(a, b, "the input value must reach the sighash"); } + + /// The compressed public key for the test mnemonic's P2WPKH account. + fn public_key() -> [u8; 33] { + use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + let secret = SecretKey::from_slice(&key()).unwrap(); + PublicKey::from_secret_key(&Secp256k1::new(), &secret).serialize() + } + + #[test] + fn split_signing_matches_one_shot_signing_across_several_inputs() { + // Several inputs on purpose: Bitcoin is the only chain here needing + // more than one signature, and the split contract is that they come + // back in input order. A transposition would still produce a + // well-formed transaction — just an unspendable one — so the two + // paths are compared byte-for-byte. + use bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; + + let utxos = [utxo(60_000, 0), utxo(70_000, 1), utxo(80_000, 2)]; + let transfer = transfer(150_000, 2_000); + let public = public_key(); + + let one_shot = transfer.sign(&utxos, &key()).unwrap(); + + let (selection, digests) = transfer.sighashes(&utxos, &public).unwrap(); + assert!( + digests.len() > 1, + "the fixture must actually select several inputs" + ); + assert_eq!(digests.len(), selection.inputs.len()); + + let secret = SecretKey::from_slice(&key()).unwrap(); + let secp = Secp256k1::signing_only(); + let signatures: Vec<[u8; 64]> = digests + .into_iter() + .map(|digest| { + secp.sign_ecdsa(&Message::from_digest(digest), &secret) + .serialize_compact() + }) + .collect(); + + let split = transfer + .attach_signatures(&utxos, &public, &signatures) + .unwrap(); + + assert_eq!(split, one_shot); + } + + #[test] + fn a_public_key_that_does_not_control_the_sender_is_refused() { + // Both halves must refuse, not just the first: a host could call + // `attach_signatures` without ever calling `sighashes`. + let utxos = [utxo(100_000, 0)]; + let transfer = transfer(50_000, 1_000); + let wrong = [0x02u8; 33]; + + assert!(matches!( + transfer.sighashes(&utxos, &wrong), + Err(Error::Signing { .. }) + )); + assert!(matches!( + transfer.attach_signatures(&utxos, &wrong, &[[0u8; 64]]), + Err(Error::Signing { .. }) + )); + } + + #[test] + fn a_signature_count_that_does_not_match_the_inputs_is_refused() { + // Silently zipping would leave later inputs with an empty witness and + // broadcast an unspendable transaction, paying the fee for nothing. + let utxos = [utxo(60_000, 0), utxo(70_000, 1), utxo(80_000, 2)]; + let transfer = transfer(150_000, 2_000); + + let error = transfer + .attach_signatures(&utxos, &public_key(), &[[0x11; 64]]) + .unwrap_err(); + assert!(matches!(error, Error::Signing { .. }), "{error:?}"); + } } diff --git a/src/tx/evm.rs b/src/tx/evm.rs index eade877..52c51ff 100644 --- a/src/tx/evm.rs +++ b/src/tx/evm.rs @@ -108,8 +108,7 @@ impl LegacyTransaction { reason: "not a valid secp256k1 secret key".to_string(), })?; - let digest = Keccak256::digest(self.signing_payload()?); - let message = Message::from_digest(digest.into()); + let message = Message::from_digest(self.digest()?); let secp = Secp256k1::signing_only(); // Recoverable, because an Ethereum signature carries the recovery id @@ -117,15 +116,53 @@ impl LegacyTransaction { let signature = secp.sign_ecdsa_recoverable(&message, &secret); let (recovery_id, bytes) = signature.serialize_compact(); - // The second half of EIP-155: v = recovery + chain_id * 2 + 35. - // `RecoveryId` is 0..=3, so the conversion cannot lose information. - let recovery = recovery_as_u64(recovery_id.to_i32())?; + // Deliberately routed through the same reassembly the split-signing + // path uses. Two copies of the EIP-155 `v` computation and the RLP + // field order would be free to drift, and the failure mode of that + // drift is a perfectly valid signature over a transaction other than + // the one the caller asked for. + let recovery = u8::try_from(recovery_id.to_i32()).map_err(|_| Error::Signing { + reason: "negative recovery id".to_string(), + })?; + self.attach_signature(&bytes, recovery) + } + + /// The 32-byte digest to sign, for a caller that holds the key elsewhere. + /// + /// Keccak-256 of [`Self::signing_payload`]. Already hashed: sign it with a + /// "prehash" entry point, never by hashing again. + /// + /// # Errors + /// + /// [`Error::Address`] if `to` is invalid. + pub fn digest(&self) -> Result<[u8; 32]> { + Ok(Keccak256::digest(self.signing_payload()?).into()) + } + + /// Reassemble the raw transaction from a signature over [`Self::digest`]. + /// + /// The half of [`Self::sign`] that follows signing, exposed so the key can + /// live somewhere this crate does not. `rs` is the 64-byte compact + /// signature and `recovery_id` its 0..=3 recovery id. + /// + /// # Errors + /// + /// - [`Error::Address`] if `to` is invalid. + /// - [`Error::Signing`] if `recovery_id` is out of range. + /// - [`Error::InvalidField`] if `chain_id` overflows the EIP-155 `v`. + pub fn attach_signature(&self, rs: &[u8; 64], recovery_id: u8) -> Result> { + if recovery_id > 3 { + return Err(Error::Signing { + reason: format!("recovery id must be 0..=3, got {recovery_id}"), + }); + } + let recovery = recovery_as_u64(i32::from(recovery_id))?; let v = checked_v(recovery, self.chain_id)?; let mut items = self.base_items()?; items.push(rlp::encode_uint(u128::from(v))); - items.push(rlp::encode_uint_bytes(&bytes[..32])); - items.push(rlp::encode_uint_bytes(&bytes[32..])); + items.push(rlp::encode_uint_bytes(&rs[..32])); + items.push(rlp::encode_uint_bytes(&rs[32..])); Ok(rlp::encode_list(&items)) } diff --git a/src/tx/solana.rs b/src/tx/solana.rs index 98c7c4d..bce9f14 100644 --- a/src/tx/solana.rs +++ b/src/tx/solana.rs @@ -129,10 +129,25 @@ impl NativeTransfer { let message = self.message()?; let signature = signing.sign(&message); + // Shares the assembly below rather than repeating it: see the note on + // the EVM path for why a second copy of a wire encoding is a hazard. + self.attach_signature(&signature.to_bytes()) + } + /// Assemble the wire transaction from a signature over [`Self::message`]. + /// + /// For a caller that holds the ed25519 key elsewhere. Note the signature is + /// over the **whole message**, not a digest — ed25519 hashes internally, so + /// there is nothing to pre-hash and a caller must not. + /// + /// # Errors + /// + /// As [`NativeTransfer::message`]. + pub fn attach_signature(&self, signature: &[u8; 64]) -> Result> { + let message = self.message()?; let mut out = Vec::with_capacity(1 + 64 + message.len()); out.extend(encode_shortvec(1)); - out.extend_from_slice(&signature.to_bytes()); + out.extend_from_slice(signature); out.extend_from_slice(&message); Ok(out) } @@ -332,4 +347,36 @@ mod test { transfer().sign(&key()).unwrap() ); } + + #[test] + fn split_signing_matches_one_shot_signing() { + // The host holds the ed25519 key and signs the message; this crate + // assembles. Both paths must produce identical wire bytes, or the + // split has silently changed what gets broadcast. + use ed25519_dalek::{Signer as _, SigningKey}; + + let transfer = transfer(); + let secret = key(); + let one_shot = transfer.sign(&secret).unwrap(); + + let bytes: [u8; 32] = secret.as_slice().try_into().unwrap(); + let signing = SigningKey::from_bytes(&bytes); + let signature = signing.sign(&transfer.message().unwrap()).to_bytes(); + let split = transfer.attach_signature(&signature).unwrap(); + + assert_eq!(split, one_shot); + } + + #[test] + fn the_signed_payload_is_the_message_itself_not_a_digest() { + // ed25519 hashes internally. A host that pre-hashes the message and + // signs the digest produces a signature the network rejects, so the + // distinction is worth pinning. + let transfer = transfer(); + let message = transfer.message().unwrap(); + assert!( + message.len() > 32, + "a Solana message is the full serialized transaction, not a 32-byte digest" + ); + } } diff --git a/src/tx/test.rs b/src/tx/test.rs index ba2b18a..178040b 100644 --- a/src/tx/test.rs +++ b/src/tx/test.rs @@ -289,3 +289,92 @@ fn results_propagate_as_the_module_result_type() { } assert!(build().is_ok()); } + +// --------------------------------------------------------------------------- +// Split signing: build here, sign elsewhere, reassemble here. +// +// The whole point of the split path is that a host can hold the key while this +// crate holds the format knowledge. That is only safe if the two paths agree +// byte-for-byte, so each test below signs the same transaction both ways and +// compares the raw result. An equivalence test is the right shape here: a +// wrong split would still produce a well-formed signed transaction, so nothing +// short of comparing against the known-good path would notice. +// --------------------------------------------------------------------------- + +/// Sign a prehashed digest the way a host would, returning `(r||s, recovery)`. +/// +/// Deliberately uses the recoverable API directly rather than any helper from +/// this crate, so the test exercises the same boundary a real host does. +fn host_sign_secp256k1(digest: [u8; 32], key: &[u8; 32]) -> ([u8; 64], u8) { + use bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; + let secret = SecretKey::from_slice(key).unwrap(); + let secp = Secp256k1::signing_only(); + let recoverable = secp.sign_ecdsa_recoverable(&Message::from_digest(digest), &secret); + let (recovery_id, compact) = recoverable.serialize_compact(); + (compact, u8::try_from(recovery_id.to_i32()).unwrap()) +} + +#[test] +fn evm_split_signing_matches_one_shot_signing() { + let tx = eip155_vector(); + + let one_shot = tx.sign(&VECTOR_KEY).unwrap(); + + let (rs, recovery) = host_sign_secp256k1(tx.digest().unwrap(), &VECTOR_KEY); + let split = tx.attach_signature(&rs, recovery).unwrap(); + + assert_eq!(hex(&split), hex(&one_shot)); +} + +#[test] +fn the_evm_digest_is_the_keccak_of_the_signing_payload_not_the_payload() { + // Guards the most likely misuse: a host that signs `signing_payload()` + // directly, or hashes `digest()` a second time, produces a valid signature + // over the wrong thing. + use sha3::{Digest as _, Keccak256}; + let tx = eip155_vector(); + let expected: [u8; 32] = Keccak256::digest(tx.signing_payload().unwrap()).into(); + assert_eq!(tx.digest().unwrap(), expected); + assert_ne!(tx.digest().unwrap().to_vec(), tx.signing_payload().unwrap()); +} + +#[test] +fn an_out_of_range_evm_recovery_id_is_refused() { + let tx = eip155_vector(); + let error = tx.attach_signature(&[0x11; 64], 4).unwrap_err(); + assert!(matches!(error, Error::Signing { .. }), "{error:?}"); +} + +#[test] +fn tron_split_signing_matches_one_shot_signing() { + // A `raw_data` blob is opaque to this crate — it only ever hashes it — so + // an arbitrary well-formed hex string exercises the path faithfully. + let raw = "0a02b1f12208".to_string() + &"ab".repeat(64); + + let one_shot = super::tron::sign(&raw, &VECTOR_KEY).unwrap(); + + let (rs, recovery) = host_sign_secp256k1(super::tron::digest(&raw).unwrap(), &VECTOR_KEY); + let split = super::tron::attach_signature(&rs, recovery).unwrap(); + + assert_eq!( + super::tron::signature_hex(&split), + super::tron::signature_hex(&one_shot) + ); +} + +#[test] +fn the_tron_digest_equals_its_recomputed_txid() { + // The two are the same value by construction, which is what lets a caller + // verify the node's `txID` against the bytes it is about to sign. + let raw = "0a02b1f12208".to_string() + &"cd".repeat(64); + assert_eq!( + hex(&super::tron::digest(&raw).unwrap()), + super::tron::recompute_txid(&raw).unwrap() + ); +} + +#[test] +fn an_out_of_range_tron_recovery_id_is_refused() { + let error = super::tron::attach_signature(&[0x11; 64], 9).unwrap_err(); + assert!(matches!(error, Error::Signing { .. }), "{error:?}"); +} diff --git a/src/tx/tron.rs b/src/tx/tron.rs index 287e58a..7a9767e 100644 --- a/src/tx/tron.rs +++ b/src/tx/tron.rs @@ -89,20 +89,49 @@ pub fn sign(raw_data_hex: &str, secret_key: &[u8]) -> Result { let secret = SecretKey::from_slice(secret_key).map_err(|_| Error::Signing { reason: "not a valid secp256k1 secret key".to_string(), })?; - let raw = decode_hex(raw_data_hex)?; - let digest: [u8; 32] = Sha256::digest(&raw).into(); - let message = Message::from_digest(digest); + let message = Message::from_digest(digest(raw_data_hex)?); let secp = Secp256k1::signing_only(); let recoverable = secp.sign_ecdsa_recoverable(&message, &secret); let (recovery_id, compact) = recoverable.serialize_compact(); - let mut out = [0u8; 65]; - out[..64].copy_from_slice(&compact); - // A bare recovery id, not EIP-155's v. - out[64] = u8::try_from(recovery_id.to_i32()).map_err(|_| Error::Signing { + let recovery = u8::try_from(recovery_id.to_i32()).map_err(|_| Error::Signing { reason: "unexpected recovery id".to_string(), })?; + attach_signature(&compact, recovery) +} + +/// The 32-byte digest a Tron transaction is signed over. +/// +/// `sha256(raw_data)` — the same value as the `txID`, which is what makes +/// [`recompute_txid`] a meaningful check on the bytes about to be signed. +/// +/// Already hashed: a caller holding the key elsewhere must sign this with a +/// "prehash" entry point rather than hashing it again. +/// +/// # Errors +/// +/// [`Error::InvalidField`] if `raw_data_hex` is not valid hex. +pub fn digest(raw_data_hex: &str) -> Result<[u8; 32]> { + let raw = decode_hex(raw_data_hex)?; + Ok(Sha256::digest(&raw).into()) +} + +/// Build the 65-byte Tron signature from a signature over [`digest`]. +/// +/// # Errors +/// +/// [`Error::Signing`] if `recovery_id` is not 0..=3. +pub fn attach_signature(rs: &[u8; 64], recovery_id: u8) -> Result { + if recovery_id > 3 { + return Err(Error::Signing { + reason: format!("recovery id must be 0..=3, got {recovery_id}"), + }); + } + let mut out = [0u8; 65]; + out[..64].copy_from_slice(rs); + // A bare recovery id, not EIP-155's v. + out[64] = recovery_id; Ok(out) } diff --git a/src/wire/mod.rs b/src/wire/mod.rs new file mode 100644 index 0000000..77f6946 --- /dev/null +++ b/src/wire/mod.rs @@ -0,0 +1,250 @@ +//! The wire contract between a host and a signing backend. +//! +//! # Why this module exists, and why it has no dependencies +//! +//! A host can run this crate's transaction building in-process, or it can run +//! it somewhere else — most usefully in a loadable module, so the chain +//! libraries that building requires (`bitcoin` and its native `secp256k1` +//! build, above all) are absent from the host binary entirely. +//! +//! For that second arrangement both sides must agree on a set of types, and +//! **only the host side may be free of the heavy dependencies**. So these types +//! live outside every format gate and pull in nothing but `serde`: a host can +//! take this crate with `default-features = false`, get the whole contract, and +//! still not link a single chain library. It is the same carve-out +//! `tinydocs::spec` makes for documents. +//! +//! # The split: building is not signing +//! +//! Every type here exists to serve one rule — **key material never crosses this +//! boundary**. A backend receives transaction fields and returns the bytes that +//! need signing; the host signs them; the backend reassembles. Two round trips +//! instead of one, in exchange for a private key that never leaves the process +//! that owns it. +//! +//! That constraint is what shapes the API. A [`SigningRequest`] carries no +//! secret, and an [`AttachRequest`] carries the original fields **again** +//! alongside the signatures, rather than a handle to something the backend +//! remembered. A backend holding half-built transactions between calls would +//! need a store, bounds on that store, and an expiry policy for callers that +//! never come back — all of which is avoided by rebuilding. Building is +//! deterministic, so rebuilding from the same fields yields the same +//! transaction the digests were computed over. +//! +//! # Signature shapes +//! +//! Three of the four chains sign a 32-byte digest with secp256k1 ECDSA and need +//! the recovery id; Solana signs the message itself with ed25519 and does not. +//! [`Signature`] is an enum over exactly those two cases rather than a bag of +//! bytes, so a host cannot hand back an ed25519 signature for an EVM +//! transaction and have it fail somewhere deep in reassembly. + +use serde::{Deserialize, Serialize}; + +use crate::chain::Chain; + +/// Bytes a host must sign, and how. +/// +/// For secp256k1 chains this is a 32-byte digest that is signed directly — +/// **already hashed**, so a host must use a "sign prehash" entry point and must +/// not hash it again. For Solana it is the full serialized message, because +/// ed25519 hashes internally as part of signing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SigningPayload { + /// Lowercase hex of the bytes to sign. + pub bytes_hex: String, + /// Which signing scheme these bytes expect. + pub scheme: Scheme, +} + +/// How a [`SigningPayload`] must be signed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum Scheme { + /// secp256k1 ECDSA over an already-computed 32-byte digest, low-`s` + /// normalized, with the recovery id retained. + /// + /// Low-`s` is not optional: Bitcoin enforces it as a relay policy rule + /// (BIP-146) and Ethereum as a consensus rule (EIP-2), so a high-`s` + /// signature produces a transaction that is rejected rather than one that + /// merely looks different. Both `k256` and `secp256k1` normalize by + /// default; a host that implements signing itself must not skip it. + Secp256k1Prehash, + /// ed25519 over the full message, which the scheme hashes itself. + Ed25519, +} + +/// A signature handed back to a backend for reassembly. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "scheme", rename_all = "snake_case")] +#[non_exhaustive] +pub enum Signature { + /// secp256k1 ECDSA: 32-byte `r`, 32-byte `s`, and the recovery id. + Secp256k1 { + /// Lowercase hex of `r || s`, exactly 64 bytes. + rs_hex: String, + /// Recovery id, 0..=3. + /// + /// Carried even for Bitcoin, which does not use it, so one variant + /// serves all three secp256k1 chains. EVM folds it into EIP-155 `v` + /// and Tron appends it directly. + recovery_id: u8, + }, + /// ed25519: the 64-byte signature. + Ed25519 { + /// Lowercase hex of the signature, exactly 64 bytes. + signature_hex: String, + }, +} + +/// The public key controlling the account a transaction spends from. +/// +/// Public by definition, so unlike the secret it may cross the boundary freely. +/// A backend needs it for two things: Bitcoin puts it in the witness, and every +/// chain uses it to check that the key the host is about to sign with actually +/// controls the `from` address — a mismatch that would otherwise surface as an +/// unspendable broadcast transaction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PublicKey { + /// Lowercase hex. Compressed SEC1 (33 bytes) for secp256k1 chains, the + /// 32-byte public key for ed25519. + pub key_hex: String, +} + +/// Ask a backend what needs signing for a transaction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SigningRequest { + /// Which chain's rules apply. + pub chain: Chain, + /// The transaction fields, in the shape that chain's builder expects. + pub transaction: TransactionSpec, + /// The public key that will sign. + pub public_key: PublicKey, +} + +/// Hand signatures back so a backend can assemble the final transaction. +/// +/// Carries `transaction` again rather than a handle: see the module docs on why +/// a backend deliberately keeps no state between the two calls. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AttachRequest { + /// Which chain's rules apply. + pub chain: Chain, + /// The same fields passed to the matching [`SigningRequest`]. + pub transaction: TransactionSpec, + /// The public key that signed. + pub public_key: PublicKey, + /// One signature per [`SigningPayload`] returned, in the same order. + /// + /// Bitcoin needs one per selected input; the other three need exactly one. + pub signatures: Vec, +} + +/// What a backend answers a [`SigningRequest`] with. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UnsignedTransaction { + /// Everything that needs a signature, in the order the signatures must be + /// returned. + pub payloads: Vec, +} + +/// What a backend answers an [`AttachRequest`] with. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SignedTransaction { + /// The broadcast-ready transaction, in whatever encoding the chain's RPC + /// expects: hex for Bitcoin, EVM and Tron, base64 for Solana. + pub raw: String, + /// The transaction id or hash a node will report, when the chain lets it be + /// computed locally. + pub txid: Option, +} + +/// A transaction to build, per chain. +/// +/// One enum rather than four methods so a host holds a single value and the +/// chain tag cannot disagree with the fields — the mismatch a pair of parallel +/// arguments would allow. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +#[non_exhaustive] +pub enum TransactionSpec { + /// A Bitcoin P2WPKH spend. + Btc { + /// Sender address; must be P2WPKH. + from: String, + /// Recipient address; any mainnet type. + to: String, + /// Amount in satoshis. + amount_sat: u64, + /// Fee rate in satoshis per virtual byte. + fee_rate_sat_vb: u64, + /// Every spendable output held by `from`. + utxos: Vec, + }, + /// An EVM legacy transaction. + Evm { + /// Recipient — the token contract for an ERC-20 transfer. + to: String, + /// Value in wei. + value_wei: String, + /// Call data, `0x`-prefixed hex. Empty for a native transfer. + data_hex: String, + /// Sender nonce. + nonce: u64, + /// Gas limit. + gas_limit: u64, + /// Gas price in wei. + gas_price_wei: String, + /// EIP-155 chain id. + chain_id: u64, + }, + /// A Solana native SOL transfer. + Solana { + /// Sender address. + from: String, + /// Recipient address. + to: String, + /// Amount in lamports. + lamports: u64, + /// A recent blockhash, base58. + recent_blockhash: String, + }, + /// A Tron transfer, already assembled by the node. + /// + /// Tron is the odd one out: `createtransaction` builds the transaction + /// server-side and returns it, so there is nothing for this crate to build + /// — only a payload to verify and sign. The verification is the point, and + /// it is why the recipient and amount are carried alongside: a node that + /// returned a transaction paying somebody else would otherwise be signed + /// without complaint. + Tron { + /// The node's `raw_data_hex`. + raw_data_hex: String, + /// The recipient the caller intended, base58check. + expected_to: String, + /// The txid the node reported, to be recomputed and compared. + expected_txid: String, + }, +} + +/// One spendable Bitcoin output. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Utxo { + /// Transaction id holding this output. + pub txid: String, + /// Output index within that transaction. + pub vout: u32, + /// Value in satoshis. + pub value: u64, +} + +#[cfg(test)] +mod test; diff --git a/src/wire/test.rs b/src/wire/test.rs new file mode 100644 index 0000000..11b82fd --- /dev/null +++ b/src/wire/test.rs @@ -0,0 +1,181 @@ +//! Tests for the host/backend wire contract. +//! +//! These are contract tests, not logic tests: the module holds no behaviour. +//! What can break here is compatibility — a field renamed, a tag changed, an +//! enum representation altered — and each of those breaks a host and a backend +//! that were built from different revisions, at runtime, with a deserialization +//! error rather than a compile failure. So the shapes are pinned literally. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::json; + +use super::{ + AttachRequest, PublicKey, Scheme, Signature, SignedTransaction, SigningPayload, SigningRequest, + TransactionSpec, UnsignedTransaction, Utxo, +}; +use crate::chain::Chain; + +#[test] +fn a_signing_request_round_trips_through_json() { + let request = SigningRequest { + chain: Chain::Evm, + transaction: TransactionSpec::Evm { + to: "0x1111111111111111111111111111111111111111".to_string(), + value_wei: "1000".to_string(), + data_hex: "0x".to_string(), + nonce: 7, + gas_limit: 21_000, + gas_price_wei: "20000000000".to_string(), + chain_id: 1, + }, + public_key: PublicKey { + key_hex: "02".repeat(33), + }, + }; + + let encoded = serde_json::to_string(&request).unwrap(); + let decoded: SigningRequest = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded, request); +} + +#[test] +fn the_transaction_spec_tag_is_the_published_one() { + // A host and a backend from different revisions meet here. The tag and the + // field names are the contract, so they are asserted against literals + // rather than against a re-serialization of the same value, which would + // agree with itself no matter what it was renamed to. + let spec = TransactionSpec::Solana { + from: "11111111111111111111111111111112".to_string(), + to: "11111111111111111111111111111113".to_string(), + lamports: 5, + recent_blockhash: "11111111111111111111111111111114".to_string(), + }; + assert_eq!( + serde_json::to_value(&spec).unwrap(), + json!({ + "kind": "solana", + "from": "11111111111111111111111111111112", + "to": "11111111111111111111111111111113", + "lamports": 5, + "recent_blockhash": "11111111111111111111111111111114", + }) + ); +} + +#[test] +fn a_signature_is_tagged_by_its_scheme() { + assert_eq!( + serde_json::to_value(Signature::Secp256k1 { + rs_hex: "ab".repeat(64), + recovery_id: 1, + }) + .unwrap(), + json!({ "scheme": "secp256k1", "rs_hex": "ab".repeat(64), "recovery_id": 1 }) + ); + assert_eq!( + serde_json::to_value(Signature::Ed25519 { + signature_hex: "cd".repeat(64), + }) + .unwrap(), + json!({ "scheme": "ed25519", "signature_hex": "cd".repeat(64) }) + ); +} + +#[test] +fn an_ed25519_signature_cannot_deserialize_as_a_secp256k1_one() { + // The enum is tagged precisely so a host cannot return the wrong scheme's + // signature and have it fail deep inside reassembly instead of at the + // boundary. + let ed = json!({ "scheme": "ed25519", "signature_hex": "cd".repeat(64) }); + let decoded: Signature = serde_json::from_value(ed).unwrap(); + assert!(matches!(decoded, Signature::Ed25519 { .. })); +} + +#[test] +fn unknown_fields_are_refused_rather_than_ignored() { + // A backend newer than its host would otherwise silently drop a field it + // was told about, which for a transaction means signing something other + // than what was asked for. + let with_extra = json!({ + "txid": "aa".repeat(32), + "vout": 0, + "value": 1000, + "surprise": true, + }); + assert!(serde_json::from_value::(with_extra).is_err()); +} + +#[test] +fn the_signing_scheme_names_are_stable() { + assert_eq!( + serde_json::to_value(Scheme::Secp256k1Prehash).unwrap(), + json!("secp256k1_prehash") + ); + assert_eq!( + serde_json::to_value(Scheme::Ed25519).unwrap(), + json!("ed25519") + ); +} + +#[test] +fn an_attach_request_carries_one_signature_per_payload() { + // Not a rule the type can enforce, but the pairing is the contract: the + // Bitcoin path returns one payload per selected input and expects them + // back in the same order. + let unsigned = UnsignedTransaction { + payloads: vec![ + SigningPayload { + bytes_hex: "11".repeat(32), + scheme: Scheme::Secp256k1Prehash, + }, + SigningPayload { + bytes_hex: "22".repeat(32), + scheme: Scheme::Secp256k1Prehash, + }, + ], + }; + let attach = AttachRequest { + chain: Chain::Btc, + transaction: TransactionSpec::Btc { + from: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string(), + to: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string(), + amount_sat: 1_000, + fee_rate_sat_vb: 5, + utxos: vec![], + }, + public_key: PublicKey { + key_hex: "02".repeat(33), + }, + signatures: vec![ + Signature::Secp256k1 { + rs_hex: "ab".repeat(64), + recovery_id: 0, + }, + Signature::Secp256k1 { + rs_hex: "cd".repeat(64), + recovery_id: 1, + }, + ], + }; + assert_eq!(attach.signatures.len(), unsigned.payloads.len()); + + let encoded = serde_json::to_string(&attach).unwrap(); + assert_eq!( + serde_json::from_str::(&encoded).unwrap(), + attach + ); +} + +#[test] +fn a_signed_transaction_may_omit_a_locally_unknowable_txid() { + let signed = SignedTransaction { + raw: "0xdeadbeef".to_string(), + txid: None, + }; + let encoded = serde_json::to_string(&signed).unwrap(); + assert_eq!( + serde_json::from_str::(&encoded).unwrap(), + signed + ); +} diff --git a/vendor/tinybus b/vendor/tinybus index ddc63e3..6ca0b0b 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit ddc63e3f9c6e99e0be4ef0effac4d35442711cc4 +Subproject commit 6ca0b0b6739a49396e36be21d450f07cf85b9de2