diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index df7636f..ab4e8ec 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -8,11 +8,17 @@ on: type: boolean default: false +env: + RUST_TOOLCHAIN: nightly-2025-10-23 + NODE_VERSION: 20 + NPM_VERSION: 11.5.1 + WASM_PACK_VERSION: 0.13.1 + WASM_OPT_VERSION: 0.116.1 + jobs: - run: - name: "Test" + build: + name: "Build" runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 with: @@ -21,28 +27,27 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 with: - toolchain: nightly-2025-10-23 + toolchain: ${{ env.RUST_TOOLCHAIN }} + components: rustfmt, clippy - name: Cache Rust dependencies uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 with: - workspaces: "packages/wasm-utxo" + workspaces: | + packages/wasm-utxo + packages/wasm-bip32 cache-on-failure: true - - name: Setup node 20 + - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 20 - - - name: Ensure npm 11.5.1 - run: | - npm install -g npm@11.5.1 + node-version: ${{ env.NODE_VERSION }} - - name: Install wasm tools + - name: Install npm and wasm tools run: | - rustup component add rustfmt - cargo install wasm-pack --version 0.13.1 - cargo install wasm-opt --version 0.116.1 + npm install -g npm@${{ env.NPM_VERSION }} + cargo install wasm-pack --version ${{ env.WASM_PACK_VERSION }} + cargo install wasm-opt --version ${{ env.WASM_OPT_VERSION }} cargo install cargo-deny --locked - name: Build Info @@ -53,14 +58,10 @@ jobs: echo "wasm-pack $(wasm-pack --version)" echo "wasm-opt $(wasm-opt --version)" echo "cargo-deny $(cargo deny --version)" - git --version - echo "base ref $GITHUB_BASE_REF" - echo "head ref $GITHUB_HEAD_REF" - name: Fetch Base Ref if: github.event_name == 'pull_request' - run: | - git fetch origin $GITHUB_BASE_REF + run: git fetch origin $GITHUB_BASE_REF - name: Install Packages run: npm ci --workspaces --include-workspace-root @@ -69,32 +70,129 @@ jobs: run: cargo deny check working-directory: packages/wasm-utxo - - name: build packages + - name: Build packages run: npm --workspaces run build - name: Check Source Code Formatting run: npm run check-fmt - - name: wasm-utxo / Lint + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-output + path: | + packages/wasm-utxo/dist/ + packages/wasm-utxo/js/wasm/ + packages/wasm-bip32/dist/ + packages/wasm-bip32/js/wasm/ + retention-days: 1 + + test: + name: "Test ${{ matrix.package }}" + needs: build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + package: [wasm-bip32, wasm-utxo] + include: + - package: wasm-utxo + needs-wasm-pack: true + has-wasm-pack-tests: true + - package: wasm-bip32 + needs-wasm-pack: false + has-wasm-pack-tests: false + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Install Rust + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + components: rustfmt, clippy + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + workspaces: packages/${{ matrix.package }} + cache-on-failure: true + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install npm + run: npm install -g npm@${{ env.NPM_VERSION }} + + - name: Install wasm-pack + if: matrix.needs-wasm-pack + run: cargo install wasm-pack --version ${{ env.WASM_PACK_VERSION }} + + - name: Install Packages + run: npm ci --workspaces --include-workspace-root + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: build-output + path: packages/ + + - name: Lint run: npm run lint - working-directory: packages/wasm-utxo + working-directory: packages/${{ matrix.package }} - - name: wasm-utxo / cargo test + - name: Cargo Test run: cargo test --workspace - working-directory: packages/wasm-utxo + working-directory: packages/${{ matrix.package }} - - name: wasm-utxo / Wasm-Pack Test (Node) + - name: Wasm-Pack Test (Node) + if: matrix.has-wasm-pack-tests run: npm run test:wasm-pack-node - working-directory: packages/wasm-utxo + working-directory: packages/${{ matrix.package }} - - name: wasm-utxo / Wasm-Pack Test (Chrome) + - name: Wasm-Pack Test (Chrome) + if: matrix.has-wasm-pack-tests run: npm run test:wasm-pack-chrome - working-directory: packages/wasm-utxo + working-directory: packages/${{ matrix.package }} - name: Unit Test - run: npm --workspaces test + run: npm test + working-directory: packages/${{ matrix.package }} - - name: Upload build artifacts + finalize: + name: "Finalize" + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install npm + run: npm install -g npm@${{ env.NPM_VERSION }} + + - name: Install Packages + run: npm ci --workspaces --include-workspace-root + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: build-output + path: packages/ + + - name: webui / Unit Test + run: npm test + working-directory: packages/webui + + - name: Upload final build artifacts if: inputs.upload-artifacts uses: actions/upload-artifact@v4 with: @@ -104,3 +202,11 @@ jobs: packages/wasm-utxo/dist/ retention-days: 1 + # This job provides a stable "test / Test" status check for branch protection. + # It runs after all other jobs complete successfully. + gate: + name: "Test" + needs: [build, test, finalize] + runs-on: ubuntu-latest + steps: + - run: echo "All checks passed" diff --git a/.github/workflows/status-check.yaml b/.github/workflows/status-check.yaml index f70041f..37fc599 100644 --- a/.github/workflows/status-check.yaml +++ b/.github/workflows/status-check.yaml @@ -22,4 +22,3 @@ jobs: exit 1 fi echo "Build and Test workflow succeeded" - diff --git a/package-lock.json b/package-lock.json index cbc0c2c..88d599b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -121,6 +121,25 @@ "dev": true, "license": "ISC" }, + "node_modules/@bitgo/secp256k1": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@bitgo/secp256k1/-/secp256k1-1.8.0.tgz", + "integrity": "sha512-sdVLB9qtrgL9Yi0vmCQIbeGZcTXhMwoadHEWZd1gka9Z0n3G4sdwxR+P2d2vFbgNbAJFt/9k8b1WOX9RFZ5e4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@brandonblack/musig": "^0.0.1-alpha.0", + "@noble/secp256k1": "1.6.3", + "bip32": "^3.0.1", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "ecpair": "npm:@bitgo/ecpair@2.1.0-rc.0" + }, + "engines": { + "node": ">=20 <23", + "npm": ">=3.10.10" + } + }, "node_modules/@bitgo/utxo-lib": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/@bitgo/utxo-lib/-/utxo-lib-10.1.0.tgz", @@ -152,6 +171,10 @@ "npm": ">=3.10.10" } }, + "node_modules/@bitgo/wasm-bip32": { + "resolved": "packages/wasm-bip32", + "link": true + }, "node_modules/@bitgo/wasm-utxo": { "resolved": "packages/wasm-utxo", "link": true @@ -21296,6 +21319,67 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/wasm-bip32": { + "name": "@bitgo/wasm-bip32", + "version": "0.0.1", + "license": "MIT", + "devDependencies": { + "@bitgo/utxo-lib": "^11.2.0", + "@types/mocha": "^10.0.7", + "@types/node": "^22.10.5", + "mocha": "^10.6.0", + "tsx": "4.20.6", + "typescript": "^5.5.3" + } + }, + "packages/wasm-bip32/node_modules/@bitgo/utxo-lib": { + "version": "11.19.0", + "resolved": "https://registry.npmjs.org/@bitgo/utxo-lib/-/utxo-lib-11.19.0.tgz", + "integrity": "sha512-VOH+n3YXQnce7EevIrs69D5uNP2JWbSpJ4/C9Tpg0Lu4TF0JTw732rkMy17uFWiYPkpycRji179hnhTCk11g7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bitgo/blake2b": "^3.2.4", + "@bitgo/secp256k1": "^1.8.0", + "@brandonblack/musig": "^0.0.1-alpha.0", + "bech32": "^2.0.0", + "bip174": "npm:@bitgo-forks/bip174@3.1.0-master.4", + "bitcoin-ops": "^1.3.0", + "bitcoinjs-lib": "npm:@bitgo-forks/bitcoinjs-lib@7.1.0-master.11", + "bs58check": "^2.1.2", + "cashaddress": "^1.1.0", + "fastpriorityqueue": "^0.7.1", + "typeforce": "^1.11.3", + "varuint-bitcoin": "^1.1.2" + }, + "engines": { + "node": ">=20 <23", + "npm": ">=3.10.10" + } + }, + "packages/wasm-bip32/node_modules/bitcoinjs-lib": { + "name": "@bitgo-forks/bitcoinjs-lib", + "version": "7.1.0-master.11", + "resolved": "https://registry.npmjs.org/@bitgo-forks/bitcoinjs-lib/-/bitcoinjs-lib-7.1.0-master.11.tgz", + "integrity": "sha512-Yyh67I26iI7FGqPBY7rxqHZ9FM9JuouAsViQocrr7URhRpuZEWVsM/oMTNbMnRw2cPFj4jWKhRDLadgrUk2HEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bech32": "^2.0.0", + "bip174": "npm:@bitgo-forks/bip174@3.1.0-master.4", + "bs58check": "^2.1.2", + "create-hash": "^1.1.0", + "fastpriorityqueue": "^0.7.1", + "json5": "^2.2.3", + "ripemd160": "^2.0.2", + "typeforce": "^1.11.3", + "varuint-bitcoin": "^1.1.2", + "wif": "^2.0.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, "packages/wasm-utxo": { "name": "@bitgo/wasm-utxo", "version": "0.0.2", diff --git a/packages/wasm-bip32/.gitignore b/packages/wasm-bip32/.gitignore new file mode 100644 index 0000000..f2d18aa --- /dev/null +++ b/packages/wasm-bip32/.gitignore @@ -0,0 +1,10 @@ +target/ +node_modules/ +# we actually only track the .ts files +dist/ +test/*.js +test/*.d.ts +js/*.js +js/*.d.ts +js/wasm +.vscode diff --git a/packages/wasm-bip32/.mocharc.json b/packages/wasm-bip32/.mocharc.json new file mode 100644 index 0000000..f585fb0 --- /dev/null +++ b/packages/wasm-bip32/.mocharc.json @@ -0,0 +1,5 @@ +{ + "extensions": ["ts", "tsx", "js", "jsx"], + "spec": ["test/**/*.ts"], + "node-option": ["import=tsx/esm", "experimental-wasm-modules"] +} diff --git a/packages/wasm-bip32/BENCHMARKS.md b/packages/wasm-bip32/BENCHMARKS.md new file mode 100644 index 0000000..c2c2bee --- /dev/null +++ b/packages/wasm-bip32/BENCHMARKS.md @@ -0,0 +1,135 @@ +# wasm-bip32 Benchmarks + +Performance comparison between `wasm-bip32` (pure Rust/WASM) and `@bitgo/utxo-lib` (native C via `tiny-secp256k1`). + +## Summary + +| Metric | wasm-bip32 | utxo-lib | +| ------------------- | --------------- | ------------ | +| **WASM Size** | 132 KB | N/A (native) | +| **Private Key Ops** | ~12-20% slower | baseline | +| **Public Key Ops** | **2-4x faster** | baseline | + +## Benchmark Results + +All benchmarks run with 1000 operations after a 100-operation warmup phase. + +| Operation | wasm-bip32 | utxo-lib | Ratio | +| ------------------------------- | -------------- | -------------- | --------- | +| `fromBase58(xprv)` + publicKey | 7,937 ops/sec | 9,091 ops/sec | **0.87x** | +| `fromBase58(xpub)` | 62,500 ops/sec | 15,873 ops/sec | **3.94x** | +| `fromSeed` + publicKey | 8,130 ops/sec | 10,204 ops/sec | **0.80x** | +| `derivePath` (xprv) + publicKey | 1,376 ops/sec | 1,563 ops/sec | **0.88x** | +| `derivePath` (xpub) + publicKey | 1,600 ops/sec | 809 ops/sec | **1.98x** | +| `neutered()` + publicKey | 8,197 ops/sec | 6,623 ops/sec | **1.24x** | +| `derive(0)` + publicKey | 7,299 ops/sec | 3,413 ops/sec | **2.14x** | + +## Understanding the Results + +### Why Private Key Operations are Slower + +Operations involving private keys require **scalar multiplication** (computing `privateKey × G` where G is the generator point). This is the most computationally expensive operation in elliptic curve cryptography. + +- `wasm-bip32` uses the pure Rust `k256` crate with precomputed tables +- `utxo-lib` uses `tiny-secp256k1`, which wraps the highly optimized C library `libsecp256k1` + +The C library has hand-tuned assembly optimizations that pure Rust cannot match in WASM. + +### Why Public Key Operations are Faster + +Operations on public keys involve **point decompression** and **point addition**, which are less computationally intensive than scalar multiplication. + +- `k256`'s precomputed tables accelerate these operations significantly +- The WASM JIT compilation can optimize tight loops effectively +- Point addition is a simpler operation that benefits from Rust's zero-cost abstractions + +### Lazy vs Eager Public Key Computation + +An important implementation detail: + +- **utxo-lib**: Lazily computes the public key from a private key (only when accessed) +- **wasm-bip32**: Eagerly computes the public key during key creation + +The benchmarks account for this by accessing `publicKey` immediately after key creation to ensure fair comparison. + +## Implementation Details + +### Cryptographic Backend + +`wasm-bip32` uses the following pure Rust crates: + +| Crate | Purpose | +| -------- | -------------------------- | +| `k256` | secp256k1 curve operations | +| `bip32` | HD key derivation | +| `sha2` | SHA-256/SHA-512 hashing | +| `ripemd` | RIPEMD-160 hashing | +| `bs58` | Base58Check encoding | + +### k256 Precomputed Tables + +The `k256` crate's `precomputed-tables` feature provides: + +- ~30KB of precomputed lookup tables for the generator point +- Accelerates `G × scalar` operations (used in public key derivation) +- Tables are lazily initialized on first use +- Trade-off: ~3% performance vs 2x table size (60KB → 30KB) + +The table size is fixed and not configurable. + +## Trade-offs + +### wasm-bip32 Advantages + +1. **Small binary size** (132 KB) - ideal for browser/mobile applications +2. **Pure Rust** - no native dependencies, works everywhere WASM runs +3. **Faster public key operations** - beneficial for address derivation workflows +4. **Memory safe** - no risk of C memory bugs + +### wasm-bip32 Disadvantages + +1. **Slower private key operations** (~12-20% slower) +2. **No hand-tuned assembly** - `libsecp256k1` uses platform-specific optimizations (x86/ARM assembly) that `k256` doesn't leverage. Note: [WASM does support SIMD](https://doc.rust-lang.org/beta/core/arch/wasm32/index.html) via `simd128` (128-bit vectors), but `k256` doesn't currently use these intrinsics. + +## Potential Optimizations + +### Already Applied + +- `precomputed-tables` feature enabled (+2-4x for public key ops) +- Release build with `opt-level = 3` +- `wasm-opt -O4` post-processing +- LTO (Link-Time Optimization) enabled + +### Not Currently Applied + +| Optimization | Impact | Trade-off | +| ------------------------- | ------------------------ | ---------------- | +| `secp256k1-ffi` backend | ~2x faster for all ops | 1.2 MB WASM size | +| Application-level caching | Varies | Memory usage | +| Batch derivation | Significant for bulk ops | API complexity | + +### Future: k256 v0.14 with crypto-bigint Scalar Inversion + +A [recent commit](https://github.com/RustCrypto/elliptic-curves/commit/c971eaa95a1664a298add84c581de00b92ddc92b) to the `k256` crate implements scalar inversions using the optimized `safegcd-bounds` algorithm from `crypto-bigint`, achieving an **~80% performance improvement** for scalar inversion operations: + +``` +scalar operations/invert time: [2.66 µs → ~13 µs] +change: [−80.024% −79.497% −78.858%] +``` + +This optimization will be available in `k256 v0.14` (currently in release candidate). Once `bip32` crate releases a compatible stable version, upgrading could improve operations that involve scalar inversions (though note: standard BIP32 derivation doesn't heavily use inversions, so impact may be limited to signing operations). + +## Running Benchmarks + +```bash +cd packages/wasm-bip32 +npm test +``` + +The benchmarks are part of the test suite and output results to the console. + +## Environment + +- Node.js with WASM support +- Benchmarks run single-threaded +- Results may vary based on CPU and JIT warmup diff --git a/packages/wasm-bip32/Cargo.lock b/packages/wasm-bip32/Cargo.lock new file mode 100644 index 0000000..2b41348 --- /dev/null +++ b/packages/wasm-bip32/Cargo.lock @@ -0,0 +1,745 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64ct" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d809780667f4410e7c41b07f52439b94d2bdf8528eeedc287fa38d3b7f95d82" + +[[package]] +name = "bip32" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db40d3dfbeab4e031d78c844642fa0caa0b0db11ce1607ac9d2986dff1405c69" +dependencies = [ + "bs58", + "hmac", + "k256", + "rand_core", + "ripemd", + "secp256k1", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "sha2", + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "secp256k1" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25996b82292a7a57ed3508f052cfff8640d38d32018784acd714758b43da9c8f" +dependencies = [ + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4473013577ec77b4ee3668179ef1186df3146e2cf2d927bd200974c6fe60fd99" +dependencies = [ + "cc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e90e66d265d3a1efc0e72a54809ab90b9c0c515915c67cdf658689d2c22c6c" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7150335716dce6028bead2b848e72f47b45e7b9422f64cccdc23bedca89affc1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasm-bip32" +version = "0.1.0" +dependencies = [ + "bip32", + "bs58", + "getrandom", + "hex", + "hmac", + "js-sys", + "k256", + "ripemd", + "sha2", + "wasm-bindgen", + "wasm-bindgen-test", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zmij" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" diff --git a/packages/wasm-bip32/Cargo.toml b/packages/wasm-bip32/Cargo.toml new file mode 100644 index 0000000..1e9985d --- /dev/null +++ b/packages/wasm-bip32/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "wasm-bip32" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[lints.clippy] +all = "warn" + +[dependencies] +wasm-bindgen = "0.2" +js-sys = "0.3" +bip32 = { version = "0.5", default-features = false, features = [ + "alloc", + "secp256k1", +] } +k256 = { version = "0.13", default-features = false, features = [ + "ecdsa", + "sha256", + "alloc", + "precomputed-tables", + "std", +] } +sha2 = { version = "0.10", default-features = false } +ripemd = { version = "0.1", default-features = false } +bs58 = { version = "0.5", default-features = false, features = [ + "check", + "alloc", +] } +getrandom = { version = "0.2", features = ["js"] } + +[dev-dependencies] +wasm-bindgen-test = "0.3" +hex = "0.4" +web-sys = { version = "0.3", features = ["console"] } +hmac = "0.12" + +[profile.release] +opt-level = "z" +lto = true +strip = true +codegen-units = 1 diff --git a/packages/wasm-bip32/Makefile b/packages/wasm-bip32/Makefile new file mode 100644 index 0000000..2e881e6 --- /dev/null +++ b/packages/wasm-bip32/Makefile @@ -0,0 +1,70 @@ +WASM_PACK = wasm-pack +WASM_OPT = wasm-opt +WASM_PACK_FLAGS = --no-pack --weak-refs + +ifdef WASM_PACK_DEV + WASM_PACK_FLAGS += --dev +endif + +# Auto-detect Mac and use Homebrew LLVM for WASM compilation +# Apple's Clang doesn't support wasm32-unknown-unknown target +UNAME_S := $(shell uname -s) + +ifeq ($(UNAME_S),Darwin) + # Mac detected - check for Homebrew LLVM installation + HOMEBREW_LLVM := $(shell brew --prefix llvm 2>/dev/null) + + ifdef HOMEBREW_LLVM + export CC = $(HOMEBREW_LLVM)/bin/clang + export AR = $(HOMEBREW_LLVM)/bin/llvm-ar + $(info Using Homebrew LLVM: $(HOMEBREW_LLVM)) + else + $(warning Homebrew LLVM not found. Install with: brew install llvm) + $(warning Continuing with system clang - may fail on Apple Silicon) + endif +endif + +define WASM_PACK_COMMAND + $(WASM_PACK) build --no-opt --out-dir $(1) $(WASM_PACK_FLAGS) --target $(2) +endef + +# run wasm-opt separately so we can pass `--enable-bulk-memory` +define WASM_OPT_COMMAND + $(WASM_OPT) --enable-bulk-memory --enable-nontrapping-float-to-int --enable-sign-ext -Oz $(1)/*.wasm -o $(1)/*.wasm +endef + +define REMOVE_GITIGNORE + find $(1) -name .gitignore -delete +endef + +define SHOW_WASM_SIZE + @find $(1) -name "*.wasm" -exec gzip -k {} \; + @find $(1) -name "*.wasm" -exec du -h {} \; + @find $(1) -name "*.wasm.gz" -exec du -h {} \; + @find $(1) -name "*.wasm.gz" -delete +endef + +define BUILD + rm -rf $(1) + $(call WASM_PACK_COMMAND,$(1),$(2)) + $(call WASM_OPT_COMMAND,$(1)) + $(call REMOVE_GITIGNORE,$(1)) + $(call SHOW_WASM_SIZE,$(1)) +endef + +.PHONY: js/wasm +js/wasm: + $(call BUILD,$@,bundler) + +.PHONY: dist/esm/js/wasm +dist/esm/js/wasm: + $(call BUILD,$@,bundler) + +.PHONY: dist/cjs/js/wasm +dist/cjs/js/wasm: + $(call BUILD,$@,nodejs) + +.PHONY: lint +lint: + cargo fmt --check + cargo clippy --all-targets --all-features -- -D warnings diff --git a/packages/wasm-bip32/js/bip32.ts b/packages/wasm-bip32/js/bip32.ts new file mode 100644 index 0000000..ae2f505 --- /dev/null +++ b/packages/wasm-bip32/js/bip32.ts @@ -0,0 +1,226 @@ +import { WasmBIP32 } from "./wasm/wasm_bip32.js"; + +/** + * BIP32Arg represents the various forms that BIP32 keys can take + * before being converted to a WasmBIP32 instance + */ +export type BIP32Arg = + /** base58-encoded extended key string (xpub/xprv/tpub/tprv) */ + | string + /** BIP32 instance */ + | BIP32 + /** WasmBIP32 instance */ + | WasmBIP32 + /** BIP32Interface compatible object */ + | BIP32Interface; + +/** + * BIP32 interface for extended key operations + */ +export interface BIP32Interface { + chainCode: Uint8Array; + depth: number; + index: number; + parentFingerprint: number; + privateKey?: Uint8Array; + publicKey: Uint8Array; + identifier: Uint8Array; + fingerprint: Uint8Array; + isNeutered(): boolean; + neutered(): BIP32Interface; + toBase58(): string; + toWIF(): string; + derive(index: number): BIP32Interface; + deriveHardened(index: number): BIP32Interface; + derivePath(path: string): BIP32Interface; +} + +/** + * BIP32 wrapper class for extended key operations + */ +export class BIP32 implements BIP32Interface { + private constructor(private _wasm: WasmBIP32) {} + + /** + * Create a BIP32 instance from a WasmBIP32 instance (internal use) + * @internal + */ + static fromWasm(wasm: WasmBIP32): BIP32 { + return new BIP32(wasm); + } + + /** + * Convert BIP32Arg to BIP32 instance + * @param key - The BIP32 key in various formats + * @returns BIP32 instance + */ + static from(key: BIP32Arg): BIP32 { + // Short-circuit if already a BIP32 instance + if (key instanceof BIP32) { + return key; + } + // If it's a WasmBIP32 instance, wrap it + if (key instanceof WasmBIP32) { + return new BIP32(key); + } + // If it's a string, parse from base58 + if (typeof key === "string") { + const wasm = WasmBIP32.from_base58(key); + return new BIP32(wasm); + } + // If it's an object (BIP32Interface), convert via base58 + if (typeof key === "object" && key !== null && "toBase58" in key) { + const wasm = WasmBIP32.from_base58(key.toBase58()); + return new BIP32(wasm); + } + throw new Error("Invalid BIP32Arg type"); + } + + /** + * Create a BIP32 key from a base58 string (xpub/xprv/tpub/tprv) + * @param base58Str - The base58-encoded extended key string + * @returns A BIP32 instance + */ + static fromBase58(base58Str: string): BIP32 { + const wasm = WasmBIP32.from_base58(base58Str); + return new BIP32(wasm); + } + + /** + * Create a BIP32 master key from a seed + * @param seed - The seed bytes + * @param network - Optional network string + * @returns A BIP32 instance + */ + static fromSeed(seed: Uint8Array, network?: string | null): BIP32 { + const wasm = WasmBIP32.from_seed(seed, network); + return new BIP32(wasm); + } + + /** + * Get the chain code as a Uint8Array + */ + get chainCode(): Uint8Array { + return this._wasm.chain_code; + } + + /** + * Get the depth + */ + get depth(): number { + return this._wasm.depth; + } + + /** + * Get the child index + */ + get index(): number { + return this._wasm.index; + } + + /** + * Get the parent fingerprint + */ + get parentFingerprint(): number { + return this._wasm.parent_fingerprint; + } + + /** + * Get the private key as a Uint8Array (if available) + */ + get privateKey(): Uint8Array | undefined { + return this._wasm.private_key; + } + + /** + * Get the public key as a Uint8Array + */ + get publicKey(): Uint8Array { + return this._wasm.public_key; + } + + /** + * Get the identifier as a Uint8Array + */ + get identifier(): Uint8Array { + return this._wasm.identifier; + } + + /** + * Get the fingerprint as a Uint8Array + */ + get fingerprint(): Uint8Array { + return this._wasm.fingerprint; + } + + /** + * Check if this is a neutered (public) key + * @returns True if the key is public-only (neutered) + */ + isNeutered(): boolean { + return this._wasm.is_neutered(); + } + + /** + * Get the neutered (public) version of this key + * @returns A new BIP32 instance containing only the public key + */ + neutered(): BIP32 { + const wasm = this._wasm.neutered(); + return new BIP32(wasm); + } + + /** + * Serialize to base58 string + * @returns The base58-encoded extended key string + */ + toBase58(): string { + return this._wasm.to_base58(); + } + + /** + * Get the WIF encoding of the private key + * @returns The WIF-encoded private key + */ + toWIF(): string { + return this._wasm.to_wif(); + } + + /** + * Derive a normal (non-hardened) child key + * @param index - The child index + * @returns A new BIP32 instance for the derived key + */ + derive(index: number): BIP32 { + const wasm = this._wasm.derive(index); + return new BIP32(wasm); + } + + /** + * Derive a hardened child key (only works for private keys) + * @param index - The child index + * @returns A new BIP32 instance for the derived key + */ + deriveHardened(index: number): BIP32 { + const wasm = this._wasm.derive_hardened(index); + return new BIP32(wasm); + } + + /** + * Derive a key using a derivation path (e.g., "0/1/2" or "m/0/1/2") + * @param path - The derivation path string + * @returns A new BIP32 instance for the derived key + */ + derivePath(path: string): BIP32 { + const wasm = this._wasm.derive_path(path); + return new BIP32(wasm); + } + + /** + * Get the underlying WASM instance (internal use only) + * @internal + */ + get wasm(): WasmBIP32 { + return this._wasm; + } +} diff --git a/packages/wasm-bip32/js/ecpair.ts b/packages/wasm-bip32/js/ecpair.ts new file mode 100644 index 0000000..59f55d6 --- /dev/null +++ b/packages/wasm-bip32/js/ecpair.ts @@ -0,0 +1,204 @@ +import { WasmECPair } from "./wasm/wasm_bip32.js"; + +/** + * ECPairArg represents the various forms that ECPair keys can take + * before being converted to a WasmECPair instance + */ +export type ECPairArg = + /** Private key (32 bytes) or compressed public key (33 bytes) as Buffer/Uint8Array */ + | Uint8Array + /** ECPair instance */ + | ECPair + /** WasmECPair instance */ + | WasmECPair; + +/** + * ECPair interface for elliptic curve key pair operations + */ +export interface ECPairInterface { + publicKey: Uint8Array; + privateKey?: Uint8Array; + toWIF(): string; + sign?(messageHash: Uint8Array): Uint8Array; + verify?(messageHash: Uint8Array, signature: Uint8Array): boolean; + signMessage?(message: string): Uint8Array; + verifyMessage?(message: string, signature: Uint8Array): boolean; +} + +/** + * ECPair wrapper class for elliptic curve key pair operations + */ +export class ECPair implements ECPairInterface { + private constructor(private _wasm: WasmECPair) {} + + /** + * Create an ECPair instance from a WasmECPair instance (internal use) + * @internal + */ + static fromWasm(wasm: WasmECPair): ECPair { + return new ECPair(wasm); + } + + /** + * Convert ECPairArg to ECPair instance + * @param key - The ECPair key in various formats + * @returns ECPair instance + */ + static from(key: ECPairArg): ECPair { + // Short-circuit if already an ECPair instance + if (key instanceof ECPair) { + return key; + } + // If it's a WasmECPair instance, wrap it + if (key instanceof WasmECPair) { + return new ECPair(key); + } + // Parse from Buffer/Uint8Array + // Check length to determine if it's a private key (32 bytes) or public key (33 bytes) + if (key.length === 32) { + const wasm = WasmECPair.from_private_key(key); + return new ECPair(wasm); + } else if (key.length === 33) { + const wasm = WasmECPair.from_public_key(key); + return new ECPair(wasm); + } else { + throw new Error( + `Invalid key length: ${key.length}. Expected 32 bytes (private key) or 33 bytes (compressed public key)`, + ); + } + } + + /** + * Create an ECPair from a private key (always uses compressed keys) + * @param buffer - The 32-byte private key + * @returns An ECPair instance + */ + static fromPrivateKey(buffer: Uint8Array): ECPair { + const wasm = WasmECPair.from_private_key(buffer); + return new ECPair(wasm); + } + + /** + * Create an ECPair from a compressed public key + * @param buffer - The compressed public key bytes (33 bytes) + * @returns An ECPair instance + */ + static fromPublicKey(buffer: Uint8Array): ECPair { + const wasm = WasmECPair.from_public_key(buffer); + return new ECPair(wasm); + } + + /** + * Create an ECPair from a WIF string (auto-detects network from WIF) + * @param wifString - The WIF-encoded private key string + * @returns An ECPair instance + */ + static fromWIF(wifString: string): ECPair { + const wasm = WasmECPair.from_wif(wifString); + return new ECPair(wasm); + } + + /** + * Create an ECPair from a mainnet WIF string + * @param wifString - The WIF-encoded private key string + * @returns An ECPair instance + */ + static fromWIFMainnet(wifString: string): ECPair { + const wasm = WasmECPair.from_wif_mainnet(wifString); + return new ECPair(wasm); + } + + /** + * Create an ECPair from a testnet WIF string + * @param wifString - The WIF-encoded private key string + * @returns An ECPair instance + */ + static fromWIFTestnet(wifString: string): ECPair { + const wasm = WasmECPair.from_wif_testnet(wifString); + return new ECPair(wasm); + } + + /** + * Get the private key as a Uint8Array (if available) + */ + get privateKey(): Uint8Array | undefined { + return this._wasm.private_key; + } + + /** + * Get the public key as a Uint8Array + */ + get publicKey(): Uint8Array { + return this._wasm.public_key; + } + + /** + * Convert to WIF string (mainnet) + * @returns The WIF-encoded private key + */ + toWIF(): string { + return this._wasm.to_wif(); + } + + /** + * Convert to mainnet WIF string + * @returns The WIF-encoded private key + */ + toWIFMainnet(): string { + return this._wasm.to_wif_mainnet(); + } + + /** + * Convert to testnet WIF string + * @returns The WIF-encoded private key + */ + toWIFTestnet(): string { + return this._wasm.to_wif_testnet(); + } + + /** + * Sign a 32-byte message hash (raw ECDSA) + * @param messageHash - The 32-byte message hash to sign + * @returns The signature as a Uint8Array + */ + sign(messageHash: Uint8Array): Uint8Array { + return this._wasm.sign(messageHash); + } + + /** + * Verify a signature against a 32-byte message hash (raw ECDSA) + * @param messageHash - The 32-byte message hash + * @param signature - The signature to verify + * @returns True if the signature is valid + */ + verify(messageHash: Uint8Array, signature: Uint8Array): boolean { + return this._wasm.verify(messageHash, signature); + } + + /** + * Sign a message using Bitcoin message signing (BIP-137) + * @param message - The message to sign + * @returns 65-byte signature (1-byte header + 64-byte signature) + */ + signMessage(message: string): Uint8Array { + return new Uint8Array(this._wasm.sign_message(message)); + } + + /** + * Verify a Bitcoin message signature (BIP-137) + * @param message - The message that was signed + * @param signature - 65-byte signature (1-byte header + 64-byte signature) + * @returns True if the signature is valid for this key + */ + verifyMessage(message: string, signature: Uint8Array): boolean { + return this._wasm.verify_message(message, signature); + } + + /** + * Get the underlying WASM instance (internal use only) + * @internal + */ + get wasm(): WasmECPair { + return this._wasm; + } +} diff --git a/packages/wasm-bip32/js/index.ts b/packages/wasm-bip32/js/index.ts new file mode 100644 index 0000000..3a1ef3f --- /dev/null +++ b/packages/wasm-bip32/js/index.ts @@ -0,0 +1,3 @@ +export { BIP32, BIP32Interface, BIP32Arg } from "./bip32.js"; +export { ECPair, ECPairInterface, ECPairArg } from "./ecpair.js"; +export { WasmBIP32, WasmECPair } from "./wasm/wasm_bip32.js"; diff --git a/packages/wasm-bip32/package.json b/packages/wasm-bip32/package.json new file mode 100644 index 0000000..6f1fb96 --- /dev/null +++ b/packages/wasm-bip32/package.json @@ -0,0 +1,59 @@ +{ + "name": "@bitgo/wasm-bip32", + "description": "Minimal WebAssembly BIP32/ECPair implementation with message signing", + "version": "0.0.1", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/BitGo/BitGoWASM" + }, + "license": "MIT", + "files": [ + "dist/esm/js/**/*", + "dist/cjs/js/**/*", + "dist/cjs/package.json" + ], + "exports": { + ".": { + "import": { + "types": "./dist/esm/js/index.d.ts", + "default": "./dist/esm/js/index.js" + }, + "require": { + "types": "./dist/cjs/js/index.d.ts", + "default": "./dist/cjs/js/index.js" + } + } + }, + "main": "./dist/cjs/js/index.js", + "module": "./dist/esm/js/index.js", + "types": "./dist/esm/js/index.d.ts", + "sideEffects": [ + "./dist/esm/js/wasm/wasm_bip32.js", + "./dist/cjs/js/wasm/wasm_bip32.js" + ], + "scripts": { + "test": "npm run test:mocha", + "test:mocha": "mocha --recursive test", + "build:wasm": "make js/wasm && make dist/esm/js/wasm && make dist/cjs/js/wasm", + "build:ts-esm": "tsc", + "build:ts-cjs": "tsc --project tsconfig.cjs.json", + "build:ts": "npm run build:ts-esm && npm run build:ts-cjs", + "build:package-json": "echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json", + "build": "npm run build:wasm && npm run build:ts && npm run build:package-json", + "check-fmt": "prettier --check . && cargo fmt -- --check", + "lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings" + }, + "devDependencies": { + "@bitgo/utxo-lib": "^11.2.0", + "@types/mocha": "^10.0.7", + "@types/node": "^22.10.5", + "mocha": "^10.6.0", + "tsx": "4.20.6", + "typescript": "^5.5.3" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/wasm-bip32/src/bench.rs b/packages/wasm-bip32/src/bench.rs new file mode 100644 index 0000000..dced611 --- /dev/null +++ b/packages/wasm-bip32/src/bench.rs @@ -0,0 +1,375 @@ +//! Rust-level WASM benchmarks to drill down into BIP32 performance +//! +//! These benchmarks break down fromBase58 and fromSeed into component operations +//! to identify where time is spent. +//! +//! Run with: `wasm-pack test --node --release` + +use wasm_bindgen_test::*; + +const XPRV: &str = "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; +const XPUB: &str = "xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5"; +const SEED: [u8; 32] = [1u8; 32]; +const OPS: usize = 100; + +use std::cell::RefCell; + +thread_local! { + static RESULTS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +fn now_ms() -> f64 { + js_sys::Date::now() +} + +fn log(msg: &str) { + web_sys::console::log_1(&msg.into()); +} + +fn bench(name: &str, ops: usize, mut f: F) { + // Warm-up + for _ in 0..10 { + f(); + } + + let start = now_ms(); + for _ in 0..ops { + f(); + } + let elapsed = now_ms() - start; + let ops_per_sec = (ops as f64) / (elapsed / 1000.0); + + let result = format!( + " {}: {:.2}ms for {} ops ({:.0} ops/sec)", + name, elapsed, ops, ops_per_sec + ); + log(&result); + RESULTS.with(|r| r.borrow_mut().push(result)); +} + +fn dump_results(header: &str) { + let mut output = String::from(header); + output.push('\n'); + RESULTS.with(|r| { + for line in r.borrow().iter() { + output.push_str(line); + output.push('\n'); + } + }); + // Use panic to show results in test output + panic!("\n{}", output); +} + +fn clear_results() { + RESULTS.with(|r| r.borrow_mut().clear()); +} + +/// Benchmark: Break down fromBase58(xprv) into components +#[wasm_bindgen_test] +fn bench_from_base58_xprv_breakdown() { + use bip32::XPrv; + use std::str::FromStr; + + clear_results(); + log("\n=== fromBase58(xprv) Breakdown ==="); + + // 1. Base58 decode only + bench("bs58 decode (with checksum)", OPS, || { + let _ = bs58::decode(XPRV).with_check(None).into_vec().unwrap(); + }); + + // 2. Parse the decoded bytes into XPrv + bench("XPrv::from_str (full parsing)", OPS, || { + let _ = XPrv::from_str(XPRV).unwrap(); + }); + + // 3. Full fromBase58 (what we expose) + bench("Full fromBase58(xprv)", OPS, || { + let _ = XPrv::from_str(XPRV).unwrap(); + }); + + // 4. Just creating a SigningKey from bytes (the expensive part) + bench("SigningKey::from_slice (32 bytes)", OPS, || { + use k256::ecdsa::SigningKey; + let secret = [0x11u8; 32]; + let _ = SigningKey::from_slice(&secret).unwrap(); + }); + + // 5. Computing public key from private key + bench("SigningKey -> VerifyingKey", OPS, || { + use k256::ecdsa::SigningKey; + let secret = [0x11u8; 32]; + let sk = SigningKey::from_slice(&secret).unwrap(); + let _ = sk.verifying_key(); + }); +} + +/// Benchmark: Break down fromBase58(xpub) into components +#[wasm_bindgen_test] +fn bench_from_base58_xpub_breakdown() { + use bip32::XPub; + use std::str::FromStr; + + log("\n=== fromBase58(xpub) Breakdown ==="); + + // 1. Base58 decode only + bench("bs58 decode (with checksum)", OPS, || { + let _ = bs58::decode(XPUB).with_check(None).into_vec().unwrap(); + }); + + // 2. Full fromBase58 + bench("Full fromBase58(xpub)", OPS, || { + let _ = XPub::from_str(XPUB).unwrap(); + }); + + // 3. VerifyingKey from SEC1 bytes (point decompression) + bench("VerifyingKey::from_sec1_bytes (33 bytes)", OPS, || { + use k256::ecdsa::VerifyingKey; + // Compressed public key (starts with 02 or 03) + let compressed = [ + 0x03, 0x39, 0xa3, 0x60, 0x13, 0x30, 0x15, 0x97, 0xda, 0xef, 0x41, 0xfb, 0xe5, 0x93, + 0xa0, 0x2c, 0xc5, 0x13, 0xd0, 0xb5, 0x55, 0x27, 0xec, 0x2d, 0xf1, 0x05, 0x0e, 0x2e, + 0x8f, 0xf4, 0x9c, 0x85, 0xc2, + ]; + let _ = VerifyingKey::from_sec1_bytes(&compressed).unwrap(); + }); +} + +/// Benchmark: Break down fromSeed into components +#[wasm_bindgen_test] +fn bench_from_seed_breakdown() { + use bip32::XPrv; + use hmac::{Hmac, Mac}; + use sha2::Sha512; + + log("\n=== fromSeed Breakdown ==="); + + // 1. HMAC-SHA512 only + bench("HMAC-SHA512 (seed -> 64 bytes)", OPS, || { + type HmacSha512 = Hmac; + let mut mac = HmacSha512::new_from_slice(b"Bitcoin seed").unwrap(); + mac.update(&SEED); + let _ = mac.finalize().into_bytes(); + }); + + // 2. Full fromSeed + bench("Full fromSeed", OPS, || { + let _ = XPrv::new(SEED).unwrap(); + }); + + // 3. SigningKey creation (called inside XPrv::new) + bench("SigningKey::from_slice", OPS, || { + use k256::ecdsa::SigningKey; + let _ = SigningKey::from_slice(&SEED).unwrap(); + }); +} + +/// Benchmark: Derivation operations +#[wasm_bindgen_test] +fn bench_derivation_breakdown() { + use bip32::{XPrv, XPub}; + use std::str::FromStr; + + log("\n=== Derivation Breakdown ==="); + + let xprv = XPrv::from_str(XPRV).unwrap(); + let xpub = XPub::from_str(XPUB).unwrap(); + + // 1. Single child derivation (private) + bench("xprv.derive_child(0)", OPS, || { + use bip32::ChildNumber; + let cn = ChildNumber::new(0, false).unwrap(); + let _ = xprv.derive_child(cn).unwrap(); + }); + + // 2. Single child derivation (public) + bench("xpub.derive_child(0)", OPS, || { + use bip32::ChildNumber; + let cn = ChildNumber::new(0, false).unwrap(); + let _ = xpub.derive_child(cn).unwrap(); + }); + + // 3. xprv -> xpub (neutered) + bench("xprv.public_key() [neutered]", OPS, || { + let _ = xprv.public_key(); + }); + + // 4. HMAC-SHA512 (used in derivation) + bench("HMAC-SHA512 (derivation step)", OPS, || { + use hmac::{Hmac, Mac}; + use sha2::Sha512; + type HmacSha512 = Hmac; + let chain_code = [0u8; 32]; + let mut mac = HmacSha512::new_from_slice(&chain_code).unwrap(); + mac.update(&[0u8; 37]); // pubkey + index + let _ = mac.finalize().into_bytes(); + }); + + // 5. Scalar multiplication (the expensive EC operation) + bench("EC point multiplication (G * scalar)", OPS, || { + use k256::elliptic_curve::sec1::ToEncodedPoint; + use k256::ProjectivePoint; + use k256::Scalar; + let scalar = Scalar::ONE; + let point = ProjectivePoint::GENERATOR * scalar; + let _ = point.to_affine().to_encoded_point(true); + }); + + // 6. EC point addition + bench("EC point addition", OPS, || { + use k256::ProjectivePoint; + let g = ProjectivePoint::GENERATOR; + let _ = g + g; + }); +} + +/// Summary benchmark comparing full operations - outputs all results via panic +#[wasm_bindgen_test] +fn bench_full_operations_summary() { + use bip32::{XPrv, XPub}; + use std::str::FromStr; + + clear_results(); + + // === fromBase58(xprv) Breakdown === + RESULTS.with(|r| { + r.borrow_mut() + .push("\n=== fromBase58(xprv) Breakdown ===".into()) + }); + + bench("bs58 decode (with checksum)", OPS, || { + let _ = bs58::decode(XPRV).with_check(None).into_vec().unwrap(); + }); + + bench("XPrv::from_str (full parsing)", OPS, || { + let _ = XPrv::from_str(XPRV).unwrap(); + }); + + bench("SigningKey::from_slice (32 bytes)", OPS, || { + use k256::ecdsa::SigningKey; + let secret = [0x11u8; 32]; + let _ = SigningKey::from_slice(&secret).unwrap(); + }); + + bench("SigningKey -> VerifyingKey", OPS, || { + use k256::ecdsa::SigningKey; + let secret = [0x11u8; 32]; + let sk = SigningKey::from_slice(&secret).unwrap(); + let _ = sk.verifying_key(); + }); + + // === fromBase58(xpub) Breakdown === + RESULTS.with(|r| { + r.borrow_mut() + .push("\n=== fromBase58(xpub) Breakdown ===".into()) + }); + + bench("bs58 decode (with checksum)", OPS, || { + let _ = bs58::decode(XPUB).with_check(None).into_vec().unwrap(); + }); + + bench("XPub::from_str (full parsing)", OPS, || { + let _ = XPub::from_str(XPUB).unwrap(); + }); + + bench("VerifyingKey::from_sec1_bytes (33 bytes)", OPS, || { + use k256::ecdsa::VerifyingKey; + let compressed = [ + 0x03, 0x39, 0xa3, 0x60, 0x13, 0x30, 0x15, 0x97, 0xda, 0xef, 0x41, 0xfb, 0xe5, 0x93, + 0xa0, 0x2c, 0xc5, 0x13, 0xd0, 0xb5, 0x55, 0x27, 0xec, 0x2d, 0xf1, 0x05, 0x0e, 0x2e, + 0x8f, 0xf4, 0x9c, 0x85, 0xc2, + ]; + let _ = VerifyingKey::from_sec1_bytes(&compressed).unwrap(); + }); + + // === fromSeed Breakdown === + RESULTS.with(|r| r.borrow_mut().push("\n=== fromSeed Breakdown ===".into())); + + bench("HMAC-SHA512 (seed -> 64 bytes)", OPS, || { + use hmac::{Hmac, Mac}; + use sha2::Sha512; + type HmacSha512 = Hmac; + let mut mac = HmacSha512::new_from_slice(b"Bitcoin seed").unwrap(); + mac.update(&SEED); + let _ = mac.finalize().into_bytes(); + }); + + bench("Full fromSeed (XPrv::new)", OPS, || { + let _ = XPrv::new(SEED).unwrap(); + }); + + // === Derivation Breakdown === + RESULTS.with(|r| r.borrow_mut().push("\n=== Derivation Breakdown ===".into())); + + let xprv = XPrv::from_str(XPRV).unwrap(); + let xpub = XPub::from_str(XPUB).unwrap(); + + bench("xprv.derive_child(0)", OPS, || { + use bip32::ChildNumber; + let cn = ChildNumber::new(0, false).unwrap(); + let _ = xprv.derive_child(cn).unwrap(); + }); + + bench("xpub.derive_child(0)", OPS, || { + use bip32::ChildNumber; + let cn = ChildNumber::new(0, false).unwrap(); + let _ = xpub.derive_child(cn).unwrap(); + }); + + bench("xprv.public_key() [neutered]", OPS, || { + let _ = xprv.public_key(); + }); + + bench("EC point multiplication (G * scalar)", OPS, || { + use k256::elliptic_curve::sec1::ToEncodedPoint; + use k256::ProjectivePoint; + use k256::Scalar; + let scalar = Scalar::ONE; + let point = ProjectivePoint::GENERATOR * scalar; + let _ = point.to_affine().to_encoded_point(true); + }); + + bench("EC point addition", OPS, || { + use k256::ProjectivePoint; + let g = ProjectivePoint::GENERATOR; + let _ = g + g; + }); + + // === Full Operations Summary === + RESULTS.with(|r| { + r.borrow_mut() + .push("\n=== Full Operations Summary ===".into()) + }); + + bench("fromBase58(xprv)", OPS, || { + let _ = XPrv::from_str(XPRV).unwrap(); + }); + + bench("fromBase58(xpub)", OPS, || { + let _ = XPub::from_str(XPUB).unwrap(); + }); + + bench("fromSeed", OPS, || { + let _ = XPrv::new(SEED).unwrap(); + }); + + bench("derivePath m/44'/0'/0'/0/0 (xprv)", OPS, || { + use bip32::DerivationPath; + let path: DerivationPath = "m/44'/0'/0'/0/0".parse().unwrap(); + let mut current = xprv.clone(); + for cn in path { + current = current.derive_child(cn).unwrap(); + } + }); + + bench("derivePath 0/0/0/0/0 (xpub)", OPS, || { + use bip32::DerivationPath; + let path: DerivationPath = "m/0/0/0/0/0".parse().unwrap(); + let mut current = xpub.clone(); + for cn in path { + current = current.derive_child(cn).unwrap(); + } + }); + + dump_results("BENCHMARK RESULTS"); +} diff --git a/packages/wasm-bip32/src/bip32.rs b/packages/wasm-bip32/src/bip32.rs new file mode 100644 index 0000000..cfe0ac4 --- /dev/null +++ b/packages/wasm-bip32/src/bip32.rs @@ -0,0 +1,318 @@ +use crate::error::WasmBip32Error; +use bip32::{ChildNumber, DerivationPath, Prefix, XPrv, XPub}; +use k256::ecdsa::VerifyingKey; +use ripemd::Ripemd160; +use sha2::{Digest, Sha256}; +use std::str::FromStr; +use wasm_bindgen::prelude::*; + +/// Internal enum to hold either public or private extended key +#[derive(Debug, Clone)] +enum BIP32Key { + Public(XPub), + Private(XPrv), +} + +impl BIP32Key { + fn verifying_key(&self) -> VerifyingKey { + match self { + BIP32Key::Public(xpub) => *xpub.public_key(), + BIP32Key::Private(xprv) => *xprv.private_key().verifying_key(), + } + } + + fn is_neutered(&self) -> bool { + matches!(self, BIP32Key::Public(_)) + } + + fn depth(&self) -> u8 { + match self { + BIP32Key::Public(xpub) => xpub.attrs().depth, + BIP32Key::Private(xprv) => xprv.attrs().depth, + } + } + + fn chain_code(&self) -> &[u8; 32] { + match self { + BIP32Key::Public(xpub) => &xpub.attrs().chain_code, + BIP32Key::Private(xprv) => &xprv.attrs().chain_code, + } + } + + fn child_number(&self) -> ChildNumber { + match self { + BIP32Key::Public(xpub) => xpub.attrs().child_number, + BIP32Key::Private(xprv) => xprv.attrs().child_number, + } + } + + fn parent_fingerprint(&self) -> [u8; 4] { + match self { + BIP32Key::Public(xpub) => xpub.attrs().parent_fingerprint, + BIP32Key::Private(xprv) => xprv.attrs().parent_fingerprint, + } + } + + fn derive(&self, index: u32) -> Result { + let child_number = ChildNumber::new(index, false) + .map_err(|_| WasmBip32Error::new("Invalid child number"))?; + + match self { + BIP32Key::Public(xpub) => { + let derived = xpub.derive_child(child_number)?; + Ok(BIP32Key::Public(derived)) + } + BIP32Key::Private(xprv) => { + let derived = xprv.derive_child(child_number)?; + Ok(BIP32Key::Private(derived)) + } + } + } + + fn derive_hardened(&self, index: u32) -> Result { + let child_number = ChildNumber::new(index, true) + .map_err(|_| WasmBip32Error::new("Invalid child number"))?; + + match self { + BIP32Key::Public(_) => Err(WasmBip32Error::new( + "Cannot derive hardened key from public key", + )), + BIP32Key::Private(xprv) => { + let derived = xprv.derive_child(child_number)?; + Ok(BIP32Key::Private(derived)) + } + } + } + + fn derive_path(&self, path: &str) -> Result { + // Remove leading 'm/' or 'M/' if present + let path_str = path + .strip_prefix("m/") + .or_else(|| path.strip_prefix("M/")) + .unwrap_or(path); + + // Handle empty path + if path_str.is_empty() { + return Ok(self.clone()); + } + + let derivation_path = DerivationPath::from_str(&format!("m/{}", path_str)) + .map_err(|e| WasmBip32Error::new(&format!("Invalid derivation path: {}", e)))?; + + let mut current = self.clone(); + for child_number in derivation_path { + current = match current { + BIP32Key::Public(xpub) => { + if child_number.is_hardened() { + return Err(WasmBip32Error::new( + "Cannot derive hardened key from public key", + )); + } + BIP32Key::Public(xpub.derive_child(child_number)?) + } + BIP32Key::Private(xprv) => BIP32Key::Private(xprv.derive_child(child_number)?), + }; + } + Ok(current) + } + + fn to_base58(&self, testnet: bool) -> String { + match self { + BIP32Key::Public(xpub) => { + let prefix = if testnet { Prefix::TPUB } else { Prefix::XPUB }; + xpub.to_string(prefix).to_string() + } + BIP32Key::Private(xprv) => { + let prefix = if testnet { Prefix::TPRV } else { Prefix::XPRV }; + xprv.to_string(prefix).to_string() + } + } + } + + fn to_wif(&self, testnet: bool) -> Result { + match self { + BIP32Key::Public(_) => Err(WasmBip32Error::new("Cannot get WIF from public key")), + BIP32Key::Private(xprv) => { + let secret_bytes = xprv.private_key().to_bytes(); + let version = if testnet { 0xefu8 } else { 0x80u8 }; + + // WIF format: version (1) + secret (32) + compression flag (1) + let mut data = Vec::with_capacity(34); + data.push(version); + data.extend_from_slice(&secret_bytes); + data.push(0x01); // Always compressed + + Ok(bs58::encode(&data).with_check().into_string()) + } + } + } +} + +/// WASM wrapper for BIP32 extended keys +#[wasm_bindgen] +#[derive(Debug, Clone)] +pub struct WasmBIP32 { + key: BIP32Key, + testnet: bool, +} + +#[wasm_bindgen] +impl WasmBIP32 { + /// Create a BIP32 key from a base58 string (xpub/xprv/tpub/tprv) + #[wasm_bindgen] + pub fn from_base58(base58_str: &str) -> Result { + let testnet = base58_str.starts_with('t'); + + // Try parsing as private key first + if let Ok(xprv) = XPrv::from_str(base58_str) { + return Ok(WasmBIP32 { + key: BIP32Key::Private(xprv), + testnet, + }); + } + + // Try parsing as public key + if let Ok(xpub) = XPub::from_str(base58_str) { + return Ok(WasmBIP32 { + key: BIP32Key::Public(xpub), + testnet, + }); + } + + Err(WasmBip32Error::new("Invalid base58 encoded key")) + } + + /// Create a BIP32 master key from a seed + #[wasm_bindgen] + pub fn from_seed(seed: &[u8], network: Option) -> Result { + let testnet = matches!( + network.as_deref(), + Some("testnet") | Some("BitcoinTestnet3") | Some("BitcoinTestnet4") + ); + + let xprv = XPrv::new(seed)?; + + Ok(WasmBIP32 { + key: BIP32Key::Private(xprv), + testnet, + }) + } + + /// Get the chain code as a Uint8Array + #[wasm_bindgen(getter)] + pub fn chain_code(&self) -> js_sys::Uint8Array { + js_sys::Uint8Array::from(&self.key.chain_code()[..]) + } + + /// Get the depth + #[wasm_bindgen(getter)] + pub fn depth(&self) -> u8 { + self.key.depth() + } + + /// Get the child index + #[wasm_bindgen(getter)] + pub fn index(&self) -> u32 { + self.key.child_number().into() + } + + /// Get the parent fingerprint + #[wasm_bindgen(getter)] + pub fn parent_fingerprint(&self) -> u32 { + u32::from_be_bytes(self.key.parent_fingerprint()) + } + + /// Get the private key as a Uint8Array (if available) + #[wasm_bindgen(getter)] + pub fn private_key(&self) -> Option { + match &self.key { + BIP32Key::Public(_) => None, + BIP32Key::Private(xprv) => { + Some(js_sys::Uint8Array::from(&xprv.private_key().to_bytes()[..])) + } + } + } + + /// Get the public key as a Uint8Array (33 bytes, compressed) + #[wasm_bindgen(getter)] + pub fn public_key(&self) -> js_sys::Uint8Array { + let verifying_key = self.key.verifying_key(); + let bytes = verifying_key.to_sec1_bytes(); + js_sys::Uint8Array::from(&bytes[..]) + } + + /// Get the identifier (hash160 of public key) + #[wasm_bindgen(getter)] + pub fn identifier(&self) -> js_sys::Uint8Array { + let pubkey_bytes = self.key.verifying_key().to_sec1_bytes(); + let sha256_hash = Sha256::digest(&pubkey_bytes); + let hash160 = Ripemd160::digest(sha256_hash); + js_sys::Uint8Array::from(&hash160[..]) + } + + /// Get the fingerprint (first 4 bytes of identifier) + #[wasm_bindgen(getter)] + pub fn fingerprint(&self) -> js_sys::Uint8Array { + let pubkey_bytes = self.key.verifying_key().to_sec1_bytes(); + let sha256_hash = Sha256::digest(&pubkey_bytes); + let hash160 = Ripemd160::digest(sha256_hash); + js_sys::Uint8Array::from(&hash160[..4]) + } + + /// Check if this is a neutered (public) key + #[wasm_bindgen] + pub fn is_neutered(&self) -> bool { + self.key.is_neutered() + } + + /// Get the neutered (public) version of this key + #[wasm_bindgen] + pub fn neutered(&self) -> WasmBIP32 { + match &self.key { + BIP32Key::Public(_) => self.clone(), + BIP32Key::Private(xprv) => WasmBIP32 { + key: BIP32Key::Public(xprv.public_key()), + testnet: self.testnet, + }, + } + } + + /// Serialize to base58 string + #[wasm_bindgen] + pub fn to_base58(&self) -> String { + self.key.to_base58(self.testnet) + } + + /// Get the WIF encoding of the private key + #[wasm_bindgen] + pub fn to_wif(&self) -> Result { + self.key.to_wif(self.testnet) + } + + /// Derive a normal (non-hardened) child key + #[wasm_bindgen] + pub fn derive(&self, index: u32) -> Result { + Ok(WasmBIP32 { + key: self.key.derive(index)?, + testnet: self.testnet, + }) + } + + /// Derive a hardened child key (only works for private keys) + #[wasm_bindgen] + pub fn derive_hardened(&self, index: u32) -> Result { + Ok(WasmBIP32 { + key: self.key.derive_hardened(index)?, + testnet: self.testnet, + }) + } + + /// Derive a key using a derivation path (e.g., "0/1/2" or "m/0/1/2") + #[wasm_bindgen] + pub fn derive_path(&self, path: &str) -> Result { + Ok(WasmBIP32 { + key: self.key.derive_path(path)?, + testnet: self.testnet, + }) + } +} diff --git a/packages/wasm-bip32/src/ecpair.rs b/packages/wasm-bip32/src/ecpair.rs new file mode 100644 index 0000000..eebf7c3 --- /dev/null +++ b/packages/wasm-bip32/src/ecpair.rs @@ -0,0 +1,255 @@ +use crate::error::WasmBip32Error; +use crate::message; +use k256::ecdsa::{SigningKey, VerifyingKey}; +use wasm_bindgen::prelude::*; + +/// Network kind for WIF encoding +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NetworkKind { + Main, + Test, +} + +/// Internal enum to hold either public-only or private+public keys +#[derive(Debug, Clone)] +enum ECPairKey { + PublicOnly(VerifyingKey), + Private { + signing_key: SigningKey, + verifying_key: VerifyingKey, + }, +} + +impl ECPairKey { + fn verifying_key(&self) -> &VerifyingKey { + match self { + ECPairKey::PublicOnly(vk) => vk, + ECPairKey::Private { verifying_key, .. } => verifying_key, + } + } + + fn signing_key(&self) -> Option<&SigningKey> { + match self { + ECPairKey::PublicOnly(_) => None, + ECPairKey::Private { signing_key, .. } => Some(signing_key), + } + } +} + +/// WASM wrapper for elliptic curve key pairs (always uses compressed keys) +#[wasm_bindgen] +#[derive(Debug, Clone)] +pub struct WasmECPair { + key: ECPairKey, +} + +#[wasm_bindgen] +impl WasmECPair { + /// Create an ECPair from a private key (always uses compressed keys) + #[wasm_bindgen] + pub fn from_private_key(private_key: &[u8]) -> Result { + if private_key.len() != 32 { + return Err(WasmBip32Error::new("Private key must be 32 bytes")); + } + + let signing_key = SigningKey::from_slice(private_key) + .map_err(|e| WasmBip32Error::new(&format!("Invalid private key: {}", e)))?; + + let verifying_key = *signing_key.verifying_key(); + + Ok(WasmECPair { + key: ECPairKey::Private { + signing_key, + verifying_key, + }, + }) + } + + /// Create an ECPair from a public key (always uses compressed keys) + #[wasm_bindgen] + pub fn from_public_key(public_key: &[u8]) -> Result { + let verifying_key = VerifyingKey::from_sec1_bytes(public_key) + .map_err(|e| WasmBip32Error::new(&format!("Invalid public key: {}", e)))?; + + Ok(WasmECPair { + key: ECPairKey::PublicOnly(verifying_key), + }) + } + + fn from_wif_with_network_check( + wif_string: &str, + expected_network: Option, + ) -> Result { + let decoded = bs58::decode(wif_string) + .with_check(None) + .into_vec() + .map_err(|e| WasmBip32Error::new(&format!("Invalid WIF: {}", e)))?; + + if decoded.is_empty() { + return Err(WasmBip32Error::new("Invalid WIF: empty")); + } + + let version = decoded[0]; + let actual_network = match version { + 0x80 => NetworkKind::Main, + 0xef => NetworkKind::Test, + _ => return Err(WasmBip32Error::new("Invalid WIF version byte")), + }; + + if let Some(expected) = expected_network { + if actual_network != expected { + let network_name = match expected { + NetworkKind::Main => "mainnet", + NetworkKind::Test => "testnet", + }; + return Err(WasmBip32Error::new(&format!( + "Expected {} WIF", + network_name + ))); + } + } + + // Check for compression flag + let private_key_bytes = if decoded.len() == 34 && decoded[33] == 0x01 { + // Compressed + &decoded[1..33] + } else if decoded.len() == 33 { + // Uncompressed (we'll still use compressed public key) + &decoded[1..33] + } else { + return Err(WasmBip32Error::new("Invalid WIF length")); + }; + + let signing_key = SigningKey::from_slice(private_key_bytes) + .map_err(|e| WasmBip32Error::new(&format!("Invalid private key in WIF: {}", e)))?; + + let verifying_key = *signing_key.verifying_key(); + + Ok(WasmECPair { + key: ECPairKey::Private { + signing_key, + verifying_key, + }, + }) + } + + /// Create an ECPair from a WIF string (auto-detects network) + #[wasm_bindgen] + pub fn from_wif(wif_string: &str) -> Result { + Self::from_wif_with_network_check(wif_string, None) + } + + /// Create an ECPair from a mainnet WIF string + #[wasm_bindgen] + pub fn from_wif_mainnet(wif_string: &str) -> Result { + Self::from_wif_with_network_check(wif_string, Some(NetworkKind::Main)) + } + + /// Create an ECPair from a testnet WIF string + #[wasm_bindgen] + pub fn from_wif_testnet(wif_string: &str) -> Result { + Self::from_wif_with_network_check(wif_string, Some(NetworkKind::Test)) + } + + /// Get the private key as a Uint8Array (if available) + #[wasm_bindgen(getter)] + pub fn private_key(&self) -> Option { + self.key + .signing_key() + .map(|sk| js_sys::Uint8Array::from(&sk.to_bytes()[..])) + } + + /// Get the compressed public key as a Uint8Array (always 33 bytes) + #[wasm_bindgen(getter)] + pub fn public_key(&self) -> js_sys::Uint8Array { + let vk = self.key.verifying_key(); + let bytes = vk.to_sec1_bytes(); + js_sys::Uint8Array::from(&bytes[..]) + } + + fn to_wif_with_network(&self, network: NetworkKind) -> Result { + let signing_key = self + .key + .signing_key() + .ok_or_else(|| WasmBip32Error::new("Cannot get WIF from public key"))?; + + let version = match network { + NetworkKind::Main => 0x80u8, + NetworkKind::Test => 0xefu8, + }; + + // WIF format: version (1) + secret (32) + compression flag (1) + let mut data = Vec::with_capacity(34); + data.push(version); + data.extend_from_slice(&signing_key.to_bytes()); + data.push(0x01); // Always compressed + + Ok(bs58::encode(&data).with_check().into_string()) + } + + /// Convert to WIF string (mainnet) + #[wasm_bindgen] + pub fn to_wif(&self) -> Result { + self.to_wif_mainnet() + } + + /// Convert to mainnet WIF string + #[wasm_bindgen] + pub fn to_wif_mainnet(&self) -> Result { + self.to_wif_with_network(NetworkKind::Main) + } + + /// Convert to testnet WIF string + #[wasm_bindgen] + pub fn to_wif_testnet(&self) -> Result { + self.to_wif_with_network(NetworkKind::Test) + } + + /// Sign a 32-byte message hash (raw ECDSA) + #[wasm_bindgen] + pub fn sign(&self, message_hash: &[u8]) -> Result { + if message_hash.len() != 32 { + return Err(WasmBip32Error::new("Message hash must be 32 bytes")); + } + + let signing_key = self + .key + .signing_key() + .ok_or_else(|| WasmBip32Error::new("Cannot sign with public key only"))?; + + let signature = message::sign_raw(signing_key, message_hash)?; + Ok(js_sys::Uint8Array::from(&signature[..])) + } + + /// Verify a signature against a 32-byte message hash (raw ECDSA) + #[wasm_bindgen] + pub fn verify(&self, message_hash: &[u8], signature: &[u8]) -> Result { + if message_hash.len() != 32 { + return Err(WasmBip32Error::new("Message hash must be 32 bytes")); + } + + let verifying_key = self.key.verifying_key(); + Ok(message::verify_raw(verifying_key, message_hash, signature)) + } + + /// Sign a message using Bitcoin message signing (BIP-137) + /// Returns 65-byte signature (1-byte header + 64-byte signature) + #[wasm_bindgen] + pub fn sign_message(&self, message: &str) -> Result { + let signing_key = self + .key + .signing_key() + .ok_or_else(|| WasmBip32Error::new("Cannot sign with public key only"))?; + + let signature = message::sign_bitcoin_message(signing_key, message)?; + Ok(js_sys::Uint8Array::from(&signature[..])) + } + + /// Verify a Bitcoin message signature (BIP-137) + /// Signature must be 65 bytes (1-byte header + 64-byte signature) + #[wasm_bindgen] + pub fn verify_message(&self, message: &str, signature: &[u8]) -> Result { + let verifying_key = self.key.verifying_key(); + message::verify_bitcoin_message(verifying_key, message, signature) + } +} diff --git a/packages/wasm-bip32/src/error.rs b/packages/wasm-bip32/src/error.rs new file mode 100644 index 0000000..861d4b6 --- /dev/null +++ b/packages/wasm-bip32/src/error.rs @@ -0,0 +1,53 @@ +use wasm_bindgen::prelude::*; + +/// Error type for wasm-bip32 operations +#[derive(Debug, Clone)] +pub struct WasmBip32Error { + message: String, +} + +impl WasmBip32Error { + pub fn new(message: &str) -> Self { + WasmBip32Error { + message: message.to_string(), + } + } +} + +impl std::fmt::Display for WasmBip32Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for WasmBip32Error {} + +impl From for JsValue { + fn from(err: WasmBip32Error) -> JsValue { + JsValue::from_str(&err.message) + } +} + +impl From for WasmBip32Error { + fn from(err: bip32::Error) -> Self { + WasmBip32Error::new(&format!("BIP32 error: {}", err)) + } +} + +impl From for WasmBip32Error { + fn from(err: k256::ecdsa::Error) -> Self { + WasmBip32Error::new(&format!("ECDSA error: {}", err)) + } +} + +impl From for WasmBip32Error { + fn from(err: bs58::decode::Error) -> Self { + WasmBip32Error::new(&format!("Base58 decode error: {}", err)) + } +} + +impl From for WasmBip32Error { + fn from(err: bs58::encode::Error) -> Self { + WasmBip32Error::new(&format!("Base58 encode error: {}", err)) + } +} diff --git a/packages/wasm-bip32/src/lib.rs b/packages/wasm-bip32/src/lib.rs new file mode 100644 index 0000000..03a5694 --- /dev/null +++ b/packages/wasm-bip32/src/lib.rs @@ -0,0 +1,11 @@ +mod bip32; +mod ecpair; +mod error; +mod message; + +#[cfg(test)] +mod bench; + +pub use bip32::WasmBIP32; +pub use ecpair::WasmECPair; +pub use error::WasmBip32Error; diff --git a/packages/wasm-bip32/src/message.rs b/packages/wasm-bip32/src/message.rs new file mode 100644 index 0000000..02ea3aa --- /dev/null +++ b/packages/wasm-bip32/src/message.rs @@ -0,0 +1,144 @@ +use crate::error::WasmBip32Error; +use k256::ecdsa::signature::hazmat::PrehashSigner; +use k256::ecdsa::{RecoveryId, Signature, SigningKey, VerifyingKey}; +use sha2::{Digest, Sha256}; + +/// Bitcoin message magic prefix +const BITCOIN_MESSAGE_MAGIC: &[u8] = b"\x18Bitcoin Signed Message:\n"; + +/// Compute Bitcoin message hash (double SHA256 with magic prefix) +fn bitcoin_message_hash(message: &str) -> [u8; 32] { + let message_bytes = message.as_bytes(); + + // Build the full message: magic + varint(len) + message + let mut data = Vec::new(); + data.extend_from_slice(BITCOIN_MESSAGE_MAGIC); + write_varint(&mut data, message_bytes.len()); + data.extend_from_slice(message_bytes); + + // Double SHA256 + let first_hash = Sha256::digest(&data); + let second_hash = Sha256::digest(first_hash); + + let mut result = [0u8; 32]; + result.copy_from_slice(&second_hash); + result +} + +/// Write a variable-length integer +fn write_varint(data: &mut Vec, value: usize) { + if value < 0xfd { + data.push(value as u8); + } else if value <= 0xffff { + data.push(0xfd); + data.extend_from_slice(&(value as u16).to_le_bytes()); + } else if value <= 0xffffffff { + data.push(0xfe); + data.extend_from_slice(&(value as u32).to_le_bytes()); + } else { + data.push(0xff); + data.extend_from_slice(&(value as u64).to_le_bytes()); + } +} + +/// Sign a raw 32-byte message hash with ECDSA +pub fn sign_raw(signing_key: &SigningKey, message_hash: &[u8]) -> Result, WasmBip32Error> { + let (signature, _recovery_id): (Signature, RecoveryId) = signing_key + .sign_prehash(message_hash) + .map_err(|e| WasmBip32Error::new(&format!("Signing failed: {}", e)))?; + + Ok(signature.to_vec()) +} + +/// Verify a raw ECDSA signature +pub fn verify_raw(verifying_key: &VerifyingKey, message_hash: &[u8], signature: &[u8]) -> bool { + use k256::ecdsa::signature::hazmat::PrehashVerifier; + + let sig = match Signature::from_slice(signature) { + Ok(s) => s, + Err(_) => return false, + }; + + verifying_key.verify_prehash(message_hash, &sig).is_ok() +} + +/// Sign a message using Bitcoin message signing (BIP-137) +/// Returns 65-byte recoverable signature (1-byte header + 64-byte signature) +pub fn sign_bitcoin_message( + signing_key: &SigningKey, + message: &str, +) -> Result, WasmBip32Error> { + let message_hash = bitcoin_message_hash(message); + + let (signature, recovery_id): (Signature, RecoveryId) = signing_key + .sign_prehash(&message_hash) + .map_err(|e| WasmBip32Error::new(&format!("Signing failed: {}", e)))?; + + // BIP-137 format: 1-byte header + 64-byte signature + // Header: 27 + recovery_id + (4 if compressed) + // We always use compressed keys, so header = 31 + recovery_id + let header = 31 + recovery_id.to_byte(); + + let mut sig_bytes = Vec::with_capacity(65); + sig_bytes.push(header); + sig_bytes.extend_from_slice(&signature.to_bytes()); + + Ok(sig_bytes) +} + +/// Verify a Bitcoin message signature (BIP-137) +/// Signature must be 65 bytes (1-byte header + 64-byte signature) +pub fn verify_bitcoin_message( + verifying_key: &VerifyingKey, + message: &str, + signature: &[u8], +) -> Result { + if signature.len() != 65 { + return Err(WasmBip32Error::new("Signature must be 65 bytes")); + } + + let header = signature[0]; + let r_s = &signature[1..65]; + + // Extract recovery id from header + // Header values: 27-30 uncompressed, 31-34 compressed + let recovery_id = if (31..=34).contains(&header) { + header - 31 + } else if (27..=30).contains(&header) { + header - 27 + } else { + return Err(WasmBip32Error::new("Invalid signature header")); + }; + + let sig = + Signature::from_slice(r_s).map_err(|_| WasmBip32Error::new("Invalid signature format"))?; + + let recid = RecoveryId::from_byte(recovery_id) + .ok_or_else(|| WasmBip32Error::new("Invalid recovery id"))?; + + let message_hash = bitcoin_message_hash(message); + + // Recover the public key from the signature + let recovered_key = VerifyingKey::recover_from_prehash(&message_hash, &sig, recid) + .map_err(|_| WasmBip32Error::new("Failed to recover public key from signature"))?; + + // Compare recovered key with provided key + Ok(recovered_key == *verifying_key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bitcoin_message_hash() { + // The hash should be deterministic + let hash1 = bitcoin_message_hash("test message"); + let hash2 = bitcoin_message_hash("test message"); + assert_eq!(hash1, hash2); + + // Different messages should produce different hashes + let hash3 = bitcoin_message_hash("different message"); + assert_ne!(hash1, hash3); + } +} diff --git a/packages/wasm-bip32/test/bip32.ts b/packages/wasm-bip32/test/bip32.ts new file mode 100644 index 0000000..fec3404 --- /dev/null +++ b/packages/wasm-bip32/test/bip32.ts @@ -0,0 +1,276 @@ +import * as assert from "assert"; +import { bip32 as utxolibBip32 } from "@bitgo/utxo-lib"; +import { BIP32 } from "../js/bip32.js"; + +describe("WasmBIP32", () => { + it("should create from base58 xpub", () => { + const xpub = + "xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5"; + const key = BIP32.fromBase58(xpub); + + assert.strictEqual(key.isNeutered(), true); + assert.strictEqual(key.depth, 3); + assert.strictEqual(key.toBase58(), xpub); + + // Verify properties exist + assert.ok(key.chainCode instanceof Uint8Array); + assert.ok(key.publicKey instanceof Uint8Array); + assert.ok(key.identifier instanceof Uint8Array); + assert.ok(key.fingerprint instanceof Uint8Array); + assert.strictEqual(key.privateKey, undefined); + }); + + it("should create from base58 xprv", () => { + const xprv = + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; + const key = BIP32.fromBase58(xprv); + + assert.strictEqual(key.isNeutered(), false); + assert.strictEqual(key.depth, 0); + assert.strictEqual(key.toBase58(), xprv); + + // Verify properties exist + assert.ok(key.chainCode instanceof Uint8Array); + assert.ok(key.publicKey instanceof Uint8Array); + assert.ok(key.privateKey instanceof Uint8Array); + assert.ok(key.identifier instanceof Uint8Array); + assert.ok(key.fingerprint instanceof Uint8Array); + }); + + it("should derive child keys", () => { + const xpub = + "xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5"; + const key = BIP32.fromBase58(xpub); + + const child = key.derive(0); + assert.strictEqual(child.depth, 4); + assert.strictEqual(child.isNeutered(), true); + }); + + it("should derive using path", () => { + const xprv = + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; + const key = BIP32.fromBase58(xprv); + + const derived1 = key.derivePath("0/1/2"); + assert.strictEqual(derived1.depth, 3); + + const derived2 = key.derivePath("m/0/1/2"); + assert.strictEqual(derived2.depth, 3); + + // Both should produce the same result + assert.strictEqual(derived1.toBase58(), derived2.toBase58()); + }); + + it("should neutered a private key", () => { + const xprv = + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; + const key = BIP32.fromBase58(xprv); + const neuteredKey = key.neutered(); + + assert.strictEqual(neuteredKey.isNeutered(), true); + assert.strictEqual(neuteredKey.privateKey, undefined); + assert.ok(neuteredKey.publicKey instanceof Uint8Array); + }); + + it("should derive hardened keys from private key", () => { + const xprv = + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; + const key = BIP32.fromBase58(xprv); + + const hardened = key.deriveHardened(0); + assert.strictEqual(hardened.depth, 1); + assert.strictEqual(hardened.isNeutered(), false); + }); + + it("should fail to derive hardened from public key", () => { + const xpub = + "xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5"; + const key = BIP32.fromBase58(xpub); + + assert.throws(() => { + key.deriveHardened(0); + }); + }); + + it("should export to WIF", () => { + const xprv = + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; + const key = BIP32.fromBase58(xprv); + + const wif = key.toWIF(); + assert.ok(typeof wif === "string"); + assert.ok(wif.length > 0); + }); + + it("should fail to export WIF from public key", () => { + const xpub = + "xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5"; + const key = BIP32.fromBase58(xpub); + + assert.throws(() => { + key.toWIF(); + }); + }); + + it("should create from seed", () => { + const seed = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + seed[i] = i; + } + + const key = BIP32.fromSeed(seed); + assert.strictEqual(key.depth, 0); + assert.strictEqual(key.isNeutered(), false); + assert.ok(key.privateKey instanceof Uint8Array); + }); + + it("should create from seed with network", () => { + const seed = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + seed[i] = i; + } + + const key = BIP32.fromSeed(seed, "BitcoinTestnet3"); + assert.strictEqual(key.depth, 0); + assert.strictEqual(key.isNeutered(), false); + assert.ok(key.toBase58().startsWith("tprv")); + }); +}); + +describe("BIP32 Benchmarks: wasm-bip32 vs utxo-lib", function () { + // Increase timeout for benchmark tests on slower CI runners + this.timeout(30000); + const warmupOps = 100; + const ops = 1000; + const seed = new Uint8Array(32).fill(1); + const xprv = + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; + const xpub = + "xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5"; + const path = "m/44'/0'/0'/0/0"; + + function benchmark(name: string, wasmFn: () => void, utxolibFn: () => void) { + it(name, () => { + // Warm-up phase: initialize lazy structures (precomputed tables, JIT, etc.) + for (let i = 0; i < warmupOps; i++) { + wasmFn(); + utxolibFn(); + } + + // Measure wasm-bip32 + let start = Date.now(); + for (let i = 0; i < ops; i++) { + wasmFn(); + } + const wasmTime = Date.now() - start; + const wasmOpsPerSec = ops / (wasmTime / 1000); + + // Measure utxo-lib + start = Date.now(); + for (let i = 0; i < ops; i++) { + utxolibFn(); + } + const utxolibTime = Date.now() - start; + const utxolibOpsPerSec = ops / (utxolibTime / 1000); + + const ratio = wasmOpsPerSec / utxolibOpsPerSec; + + console.log(`\n ${name}:`); + console.log( + ` wasm-bip32: ${wasmTime.toFixed(2)}ms for ${ops} ops (${wasmOpsPerSec.toFixed(0)} ops/sec)`, + ); + console.log( + ` utxo-lib : ${utxolibTime.toFixed(2)}ms for ${ops} ops (${utxolibOpsPerSec.toFixed(0)} ops/sec)`, + ); + console.log(` Ratio: ${ratio.toFixed(2)}x`); + }); + } + + // Note: utxo-lib lazily computes publicKey, so we access it to make fair comparison + benchmark( + "fromBase58 (xprv) + publicKey", + () => { + const key = BIP32.fromBase58(xprv); + void key.publicKey; // Force publicKey computation + }, + () => { + const key = utxolibBip32.fromBase58(xprv); + void key.publicKey; // Force publicKey computation (lazy in utxo-lib) + }, + ); + + benchmark( + "fromBase58 (xpub)", + () => BIP32.fromBase58(xpub), + () => utxolibBip32.fromBase58(xpub), + ); + + benchmark( + "fromSeed + publicKey", + () => { + const key = BIP32.fromSeed(seed); + void key.publicKey; // Force publicKey computation + }, + () => { + const key = utxolibBip32.fromSeed(Buffer.from(seed)); + void key.publicKey; // Force publicKey computation (lazy in utxo-lib) + }, + ); + + benchmark( + "derivePath from xprv + publicKey", + () => { + const key = BIP32.fromBase58(xprv); + const derived = key.derivePath(path); + void derived.publicKey; // Force publicKey computation + }, + () => { + const key = utxolibBip32.fromBase58(xprv); + const derived = key.derivePath(path); + void derived.publicKey; // Force publicKey computation + }, + ); + + benchmark( + "derivePath from xpub + publicKey", + () => { + const key = BIP32.fromBase58(xpub); + const derived = key.derivePath("0/0/0/0/0"); + void derived.publicKey; // Force publicKey computation + }, + () => { + const key = utxolibBip32.fromBase58(xpub); + const derived = key.derivePath("0/0/0/0/0"); + void derived.publicKey; // Force publicKey computation + }, + ); + + benchmark( + "neutered() + publicKey", + () => { + const key = BIP32.fromBase58(xprv); + const pub = key.neutered(); + void pub.publicKey; // Force publicKey computation + }, + () => { + const key = utxolibBip32.fromBase58(xprv); + const pub = key.neutered(); + void pub.publicKey; // Force publicKey computation + }, + ); + + benchmark( + "derive single child + publicKey", + () => { + const key = BIP32.fromBase58(xpub); + const derived = key.derive(0); + void derived.publicKey; // Force publicKey computation + }, + () => { + const key = utxolibBip32.fromBase58(xpub); + const derived = key.derive(0); + void derived.publicKey; // Force publicKey computation + }, + ); +}); diff --git a/packages/wasm-bip32/test/ecpair.ts b/packages/wasm-bip32/test/ecpair.ts new file mode 100644 index 0000000..fabde41 --- /dev/null +++ b/packages/wasm-bip32/test/ecpair.ts @@ -0,0 +1,179 @@ +import * as assert from "assert"; +import { ECPair } from "../js/ecpair.js"; + +describe("WasmECPair", () => { + const testPrivateKey = new Uint8Array( + Buffer.from("1111111111111111111111111111111111111111111111111111111111111111", "hex"), + ); + + const testWifMainnet = "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn"; + const testWifTestnet = "cMahea7zqjxrtgAbB7LSGbcQUr1uX1ojuat9jZodMN87JcbXMTcA"; + + it("should create from private key", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + + assert.ok(key.privateKey instanceof Uint8Array); + assert.ok(key.publicKey instanceof Uint8Array); + assert.strictEqual(key.privateKey.length, 32); + assert.strictEqual(key.publicKey.length, 33); // Always compressed + }); + + it("should create from public key", () => { + const tempKey = ECPair.fromPrivateKey(testPrivateKey); + const publicKey = tempKey.publicKey; + + const key = ECPair.fromPublicKey(publicKey); + + assert.strictEqual(key.privateKey, undefined); + assert.ok(key.publicKey instanceof Uint8Array); + assert.strictEqual(key.publicKey.length, 33); + }); + + it("should create from mainnet WIF", () => { + const key = ECPair.fromWIF(testWifMainnet); + + assert.ok(key.privateKey instanceof Uint8Array); + assert.ok(key.publicKey instanceof Uint8Array); + assert.strictEqual(key.privateKey.length, 32); + }); + + it("should create from testnet WIF", () => { + const key = ECPair.fromWIF(testWifTestnet); + + assert.ok(key.privateKey instanceof Uint8Array); + assert.ok(key.publicKey instanceof Uint8Array); + assert.strictEqual(key.privateKey.length, 32); + }); + + it("should create from mainnet WIF using fromWIFMainnet", () => { + const key = ECPair.fromWIFMainnet(testWifMainnet); + + assert.ok(key.privateKey instanceof Uint8Array); + assert.ok(key.publicKey instanceof Uint8Array); + }); + + it("should create from testnet WIF using fromWIFTestnet", () => { + const key = ECPair.fromWIFTestnet(testWifTestnet); + + assert.ok(key.privateKey instanceof Uint8Array); + assert.ok(key.publicKey instanceof Uint8Array); + }); + + it("should fail when using wrong network WIF method", () => { + assert.throws(() => { + ECPair.fromWIFMainnet(testWifTestnet); + }); + + assert.throws(() => { + ECPair.fromWIFTestnet(testWifMainnet); + }); + }); + + it("should export to WIF mainnet", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const wif = key.toWIF(); + + assert.ok(typeof wif === "string"); + assert.ok(wif.length > 0); + assert.ok(wif.startsWith("K") || wif.startsWith("L")); // Mainnet compressed + }); + + it("should export to WIF testnet", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const wif = key.toWIFTestnet(); + + assert.ok(typeof wif === "string"); + assert.ok(wif.length > 0); + assert.ok(wif.startsWith("c")); // Testnet compressed + }); + + it("should roundtrip WIF mainnet", () => { + const key1 = ECPair.fromPrivateKey(testPrivateKey); + const wif = key1.toWIF(); + const key2 = ECPair.fromWIF(wif); + + assert.deepStrictEqual(key1.privateKey, key2.privateKey); + assert.deepStrictEqual(key1.publicKey, key2.publicKey); + }); + + it("should roundtrip WIF testnet", () => { + const key1 = ECPair.fromPrivateKey(testPrivateKey); + const wif = key1.toWIFTestnet(); + const key2 = ECPair.fromWIF(wif); + + assert.deepStrictEqual(key1.privateKey, key2.privateKey); + assert.deepStrictEqual(key1.publicKey, key2.publicKey); + }); + + it("should fail to export WIF from public key", () => { + const tempKey = ECPair.fromPrivateKey(testPrivateKey); + const publicKey = tempKey.publicKey; + const key = ECPair.fromPublicKey(publicKey); + + assert.throws(() => { + key.toWIF(); + }); + + assert.throws(() => { + key.toWIFMainnet(); + }); + + assert.throws(() => { + key.toWIFTestnet(); + }); + }); + + it("should reject invalid private keys", () => { + // All zeros + assert.throws(() => { + ECPair.fromPrivateKey(new Uint8Array(32)); + }); + + // Wrong length + assert.throws(() => { + ECPair.fromPrivateKey(new Uint8Array(31)); + }); + + assert.throws(() => { + ECPair.fromPrivateKey(new Uint8Array(33)); + }); + }); + + it("should reject invalid public keys", () => { + // Wrong length + assert.throws(() => { + ECPair.fromPublicKey(new Uint8Array(32)); + }); + + assert.throws(() => { + ECPair.fromPublicKey(new Uint8Array(34)); + }); + + // Invalid format + assert.throws(() => { + const invalidPubkey = new Uint8Array(33); + invalidPubkey[0] = 0x01; // Invalid prefix + ECPair.fromPublicKey(invalidPubkey); + }); + }); + + it("should always produce compressed public keys", () => { + const key1 = ECPair.fromPrivateKey(testPrivateKey); + const key2 = ECPair.fromWIF(testWifMainnet); + + // All public keys should be 33 bytes (compressed) + assert.strictEqual(key1.publicKey.length, 33); + assert.strictEqual(key2.publicKey.length, 33); + + // All should start with 0x02 or 0x03 (compressed format) + assert.ok(key1.publicKey[0] === 0x02 || key1.publicKey[0] === 0x03); + assert.ok(key2.publicKey[0] === 0x02 || key2.publicKey[0] === 0x03); + }); + + it("should derive same public key from same private key", () => { + const key1 = ECPair.fromPrivateKey(testPrivateKey); + const key2 = ECPair.fromPrivateKey(testPrivateKey); + + assert.deepStrictEqual(key1.publicKey, key2.publicKey); + }); +}); diff --git a/packages/wasm-bip32/test/message.ts b/packages/wasm-bip32/test/message.ts new file mode 100644 index 0000000..79544f7 --- /dev/null +++ b/packages/wasm-bip32/test/message.ts @@ -0,0 +1,201 @@ +import * as assert from "assert"; +import { ECPair } from "../js/ecpair.js"; + +describe("Message Signing", () => { + const testPrivateKey = new Uint8Array( + Buffer.from("1111111111111111111111111111111111111111111111111111111111111111", "hex"), + ); + + describe("Raw ECDSA signing", () => { + it("should sign a 32-byte message hash", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const messageHash = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + messageHash[i] = i; + } + + const signature = key.sign(messageHash); + + assert.ok(signature instanceof Uint8Array); + assert.strictEqual(signature.length, 64); // r (32) + s (32) + }); + + it("should verify a valid signature", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const messageHash = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + messageHash[i] = i; + } + + const signature = key.sign(messageHash); + const isValid = key.verify(messageHash, signature); + + assert.strictEqual(isValid, true); + }); + + it("should reject invalid signature", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const messageHash = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + messageHash[i] = i; + } + + // Create an invalid signature + const invalidSignature = new Uint8Array(64); + const isValid = key.verify(messageHash, invalidSignature); + + assert.strictEqual(isValid, false); + }); + + it("should reject signature for different message", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const messageHash1 = new Uint8Array(32); + const messageHash2 = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + messageHash1[i] = i; + messageHash2[i] = i + 1; + } + + const signature = key.sign(messageHash1); + const isValid = key.verify(messageHash2, signature); + + assert.strictEqual(isValid, false); + }); + + it("should verify signature with public key only", () => { + const privateKey = ECPair.fromPrivateKey(testPrivateKey); + const publicKey = ECPair.fromPublicKey(privateKey.publicKey); + + const messageHash = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + messageHash[i] = i; + } + + const signature = privateKey.sign(messageHash); + const isValid = publicKey.verify(messageHash, signature); + + assert.strictEqual(isValid, true); + }); + + it("should fail to sign with public key only", () => { + const privateKey = ECPair.fromPrivateKey(testPrivateKey); + const publicKey = ECPair.fromPublicKey(privateKey.publicKey); + + const messageHash = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + messageHash[i] = i; + } + + assert.throws(() => { + publicKey.sign(messageHash); + }); + }); + + it("should reject message hash of wrong length", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + + assert.throws(() => { + key.sign(new Uint8Array(31)); + }); + + assert.throws(() => { + key.sign(new Uint8Array(33)); + }); + }); + }); + + describe("Bitcoin message signing (BIP-137)", () => { + it("should sign a message", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const message = "Hello, Bitcoin!"; + + const signature = key.signMessage(message); + + assert.ok(signature instanceof Uint8Array); + // 1-byte header + 64-byte signature + assert.strictEqual(signature.length, 65); + }); + + it("should verify a valid message signature", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const message = "Hello, Bitcoin!"; + + const signature = key.signMessage(message); + const isValid = key.verifyMessage(message, signature); + + assert.strictEqual(isValid, true); + }); + + it("should reject signature for different message", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + + const signature = key.signMessage("Hello, Bitcoin!"); + const isValid = key.verifyMessage("Different message", signature); + + assert.strictEqual(isValid, false); + }); + + it("should verify message signature with public key", () => { + const privateKey = ECPair.fromPrivateKey(testPrivateKey); + const publicKey = ECPair.fromPublicKey(privateKey.publicKey); + + const message = "Hello, Bitcoin!"; + const signature = privateKey.signMessage(message); + const isValid = publicKey.verifyMessage(message, signature); + + assert.strictEqual(isValid, true); + }); + + it("should fail to sign message with public key only", () => { + const privateKey = ECPair.fromPrivateKey(testPrivateKey); + const publicKey = ECPair.fromPublicKey(privateKey.publicKey); + + assert.throws(() => { + publicKey.signMessage("Hello, Bitcoin!"); + }); + }); + + it("should produce consistent signatures", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const message = "Test message"; + + // Sign the same message twice + const sig1 = key.signMessage(message); + const sig2 = key.signMessage(message); + + // Both signatures should be valid + assert.strictEqual(key.verifyMessage(message, sig1), true); + assert.strictEqual(key.verifyMessage(message, sig2), true); + }); + + it("should handle empty message", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const message = ""; + + const signature = key.signMessage(message); + const isValid = key.verifyMessage(message, signature); + + assert.strictEqual(isValid, true); + }); + + it("should handle long message", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const message = "A".repeat(1000); + + const signature = key.signMessage(message); + const isValid = key.verifyMessage(message, signature); + + assert.strictEqual(isValid, true); + }); + + it("should handle unicode message", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const message = "Hello, 世界! 🚀"; + + const signature = key.signMessage(message); + const isValid = key.verifyMessage(message, signature); + + assert.strictEqual(isValid, true); + }); + }); +}); diff --git a/packages/wasm-bip32/tsconfig.cjs.json b/packages/wasm-bip32/tsconfig.cjs.json new file mode 100644 index 0000000..f6f2384 --- /dev/null +++ b/packages/wasm-bip32/tsconfig.cjs.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "node", + "rootDir": ".", + "outDir": "./dist/cjs" + }, + "exclude": ["test/**/*"] +} diff --git a/packages/wasm-bip32/tsconfig.json b/packages/wasm-bip32/tsconfig.json new file mode 100644 index 0000000..97f8a7a --- /dev/null +++ b/packages/wasm-bip32/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "ES2022", + "target": "ES2022", + "moduleResolution": "bundler", + "esModuleInterop": true, + "allowJs": true, + "skipLibCheck": true, + "declaration": true, + "composite": true, + "rootDir": ".", + "outDir": "./dist/esm", + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["./js/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "./js/wasm/**/*"] +}