Skip to content

Commit f2b2054

Browse files
authored
Merge pull request #186 from bsv-blockchain/feat/bdk
Pluggable BDK WASM script-verification backend (@bsv/verifast)
2 parents 55e5ca8 + 412d8cb commit f2b2054

22 files changed

Lines changed: 2161 additions & 20 deletions

docs/superpowers/plans/2026-06-08-verifast-bdk-backend.md

Lines changed: 1161 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
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.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type Transaction from './Transaction.js'
2+
3+
/**
4+
* A pluggable backend that verifies ALL input scripts of a single transaction.
5+
*
6+
* Implementations (e.g. @bsv/verifast's BdkVerifier) typically delegate to a
7+
* native/WASM engine. The backend operates at whole-transaction granularity,
8+
* not per input.
9+
*/
10+
export default interface BdkVerifierInterface {
11+
/**
12+
* Verify all input scripts of `params.tx`.
13+
* @returns Promise resolving true if every input script is valid, false otherwise.
14+
* @throws If the backend itself fails (load error, marshalling error, unavailable).
15+
*/
16+
verifyScripts: (params: {
17+
tx: Transaction
18+
blockHeight: number
19+
consensus: boolean
20+
verifyFlags?: string | string[]
21+
}) => Promise<boolean>
22+
}

packages/sdk/src/transaction/Transaction.ts

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import P2PKH from '../script/templates/P2PKH.js'
1818
import type { WalletInterface, DescriptionString5to50Bytes, CreateActionOptions } from '../wallet/Wallet.interfaces.js'
1919
import TransactionSignature from '../primitives/TransactionSignature.js'
2020
import Random from '../primitives/Random.js'
21+
import type BdkVerifierInterface from './BdkVerifierInterface.js'
22+
23+
/** Post-Chronicle height used when an input's source UTXO mined-height is unobtainable. */
24+
const POST_CHRONICLE_HEIGHT_FALLBACK = 943816
2125

2226
/**
2327
* Represents a complete Bitcoin transaction. This class encapsulates all the details
@@ -833,7 +837,8 @@ export default class Transaction {
833837
async verify (
834838
chainTracker: ChainTracker | 'scripts only' = defaultChainTracker(),
835839
feeModel?: FeeModel,
836-
memoryLimit?: number
840+
memoryLimit?: number,
841+
verifier?: BdkVerifierInterface
837842
): Promise<boolean> {
838843
const verifiedTxids = new Set<string>()
839844
const txQueue: Transaction[] = [this]
@@ -910,23 +915,41 @@ export default class Transaction {
910915
const otherInputs = tx.inputs.filter((_, idx) => idx !== i)
911916
input.sourceTXID ??= sourceTxid
912917

913-
const spend = new Spend({
914-
sourceTXID: input.sourceTXID,
915-
sourceOutputIndex: input.sourceOutputIndex,
916-
lockingScript: sourceOutput.lockingScript,
917-
sourceSatoshis: sourceOutput.satoshis ?? 0,
918-
transactionVersion: tx.version,
919-
otherInputs,
920-
unlockingScript: input.unlockingScript,
921-
inputSequence: input.sequence ?? 0xffffffff, // default to max sequence
922-
inputIndex: i,
923-
outputs: tx.outputs,
924-
lockTime: tx.lockTime,
925-
memoryLimit
926-
})
927-
const spendValid = spend.validate()
918+
if (verifier === undefined) {
919+
const spend = new Spend({
920+
sourceTXID: input.sourceTXID,
921+
sourceOutputIndex: input.sourceOutputIndex,
922+
lockingScript: sourceOutput.lockingScript,
923+
sourceSatoshis: sourceOutput.satoshis ?? 0,
924+
transactionVersion: tx.version,
925+
otherInputs,
926+
unlockingScript: input.unlockingScript,
927+
inputSequence: input.sequence ?? 0xffffffff, // default to max sequence
928+
inputIndex: i,
929+
outputs: tx.outputs,
930+
lockTime: tx.lockTime,
931+
memoryLimit
932+
})
933+
const spendValid = spend.validate()
934+
935+
if (!spendValid) {
936+
return false
937+
}
938+
}
939+
}
928940

929-
if (!spendValid) {
941+
// When a pluggable verifier is configured, hand the whole transaction to it
942+
// once (BDK operates at whole-tx granularity). Strict: its verdict is
943+
// authoritative and any thrown error propagates (no JS fallback).
944+
if (verifier !== undefined) {
945+
// A tx reaching here has no merkle proof (mined txs short-circuit above),
946+
// so its source UTXO mined-height is unobtainable -> post-Chronicle fallback.
947+
const scriptsValid = await verifier.verifyScripts({
948+
tx,
949+
blockHeight: POST_CHRONICLE_HEIGHT_FALLBACK,
950+
consensus: true
951+
})
952+
if (!scriptsValid) {
930953
return false
931954
}
932955
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import Transaction from '../Transaction'
2+
import Script from '../../script/Script'
3+
import P2PKH from '../../script/templates/P2PKH'
4+
import PrivateKey from '../../primitives/PrivateKey'
5+
import MerklePath from '../MerklePath'
6+
import type BdkVerifierInterface from '../BdkVerifierInterface'
7+
8+
// Build a tx whose single P2PKH input is genuinely valid under the pure-JS interpreter.
9+
async function buildValidTx (): Promise<Transaction> {
10+
const key = PrivateKey.fromRandom()
11+
const source = new Transaction()
12+
source.addInput({
13+
sourceTXID: '00'.repeat(32),
14+
sourceOutputIndex: 0,
15+
unlockingScript: Script.fromASM('OP_TRUE')
16+
})
17+
source.addOutput({ satoshis: 2, lockingScript: new P2PKH().lock(key.toAddress()) })
18+
await source.sign()
19+
source.merklePath = new MerklePath(1000, [
20+
[{ offset: 0, hash: source.id('hex'), txid: true }, { offset: 1, duplicate: true }]
21+
])
22+
23+
const tx = new Transaction()
24+
tx.addInput({
25+
sourceTransaction: source,
26+
sourceOutputIndex: 0,
27+
unlockingScriptTemplate: new P2PKH().unlock(key)
28+
})
29+
tx.addOutput({ satoshis: 1, lockingScript: new P2PKH().lock(key.toAddress()) })
30+
await tx.sign()
31+
return tx
32+
}
33+
34+
describe('Transaction.verify with a pluggable verifier', () => {
35+
it('routes to the verifier and returns its false result, bypassing Spend', async () => {
36+
const tx = await buildValidTx()
37+
let called = 0
38+
const verifier: BdkVerifierInterface = {
39+
verifyScripts: async () => { called++; return false }
40+
}
41+
// Pure-JS would return true; verifier says false -> proves bypass + routing.
42+
const result = await tx.verify('scripts only', undefined, undefined, verifier)
43+
expect(called).toBe(1)
44+
expect(result).toBe(false)
45+
})
46+
47+
it('returns true when the verifier approves', async () => {
48+
const tx = await buildValidTx()
49+
const verifier: BdkVerifierInterface = { verifyScripts: async () => true }
50+
const result = await tx.verify('scripts only', undefined, undefined, verifier)
51+
expect(result).toBe(true)
52+
})
53+
54+
it('propagates a verifier throw (strict, no fallback)', async () => {
55+
const tx = await buildValidTx()
56+
const verifier: BdkVerifierInterface = {
57+
verifyScripts: async () => { throw new Error('wasm unavailable') }
58+
}
59+
await expect(tx.verify('scripts only', undefined, undefined, verifier))
60+
.rejects.toThrow('wasm unavailable')
61+
})
62+
63+
it('passes the post-Chronicle fallback blockHeight (943816) to the verifier', async () => {
64+
const tx = await buildValidTx() // unmined tx -> no merkle proof -> fallback height
65+
let seenHeight = -1
66+
const verifier: BdkVerifierInterface = {
67+
verifyScripts: async ({ blockHeight }) => { seenHeight = blockHeight; return true }
68+
}
69+
await tx.verify('scripts only', undefined, undefined, verifier)
70+
expect(seenHeight).toBe(943816)
71+
})
72+
})

packages/sdk/src/transaction/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { default as Transaction } from './Transaction.js'
2+
export type { default as BdkVerifierInterface } from './BdkVerifierInterface.js'
23
export { default as MerklePath } from './MerklePath.js'
34
export type { default as TransactionInput } from './TransactionInput.js'
45
export type { default as TransactionOutput } from './TransactionOutput.js'

packages/verifast/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
dist
2+
node_modules
3+
src/wasm/*.wasm
4+
src/wasm/*.mjs

0 commit comments

Comments
 (0)