|
| 1 | +--- |
| 2 | +id: verifast-bdk-backend-design |
| 3 | +title: "Design: Pluggable C++/WASM script-verification backend (@bsv/verifast + BDK)" |
| 4 | +kind: spec |
| 5 | +version: "n/a" |
| 6 | +last_updated: "2026-06-08" |
| 7 | +last_verified: "2026-06-16" |
| 8 | +status: experimental |
| 9 | +tags: [verifast, bdk, wasm, design] |
| 10 | +--- |
| 11 | + |
| 12 | +# Design: Pluggable C++/WASM script-verification backend (`@bsv/verifast` + BDK) |
| 13 | + |
| 14 | +**Date:** 2026-06-08 |
| 15 | +**Branch:** `feat/bdk` |
| 16 | +**Status:** Approved design, pre-implementation |
| 17 | + |
| 18 | +## Goal |
| 19 | + |
| 20 | +Let transaction verification optionally run through the BSV C++ Bitcoin Development |
| 21 | +Kit ([bitcoin-sv/bdk](https://github.com/bitcoin-sv/bdk)) compiled to WASM, for |
| 22 | +performance, while keeping the existing pure-TypeScript interpreter as the default |
| 23 | +and sole fallback path. Deliver a benchmark harness to prove (or disprove) the |
| 24 | +speedup against real transaction validation. |
| 25 | + |
| 26 | +## Background / constraints discovered |
| 27 | + |
| 28 | +- **TS `Spend`** (`packages/sdk/src/script/Spend.ts`) is a **per-input**, step-based |
| 29 | + script interpreter. Entry point `validate()`. It is constructed once per input in |
| 30 | + a loop inside `Transaction.verify()` (`packages/sdk/src/transaction/Transaction.ts:889-932`). |
| 31 | +- **BDK WASM** exposes a single function (`module/typesbdk/wasm/txvalidator_wasm.h`): |
| 32 | + ```cpp |
| 33 | + int VerifyScriptWASM( |
| 34 | + const std::vector<uint8_t>& extendedTX, |
| 35 | + const std::vector<int32_t>& utxoHeights, |
| 36 | + int32_t blockHeight, |
| 37 | + bool consensus, |
| 38 | + const std::vector<uint32_t>& customFlags); |
| 39 | + ``` |
| 40 | + bound to JS via embind as |
| 41 | + `bdk.VerifyScript(extendedTX, utxoHeights, blockHeight, consensus, customFlags)`. |
| 42 | + It is **whole-transaction**: it consumes an *extended* transaction (all inputs, |
| 43 | + with source satoshis + locking scripts inlined) and returns an int result code. |
| 44 | + There is **no per-input API, no step()/stack introspection.** |
| 45 | +- Therefore BDK's natural seam is `Transaction.verify()`, **not** `Spend`. |
| 46 | +- The prebuilt `module/typesbdk/wasm/bdk-core.{mjs,wasm}` in the BDK repo is an |
| 47 | + explicit **smoke-test stub** — per its README, it only confirms the module loads |
| 48 | + and `VerifyScript` is callable; the verification logic is *not* validated against |
| 49 | + real data. Real use requires building the wasm from C++ |
| 50 | + (emscripten + boost 1.85 + openssl 3.4). |
| 51 | +- BDK uses a `uint32` flag bitfield; TS uses string flags |
| 52 | + (`P2SH`, `MINIMALDATA`, `GENESIS`, …). A mapping is required. |
| 53 | +
|
| 54 | +## Decisions |
| 55 | +
|
| 56 | +| # | Decision | |
| 57 | +|---|----------| |
| 58 | +| 1 | Hook the backend at `Transaction.verify()`. `Spend` is untouched and remains the default pure-JS path. | |
| 59 | +| 2 | Adapter + wasm live in a **new monorepo package `@bsv/verifast`** depending on `@bsv/sdk`. Core SDK gains only a small interface, zero new deps. | |
| 60 | +| 3 | **Strict, no fallback.** If a backend is configured, its boolean is authoritative; a throw / load-failure propagates as an error. | |
| 61 | +| 4 | UTXO height per input = `input.sourceTransaction.merklePath.blockHeight` if present, else **943816** (a post-Chronicle height) so consensus rules evaluate as current-network, not early-activation. | |
| 62 | +| 5 | **Supply-your-own wasm.** This work delivers the architecture, adapter, marshalling, mock-backed tests, and benchmark harness. Building a real `bdk-core.wasm` is a documented, out-of-session step. | |
| 63 | +| 6 | Benchmarks are a first-class deliverable: an equivalence corpus (JS vs backend, assert-equal) and a perf harness (inputs/sec). | |
| 64 | +
|
| 65 | +## Architecture |
| 66 | +
|
| 67 | +### Core SDK change (minimal) |
| 68 | +
|
| 69 | +New interface (new file `packages/sdk/src/transaction/BdkVerifierInterface.ts`, |
| 70 | +re-exported from `mod.ts`): |
| 71 | +
|
| 72 | +```ts |
| 73 | +export interface BdkVerifierInterface { |
| 74 | + /** |
| 75 | + * Verify ALL input scripts of a single transaction. |
| 76 | + * Resolves true/false for script validity; throws if the backend itself |
| 77 | + * fails (load error, marshalling error, unavailable). |
| 78 | + */ |
| 79 | + verifyScripts (params: { |
| 80 | + tx: Transaction |
| 81 | + blockHeight: number |
| 82 | + consensus: boolean |
| 83 | + verifyFlags?: string | string[] |
| 84 | + }): Promise<boolean> |
| 85 | +} |
| 86 | +``` |
| 87 | + |
| 88 | +`Transaction.verify()` gains an optional trailing param: |
| 89 | + |
| 90 | +```ts |
| 91 | +async verify ( |
| 92 | + chainTracker: ChainTracker | 'scripts only' = defaultChainTracker(), |
| 93 | + feeModel?: FeeModel, |
| 94 | + memoryLimit?: number, |
| 95 | + verifier?: BdkVerifierInterface |
| 96 | +): Promise<boolean> |
| 97 | +``` |
| 98 | + |
| 99 | +Behaviour change inside the per-tx body: |
| 100 | +- The input loop still runs for **source-tx recursion** and **`inputTotal`** accumulation. |
| 101 | +- The script-validity check changes: |
| 102 | + - **No backend (default):** per input, construct `Spend` and call `validate()` exactly as today. |
| 103 | + - **Backend present:** skip per-input `Spend`; after the loop, call |
| 104 | + `await verifier.verifyScripts({ tx, blockHeight, consensus, verifyFlags })` **once**. |
| 105 | + If it resolves `false`, `verify()` returns `false`. If it throws, the error propagates. |
| 106 | +- `blockHeight` / `consensus` / `verifyFlags` for the backend call: default |
| 107 | + `consensus = true`; `verifyFlags` undefined (BDK default policy); `blockHeight` |
| 108 | + derived from `tx.merklePath?.blockHeight` else the 943816 fallback. (These may be |
| 109 | + surfaced as `verify()` options in a later iteration; not required for v1.) |
| 110 | + |
| 111 | +No other SDK behaviour changes. `Spend` is not modified. |
| 112 | + |
| 113 | +### `@bsv/verifast` package |
| 114 | + |
| 115 | +``` |
| 116 | +packages/verifast/ |
| 117 | + package.json # name @bsv/verifast, deps: @bsv/sdk |
| 118 | + src/ |
| 119 | + mod.ts # exports BdkVerifier, flag map, types |
| 120 | + BdkVerifier.ts # implements BdkVerifierInterface |
| 121 | + flags.ts # TS string flags -> BDK uint32 bitfield |
| 122 | + wasm/ # (gitignored) user-supplied bdk-core.{mjs,wasm} |
| 123 | + __tests/ |
| 124 | + BdkVerifier.test.ts |
| 125 | + bench/ |
| 126 | + corpus.ts # builds/loads the test transaction corpus |
| 127 | + equivalence.test.ts # JS Spend vs BdkVerifier, assert-equal |
| 128 | + benchmark.ts # inputs/sec, JS vs backend |
| 129 | + README.md # build-your-own-wasm instructions |
| 130 | +``` |
| 131 | + |
| 132 | +`BdkVerifier`: |
| 133 | +- Constructor takes a wasm-module factory (so tests inject a mock; prod passes the |
| 134 | + real `createBdkModule`). Lazy-inits the module once, memoised. |
| 135 | +- `verifyScripts`: |
| 136 | + 1. `extendedTX = tx.toEF()` → `VectorUInt8`. |
| 137 | + 2. `utxoHeights`: per input, `merklePath.blockHeight ?? 943816` → `VectorInt32`. |
| 138 | + 3. `customFlags`: map `verifyFlags` via `flags.ts` → `VectorUInt32`. |
| 139 | + 4. `result = bdk.VerifyScript(extendedTX, utxoHeights, blockHeight, consensus, customFlags)`. |
| 140 | + 5. Free all embind vectors (`.delete()`/`.free()`), in a `finally`. |
| 141 | + 6. Return `result === <success code>`. |
| 142 | + |
| 143 | +### Key mappings (correctness risk) |
| 144 | + |
| 145 | +1. **Extended-tx format** — assume BDK `extendedTX` == BSV EF (BRC-30) emitted by |
| 146 | + `tx.toEF()`. **Assumption to confirm** against a real wasm build. |
| 147 | +2. **UTXO heights** — see decision #4; fallback 943816. |
| 148 | +3. **Flag mapping** — `flags.ts` table from TS string flags to BDK `SCRIPT_VERIFY_*` |
| 149 | + bits. Primary correctness risk; guarded by the equivalence corpus. |
| 150 | + |
| 151 | +## Testing & benchmarks |
| 152 | + |
| 153 | +- **Mock backend test** (SDK): a fake `BdkVerifierInterface` proves |
| 154 | + `Transaction.verify(..., backend)` routes through the backend, returns its bool, |
| 155 | + and propagates its throw — no wasm needed. |
| 156 | +- **Equivalence corpus** (`bench/equivalence.test.ts`): N transactions covering |
| 157 | + P2PKH, bare multisig, CLTV/CSV, and large data-push scripts. Each verified by both |
| 158 | + pure-JS `Spend` and `BdkVerifier`; assert identical boolean. Guards the strict |
| 159 | + no-fallback choice and the flag mapping. Skips automatically if no real wasm is present. |
| 160 | +- **Benchmark harness** (`bench/benchmark.ts`): same corpus, warm wasm (module load |
| 161 | + excluded), measure inputs/sec for pure-JS vs backend; report speedup and per-call |
| 162 | + marshalling overhead. Runs against any supplied backend; with the mock it measures |
| 163 | + marshalling only. |
| 164 | + |
| 165 | +## Monorepo linkage constraint (discovered) |
| 166 | + |
| 167 | +The root `package.json` sets `pnpm.overrides["@bsv/sdk"] = "2.1.3"`. This forces |
| 168 | +**every** workspace consumer — even those declaring `"@bsv/sdk": "workspace:^"` — to |
| 169 | +resolve to the **published** registry `@bsv/sdk@2.1.3`, not the local `packages/sdk` |
| 170 | +source (currently `2.1.4`). Verified: `packages/wallet/wallet-toolbox` and |
| 171 | +`packages/middleware/auth` both symlink to `.pnpm/@bsv+sdk@2.1.3`. So local SDK |
| 172 | +source changes are NOT visible to sibling packages via `@bsv/sdk`. |
| 173 | + |
| 174 | +Consequences for this work: |
| 175 | + |
| 176 | +1. **Core SDK changes** (`BdkVerifierInterface`, the `Transaction.verify` param, the |
| 177 | + mock-backed seam test) live in `packages/sdk` and are exercised by **`packages/sdk`'s |
| 178 | + own jest suite**, which compiles local source directly. This works today with no |
| 179 | + linkage workaround. |
| 180 | +2. **`@bsv/verifast`'s `BdkVerifier` + `flags.ts`** depend only on |
| 181 | + `Transaction.toEF()` (present in published 2.1.3) and the *shape* of |
| 182 | + `BdkVerifierInterface` (structural — `BdkVerifier` re-declares a local copy of the |
| 183 | + interface and `implements` it, so it does not need the unpublished export). |
| 184 | +3. **`@bsv/verifast`'s equivalence + benchmark harness** needs the *new* |
| 185 | + `Transaction.verify(verifier)` signature, which only exists in local SDK source. |
| 186 | + To avoid touching the repo-wide override, verifast configures a **jest |
| 187 | + `moduleNameMapper` / tsconfig path** that maps `@bsv/sdk` → `../sdk/mod.ts` (local |
| 188 | + source) for its own test/bench runs only. The published dep declaration stays for |
| 189 | + type/publish hygiene; the mapper is dev-only and isolated to verifast. |
| 190 | + |
| 191 | +The root override is deliberately left untouched. |
| 192 | + |
| 193 | +## Out of scope / caveats |
| 194 | + |
| 195 | +- Building a real, logic-validated `bdk-core.wasm` (upstream's is a stub). Documented |
| 196 | + in the package README; real benchmark numbers require it. |
| 197 | +- Surfacing `consensus` / explicit `blockHeight` / `verifyFlags` as first-class |
| 198 | + `verify()` options (later iteration). |
| 199 | +- Per-input/step backend execution — impossible with BDK's whole-tx-only API. |
| 200 | + |
| 201 | +## Acceptance |
| 202 | + |
| 203 | +- `@bsv/sdk` exposes `BdkVerifierInterface`; `Transaction.verify` accepts and |
| 204 | + uses it; default path (no backend) behaviour byte-for-byte unchanged; mock-backed |
| 205 | + tests green. |
| 206 | +- `@bsv/verifast` builds, lints, `BdkVerifier` unit-tested against a mock wasm. |
| 207 | +- Equivalence + benchmark harness present and runnable; documented how to supply a |
| 208 | + real wasm to get real numbers. |
0 commit comments