Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d81335c
docs: design for pluggable BDK wasm script-verification backend (@bsv…
sirdeggen Jun 8, 2026
1d45aab
docs: rename ScriptVerificationBackend -> BdkVerifierInterface
sirdeggen Jun 8, 2026
bb74549
docs: add verifast implementation plan + monorepo linkage addendum
sirdeggen Jun 8, 2026
35d9813
feat(sdk): add BdkVerifierInterface for pluggable script verification
sirdeggen Jun 8, 2026
4d5c49f
feat(sdk): optional verifier backend in Transaction.verify, bypasses …
sirdeggen Jun 8, 2026
6c3e96d
chore(verifast): scaffold @bsv/verifast package
sirdeggen Jun 8, 2026
132f845
feat(verifast): flag string -> BDK uint32 bitfield mapping
sirdeggen Jun 8, 2026
85cbd7e
feat(verifast): BdkVerifier adapter marshalling tx.toEF + heights + f…
sirdeggen Jun 8, 2026
4079fb3
test(verifast): deterministic P2PKH transaction corpus for bench/equi…
sirdeggen Jun 8, 2026
d4b0d4c
test(verifast): JS-vs-BDK equivalence corpus (skips without real wasm)
sirdeggen Jun 8, 2026
7415562
test(verifast): inputs/sec benchmark harness, JS vs BDK
sirdeggen Jun 8, 2026
1515ae3
docs(verifast): usage, supply-your-own-wasm, and caveats
sirdeggen Jun 8, 2026
e6db1ba
Merge branch 'main' into feat/bdk
sirdeggen Jun 16, 2026
14f46ba
Merge branch 'main' into feat/bdk
sirdeggen Jun 16, 2026
b58369c
docs(superpowers): add required frontmatter to verifast BDK plan/spec
sirdeggen Jun 16, 2026
e4899e0
fix(verifast): resolve sonar issues and close codecov gaps on PR 186
sirdeggen Jun 16, 2026
420701c
fix(ci): regenerate pnpm-lockfile to repair badly-merged peer entries
sirdeggen Jun 16, 2026
412d8cb
Merge remote-tracking branch 'origin/main' into feat/bdk
sirdeggen Jun 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,161 changes: 1,161 additions & 0 deletions docs/superpowers/plans/2026-06-08-verifast-bdk-backend.md

Large diffs are not rendered by default.

208 changes: 208 additions & 0 deletions docs/superpowers/specs/2026-06-08-verifast-bdk-backend-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
---
id: verifast-bdk-backend-design
title: "Design: Pluggable C++/WASM script-verification backend (@bsv/verifast + BDK)"
kind: spec
version: "n/a"
last_updated: "2026-06-08"
last_verified: "2026-06-16"
status: experimental
tags: [verifast, bdk, wasm, design]
---

# Design: Pluggable C++/WASM script-verification backend (`@bsv/verifast` + BDK)

**Date:** 2026-06-08
**Branch:** `feat/bdk`
**Status:** Approved design, pre-implementation

## Goal

Let transaction verification optionally run through the BSV C++ Bitcoin Development
Kit ([bitcoin-sv/bdk](https://github.com/bitcoin-sv/bdk)) compiled to WASM, for
performance, while keeping the existing pure-TypeScript interpreter as the default
and sole fallback path. Deliver a benchmark harness to prove (or disprove) the
speedup against real transaction validation.

## Background / constraints discovered

- **TS `Spend`** (`packages/sdk/src/script/Spend.ts`) is a **per-input**, step-based
script interpreter. Entry point `validate()`. It is constructed once per input in
a loop inside `Transaction.verify()` (`packages/sdk/src/transaction/Transaction.ts:889-932`).
- **BDK WASM** exposes a single function (`module/typesbdk/wasm/txvalidator_wasm.h`):
```cpp
int VerifyScriptWASM(
const std::vector<uint8_t>& extendedTX,
const std::vector<int32_t>& utxoHeights,
int32_t blockHeight,
bool consensus,
const std::vector<uint32_t>& customFlags);
```
bound to JS via embind as
`bdk.VerifyScript(extendedTX, utxoHeights, blockHeight, consensus, customFlags)`.
It is **whole-transaction**: it consumes an *extended* transaction (all inputs,
with source satoshis + locking scripts inlined) and returns an int result code.
There is **no per-input API, no step()/stack introspection.**
- Therefore BDK's natural seam is `Transaction.verify()`, **not** `Spend`.
- The prebuilt `module/typesbdk/wasm/bdk-core.{mjs,wasm}` in the BDK repo is an
explicit **smoke-test stub** — per its README, it only confirms the module loads
and `VerifyScript` is callable; the verification logic is *not* validated against
real data. Real use requires building the wasm from C++
(emscripten + boost 1.85 + openssl 3.4).
- BDK uses a `uint32` flag bitfield; TS uses string flags
(`P2SH`, `MINIMALDATA`, `GENESIS`, …). A mapping is required.

## Decisions

| # | Decision |
|---|----------|
| 1 | Hook the backend at `Transaction.verify()`. `Spend` is untouched and remains the default pure-JS path. |
| 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. |
| 3 | **Strict, no fallback.** If a backend is configured, its boolean is authoritative; a throw / load-failure propagates as an error. |
| 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. |
| 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. |
| 6 | Benchmarks are a first-class deliverable: an equivalence corpus (JS vs backend, assert-equal) and a perf harness (inputs/sec). |

## Architecture

### Core SDK change (minimal)

New interface (new file `packages/sdk/src/transaction/BdkVerifierInterface.ts`,
re-exported from `mod.ts`):

```ts
export interface BdkVerifierInterface {
/**
* Verify ALL input scripts of a single transaction.
* Resolves true/false for script validity; throws if the backend itself
* fails (load error, marshalling error, unavailable).
*/
verifyScripts (params: {
tx: Transaction
blockHeight: number
consensus: boolean
verifyFlags?: string | string[]
}): Promise<boolean>
}
```

`Transaction.verify()` gains an optional trailing param:

```ts
async verify (
chainTracker: ChainTracker | 'scripts only' = defaultChainTracker(),
feeModel?: FeeModel,
memoryLimit?: number,
verifier?: BdkVerifierInterface
): Promise<boolean>
```

Behaviour change inside the per-tx body:
- The input loop still runs for **source-tx recursion** and **`inputTotal`** accumulation.
- The script-validity check changes:
- **No backend (default):** per input, construct `Spend` and call `validate()` exactly as today.
- **Backend present:** skip per-input `Spend`; after the loop, call
`await verifier.verifyScripts({ tx, blockHeight, consensus, verifyFlags })` **once**.
If it resolves `false`, `verify()` returns `false`. If it throws, the error propagates.
- `blockHeight` / `consensus` / `verifyFlags` for the backend call: default
`consensus = true`; `verifyFlags` undefined (BDK default policy); `blockHeight`
derived from `tx.merklePath?.blockHeight` else the 943816 fallback. (These may be
surfaced as `verify()` options in a later iteration; not required for v1.)

No other SDK behaviour changes. `Spend` is not modified.

### `@bsv/verifast` package

```
packages/verifast/
package.json # name @bsv/verifast, deps: @bsv/sdk
src/
mod.ts # exports BdkVerifier, flag map, types
BdkVerifier.ts # implements BdkVerifierInterface
flags.ts # TS string flags -> BDK uint32 bitfield
wasm/ # (gitignored) user-supplied bdk-core.{mjs,wasm}
__tests/
BdkVerifier.test.ts
bench/
corpus.ts # builds/loads the test transaction corpus
equivalence.test.ts # JS Spend vs BdkVerifier, assert-equal
benchmark.ts # inputs/sec, JS vs backend
README.md # build-your-own-wasm instructions
```

`BdkVerifier`:
- Constructor takes a wasm-module factory (so tests inject a mock; prod passes the
real `createBdkModule`). Lazy-inits the module once, memoised.
- `verifyScripts`:
1. `extendedTX = tx.toEF()` → `VectorUInt8`.
2. `utxoHeights`: per input, `merklePath.blockHeight ?? 943816` → `VectorInt32`.
3. `customFlags`: map `verifyFlags` via `flags.ts` → `VectorUInt32`.
4. `result = bdk.VerifyScript(extendedTX, utxoHeights, blockHeight, consensus, customFlags)`.
5. Free all embind vectors (`.delete()`/`.free()`), in a `finally`.
6. Return `result === <success code>`.

### Key mappings (correctness risk)

1. **Extended-tx format** — assume BDK `extendedTX` == BSV EF (BRC-30) emitted by
`tx.toEF()`. **Assumption to confirm** against a real wasm build.
2. **UTXO heights** — see decision #4; fallback 943816.
3. **Flag mapping** — `flags.ts` table from TS string flags to BDK `SCRIPT_VERIFY_*`
bits. Primary correctness risk; guarded by the equivalence corpus.

## Testing & benchmarks

- **Mock backend test** (SDK): a fake `BdkVerifierInterface` proves
`Transaction.verify(..., backend)` routes through the backend, returns its bool,
and propagates its throw — no wasm needed.
- **Equivalence corpus** (`bench/equivalence.test.ts`): N transactions covering
P2PKH, bare multisig, CLTV/CSV, and large data-push scripts. Each verified by both
pure-JS `Spend` and `BdkVerifier`; assert identical boolean. Guards the strict
no-fallback choice and the flag mapping. Skips automatically if no real wasm is present.
- **Benchmark harness** (`bench/benchmark.ts`): same corpus, warm wasm (module load
excluded), measure inputs/sec for pure-JS vs backend; report speedup and per-call
marshalling overhead. Runs against any supplied backend; with the mock it measures
marshalling only.

## Monorepo linkage constraint (discovered)

The root `package.json` sets `pnpm.overrides["@bsv/sdk"] = "2.1.3"`. This forces
**every** workspace consumer — even those declaring `"@bsv/sdk": "workspace:^"` — to
resolve to the **published** registry `@bsv/sdk@2.1.3`, not the local `packages/sdk`
source (currently `2.1.4`). Verified: `packages/wallet/wallet-toolbox` and
`packages/middleware/auth` both symlink to `.pnpm/@bsv+sdk@2.1.3`. So local SDK
source changes are NOT visible to sibling packages via `@bsv/sdk`.

Consequences for this work:

1. **Core SDK changes** (`BdkVerifierInterface`, the `Transaction.verify` param, the
mock-backed seam test) live in `packages/sdk` and are exercised by **`packages/sdk`'s
own jest suite**, which compiles local source directly. This works today with no
linkage workaround.
2. **`@bsv/verifast`'s `BdkVerifier` + `flags.ts`** depend only on
`Transaction.toEF()` (present in published 2.1.3) and the *shape* of
`BdkVerifierInterface` (structural — `BdkVerifier` re-declares a local copy of the
interface and `implements` it, so it does not need the unpublished export).
3. **`@bsv/verifast`'s equivalence + benchmark harness** needs the *new*
`Transaction.verify(verifier)` signature, which only exists in local SDK source.
To avoid touching the repo-wide override, verifast configures a **jest
`moduleNameMapper` / tsconfig path** that maps `@bsv/sdk` → `../sdk/mod.ts` (local
source) for its own test/bench runs only. The published dep declaration stays for
type/publish hygiene; the mapper is dev-only and isolated to verifast.

The root override is deliberately left untouched.

## Out of scope / caveats

- Building a real, logic-validated `bdk-core.wasm` (upstream's is a stub). Documented
in the package README; real benchmark numbers require it.
- Surfacing `consensus` / explicit `blockHeight` / `verifyFlags` as first-class
`verify()` options (later iteration).
- Per-input/step backend execution — impossible with BDK's whole-tx-only API.

## Acceptance

- `@bsv/sdk` exposes `BdkVerifierInterface`; `Transaction.verify` accepts and
uses it; default path (no backend) behaviour byte-for-byte unchanged; mock-backed
tests green.
- `@bsv/verifast` builds, lints, `BdkVerifier` unit-tested against a mock wasm.
- Equivalence + benchmark harness present and runnable; documented how to supply a
real wasm to get real numbers.
22 changes: 22 additions & 0 deletions packages/sdk/src/transaction/BdkVerifierInterface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type Transaction from './Transaction.js'

/**
* A pluggable backend that verifies ALL input scripts of a single transaction.
*
* Implementations (e.g. @bsv/verifast's BdkVerifier) typically delegate to a
* native/WASM engine. The backend operates at whole-transaction granularity,
* not per input.
*/
export default interface BdkVerifierInterface {
/**
* Verify all input scripts of `params.tx`.
* @returns Promise resolving true if every input script is valid, false otherwise.
* @throws If the backend itself fails (load error, marshalling error, unavailable).
*/
verifyScripts: (params: {
tx: Transaction
blockHeight: number
consensus: boolean
verifyFlags?: string | string[]
}) => Promise<boolean>
}
57 changes: 40 additions & 17 deletions packages/sdk/src/transaction/Transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import P2PKH from '../script/templates/P2PKH.js'
import type { WalletInterface, DescriptionString5to50Bytes, CreateActionOptions } from '../wallet/Wallet.interfaces.js'
import TransactionSignature from '../primitives/TransactionSignature.js'
import Random from '../primitives/Random.js'
import type BdkVerifierInterface from './BdkVerifierInterface.js'

/** Post-Chronicle height used when an input's source UTXO mined-height is unobtainable. */
const POST_CHRONICLE_HEIGHT_FALLBACK = 943816

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

const spend = new Spend({
sourceTXID: input.sourceTXID,
sourceOutputIndex: input.sourceOutputIndex,
lockingScript: sourceOutput.lockingScript,
sourceSatoshis: sourceOutput.satoshis ?? 0,
transactionVersion: tx.version,
otherInputs,
unlockingScript: input.unlockingScript,
inputSequence: input.sequence ?? 0xffffffff, // default to max sequence
inputIndex: i,
outputs: tx.outputs,
lockTime: tx.lockTime,
memoryLimit
})
const spendValid = spend.validate()
if (verifier === undefined) {
const spend = new Spend({
sourceTXID: input.sourceTXID,
sourceOutputIndex: input.sourceOutputIndex,
lockingScript: sourceOutput.lockingScript,
sourceSatoshis: sourceOutput.satoshis ?? 0,
transactionVersion: tx.version,
otherInputs,
unlockingScript: input.unlockingScript,
inputSequence: input.sequence ?? 0xffffffff, // default to max sequence
inputIndex: i,
outputs: tx.outputs,
lockTime: tx.lockTime,
memoryLimit
})
const spendValid = spend.validate()

if (!spendValid) {
return false
}
}
}

if (!spendValid) {
// When a pluggable verifier is configured, hand the whole transaction to it
// once (BDK operates at whole-tx granularity). Strict: its verdict is
// authoritative and any thrown error propagates (no JS fallback).
if (verifier !== undefined) {
// A tx reaching here has no merkle proof (mined txs short-circuit above),
// so its source UTXO mined-height is unobtainable -> post-Chronicle fallback.
const scriptsValid = await verifier.verifyScripts({
tx,
blockHeight: POST_CHRONICLE_HEIGHT_FALLBACK,
consensus: true
})
if (!scriptsValid) {
return false
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Transaction from '../Transaction'
import Script from '../../script/Script'
import P2PKH from '../../script/templates/P2PKH'
import PrivateKey from '../../primitives/PrivateKey'
import MerklePath from '../MerklePath'
import type BdkVerifierInterface from '../BdkVerifierInterface'

// Build a tx whose single P2PKH input is genuinely valid under the pure-JS interpreter.
async function buildValidTx (): Promise<Transaction> {
const key = PrivateKey.fromRandom()
const source = new Transaction()
source.addInput({
sourceTXID: '00'.repeat(32),
sourceOutputIndex: 0,
unlockingScript: Script.fromASM('OP_TRUE')
})
source.addOutput({ satoshis: 2, lockingScript: new P2PKH().lock(key.toAddress()) })
await source.sign()
source.merklePath = new MerklePath(1000, [
[{ offset: 0, hash: source.id('hex'), txid: true }, { offset: 1, duplicate: true }]
])

const tx = new Transaction()
tx.addInput({
sourceTransaction: source,
sourceOutputIndex: 0,
unlockingScriptTemplate: new P2PKH().unlock(key)
})
tx.addOutput({ satoshis: 1, lockingScript: new P2PKH().lock(key.toAddress()) })
await tx.sign()
return tx
}

describe('Transaction.verify with a pluggable verifier', () => {
it('routes to the verifier and returns its false result, bypassing Spend', async () => {
const tx = await buildValidTx()
let called = 0
const verifier: BdkVerifierInterface = {
verifyScripts: async () => { called++; return false }
}
// Pure-JS would return true; verifier says false -> proves bypass + routing.
const result = await tx.verify('scripts only', undefined, undefined, verifier)
expect(called).toBe(1)
expect(result).toBe(false)
})

it('returns true when the verifier approves', async () => {
const tx = await buildValidTx()
const verifier: BdkVerifierInterface = { verifyScripts: async () => true }
const result = await tx.verify('scripts only', undefined, undefined, verifier)
expect(result).toBe(true)
})

it('propagates a verifier throw (strict, no fallback)', async () => {
const tx = await buildValidTx()
const verifier: BdkVerifierInterface = {
verifyScripts: async () => { throw new Error('wasm unavailable') }
}
await expect(tx.verify('scripts only', undefined, undefined, verifier))
.rejects.toThrow('wasm unavailable')
})

it('passes the post-Chronicle fallback blockHeight (943816) to the verifier', async () => {
const tx = await buildValidTx() // unmined tx -> no merkle proof -> fallback height
let seenHeight = -1
const verifier: BdkVerifierInterface = {
verifyScripts: async ({ blockHeight }) => { seenHeight = blockHeight; return true }
}
await tx.verify('scripts only', undefined, undefined, verifier)
expect(seenHeight).toBe(943816)
})
})
1 change: 1 addition & 0 deletions packages/sdk/src/transaction/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { default as Transaction } from './Transaction.js'
export type { default as BdkVerifierInterface } from './BdkVerifierInterface.js'
export { default as MerklePath } from './MerklePath.js'
export type { default as TransactionInput } from './TransactionInput.js'
export type { default as TransactionOutput } from './TransactionOutput.js'
Expand Down
4 changes: 4 additions & 0 deletions packages/verifast/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
dist
node_modules
src/wasm/*.wasm
src/wasm/*.mjs
Loading
Loading