# Architecture

## Design Goal

The proof suite answers one concrete question: can several real Bitcoin implementations hand the same PSBT
to one another and still produce a transaction that Bitcoin Core can finalize and accept under
policy? It does not reimplement wallet logic in TypeScript. The orchestrator controls the run while
each native library parses and acts on the PSBT itself.

Read the system from control to evidence. The proof engine coordinates isolated native runtimes.
Library adapters do not call one another; the HWI adapter invokes only its dedicated simulated
device process. Every returned PSBT crosses the semantic-policy layer before it becomes durable
evidence.

```mermaid
architecture-beta
  group control(cloud)[Control plane]
  group proof(cloud)[Proof engine]
  group runtimes(cloud)[Isolated runtimes]
  group evidence(cloud)[Evidence]

  service selection(disk)[Validated selection] in control
  service cli(server)[TypeScript CLI] in control

  service engine(server)[Scenario engine] in proof
  service rules(server)[Transition rules] in proof
  service facts(database)[Wire facts] in proof

  service core(server)[Bitcoin Core v31] in runtimes
  service v0(server)[PSBTv0 adapters] in runtimes
  service v2(server)[PSBTv2 adapters] in runtimes
  service musig(server)[MuSig2 signers] in runtimes
  service hwi(server)[HWI adapter] in runtimes
  service device(server)[Simulated device] in runtimes

  service checkpoints(disk)[Private checkpoints] in evidence
  service reports(disk)[JSON Markdown HTML] in evidence
  service replay(server)[Replay and compare] in evidence

  selection:R --> L:cli
  cli:R --> L:engine
  engine:B --> T:core
  engine:B --> T:v0
  engine:B --> T:v2
  engine:B --> T:musig
  engine:B --> T:hwi
  hwi:R --> L:device
  engine:R --> L:rules
  rules:R --> L:facts
  facts:R --> L:checkpoints
  checkpoints:B --> T:reports
  checkpoints:R --> L:replay
```

| Runtime family | Native implementations |
| --- | --- |
| PSBTv0 adapters | rust-bitcoin 0.32.102, btcsuite psbt 1.2.0, bitcoinjs-lib 7.0.1, BDK Wallet 3.1.0, and the frozen bdkpython 2.3.1 regression specimen |
| PSBTv2 adapters | rust-psbt 0.3.0 and libwally 1.5.4, including v0/v2 conversion paths |
| Stateful protocols | Independent Rust musig2 0.4.1 and TypeScript Scure 2.2.0 signers, plus the HWI simulator/device pair |

## Components

### TypeScript orchestrator

The CLI owns scenario order, Core RPC, adapter lifecycle, strict request and response validation,
timeouts, result classification, artifact writing, and replay. TypeScript is used for developer
experience and orchestration; no signature algorithm is implemented there.

Scenario and category selections are resolved before the runtime provider is created. Unknown or
empty selections therefore fail without starting Core, Docker, or adapter processes.

For every adapter round trip, `ScenarioExecutionContext.requireTransition` parses the source and
returned PSBT and applies the lossless `roundtrip` transition policy independently of the adapter's
`byteIdentical` claim. Legal map reordering is diagnostic rather than failure. `CoreRpc.call` in
`src/core/rpc.ts` requires matching JSON-RPC request/response IDs, and fixture preparation requires
Bitcoin Core numeric version `310100`.

### Bitcoin Core fixture source and oracle

Core 31.1 mines a local regtest chain and funds deterministic public P2PKH, P2WPKH, nested
P2SH-P2WPKH, nested P2SH-P2WSH, single-key and 2-of-3 P2WSH, P2TR key-path, and P2TR script-path
descriptors, plus a `rawtr` output committed to the BIP327 aggregate key used by the MuSig2
fixture. It creates PSBTv0 with `createpsbt` and fills UTXO/script metadata with
`utxoupdatepsbt`. Intent fixtures add multiple outputs, RBF sequence, non-zero locktime, explicit
sighash type, and derivation metadata. At the end of each signing path,
`finalizepsbt` extracts the transaction and `testmempoolaccept` checks current consensus and mempool
policy without broadcasting it.

The Core RPC `version` argument to `createpsbt` is the unsigned transaction version. It is not the
PSBT format version. The generated fixture is inspected as PSBTv0 before any adapter receives it.

### Native adapters

Adapters speak `psbt-lab.adapter/0.2`, one JSON object per line. Every response repeats the request
ID and includes implementation name, version, and artifact digest. The status is one of `ok`,
`unsupported`, `rejected`, `crashed`, or `timeout`; failures use stable error classes rather than
language-specific stack traces.

Scenario execution records every adapter request as a scenario adapter cell with adapter ID,
operation, request ID, status, duration, and failure detail. Transport failures, malformed
responses, timeouts, and adapter-reported crash/timeout statuses are converted into failed cells;
restartable adapter processes are restarted before the next matrix cell is attempted.

Startup pins each adapter's self-reported name, version, source revision, operations, and declared
PSBT-version support as a compatibility check. A malicious adapter can spoof the expected identity strings and
supply any schema-valid self-reported digest; the runner does not compare a pinned content digest.
These values are not cryptographic attestation of the running image. Dockerfile base digests,
downloaded checksums, and dependency hashes support reproducible build selection rather than
runtime provenance.

Hello capabilities declare script support per operation. This prevents broad parsing or signing
support from being interpreted as support for finalizing the same script type; scenarios with an
unsupported operation/script pair are reported as unsupported before execution.

The `native-parse` operation removes the adapter's PSBT structural preflight from invalid-input
testing. After bounded canonical base64 decoding, the Rust, Go, JavaScript, or Python adapter calls
the native library parser directly and reports whether that parser accepted or rejected the bytes.

The Rust, Go, JavaScript, current BDK, rust-psbt-v2, and libwally adapters sign and finalize only
known run-committed fixture inputs. rust-psbt-v2 and libwally both exercise the official BIP370
corpus and run bidirectional PSBTv2 signing/finalization workflows. The Python adapter freezes the affected `bdkpython` 2.3.1 wheel and exposes
round-trip/finalize behavior. No adapter has network access at runtime.

Two independent MuSig2 adapters each own one deterministic regtest key and keep their
CSPRNG-seeded secret nonce only in memory. The first uses Rust `musig2` 0.4.1 and the second uses
TypeScript Scure 2.2.0. BIP373 participant,
public-nonce, and partial-signature fields carry the two-round protocol; live nonces expire, recent
session identifiers are held in a bounded replay cache, each partial is verified before
aggregation, and the final BIP340 signature is written as the standard Taproot key signature before
Core finalization. The resulting spend proves cross-library BIP327 signing agreement as well as
process and nonce-state isolation.

The HWI adapter first enumerates a separate JSON-speaking simulator process, then invokes its
`signtx` command with a fixed regtest BIP84 origin. The device process owns the deterministic key,
enforces simulated user confirmation, and returns the signed PSBT. The adapter independently checks
that the unsigned transaction and every non-signature field are unchanged. This validates the HWI
process contract and refusal workflow, not USB transport, secure elements, or vendor firmware.

### Wire facts and artifacts

The wire-facts parser reads BIP174/BIP370 framing directly so serialization changes cannot be
hidden by a library's normalized object model. It records PSBT version, byte length, SHA256, map
counts, and key/value sizes. Raw PSBT, script, and UTXO material is excluded from reports.
Implementation name, version, source revision, and self-reported digest metadata are intentionally
recorded and protected only by local file permissions.

Roundtrip checks preserve every field except one BIP370 equivalence: an omitted
`PSBT_GLOBAL_TX_MODIFIABLE` field and an explicit one-byte zero value both mean that no inputs or
outputs may be changed. The checker accepts only that missing-to-zero or zero-to-missing
normalization. Nonzero flags, any other addition/removal, and every value mutation still fail.
Signing, combining, and finalization additionally allow only BIP370-monotonic flag changes:
input/output modification permissions may be cleared, the sighash-single bit may only be set, and
unknown bits must remain unchanged.

Each handoff writes both the canonical base64 PSBT and its facts. The manifest records these files
alongside the run's self-reported implementation identities. Replay reparses each PSBT and verifies
its SHA256 against the manifest and stored facts JSON `sha256`; it does not recompute other stored
facts or recorded outcomes and does not rerun adapters. This does not authenticate the mutable
artifact directory.

`psbt-lab compare <base> <head>` first verifies both artifact manifests through the replay path,
then reports scenario status, finding, adapter-cell, and checkpoint changes between those runs.
Deterministic checkpoints compare their complete wire facts, including SHA256. Checkpoints that
contain fresh MuSig2 nonces or signatures opt into structural comparison: PSBT version, byte length,
map shape, field types, key-data sizes, and value sizes must match, while the entropy-dependent
SHA256 is excluded from cross-run comparison. The earlier exact participant checkpoint still
anchors the deterministic transaction and key set. Replay always verifies each stored PSBT's exact
SHA256 regardless of comparison policy. Comparison does not rerun Core or adapters and inherits
replay's local mutable-directory trust boundary.

Replay rejects absolute and lexically escaping checkpoint paths and caps a manifest at 1,000
checkpoints. Intermediate symlinks remain trusted. Final-component `O_NOFOLLOW` protection applies
only where Node exposes the flag.

## Runtime And CI Boundaries

Locally, one trusted developer controls the host account and Docker daemon. Core and all adapters
run with read-only roots, dropped capabilities, process/memory limits, and `no-new-privileges`; Core
alone retains its named regtest data volume and a bounded temporary `/tmp` mount. Adapters use no
network, while Core JSON-RPC is published only to host loopback. These settings reduce ordinary
process and resource exposure but do not protect against a compromised trusted host, Docker daemon,
kernel, or base image.

`psbt-lab parse-matrix --runtime local` is a separate Dockerless parser-only boundary. The package
loads a strict internal manifest, resolves only safe package-relative adapter paths, rejects
symlinks and paths outside the package, bounds each artifact at 16 MiB, verifies its pinned SHA256,
and executes a private read-only snapshot through the same bounded JSONL process protocol. The
current package provides one bundled JavaScript parser; native adapters without published local
binaries are reported as unsupported. This path does not start Core, sign, finalize, or provide the
complete interoperability proof. The spawned parser still runs with the invoking user's host
privileges and is trusted as package code rather than sandboxed code.

GitHub-hosted CI is separate from that runtime. `.github/workflows/ci.yml` gives jobs read-only
repository permission, no persisted checkout credential or workflow secrets, pinned action commits,
ephemeral runners, timeouts, and concurrency cancellation. Pull requests run the TypeScript, Rust,
Go, JavaScript, BDK, and libwally checks; the complete Docker proof runs only on `refs/heads/main` or a trusted
manual dispatch.
These controls mitigate credential, compute, and network abuse. Pull-request code can alter its own
tests and build scripts, so green means revision self-consistency rather than independent check
integrity. The workflow does not publish or attest a release image.

The detailed assumptions, abuse paths, and residual risks are recorded in the
[threat model](../psbt-interop-lab-threat-model.md).

## Proof Scenarios

The executable catalog currently contains 52 scenarios. Twelve independent Core-to-library
handoffs exercise rust-bitcoin, btcsuite, bitcoinjs, and current BDK signing for P2WSH, P2WPKH, and
P2TR key-path inputs. Additional rust-bitcoin handoffs prove legacy P2PKH signing from an exact
`non_witness_utxo` and nested P2SH-P2WSH 2-of-3 signing and finalization.
A same-input 2-of-3 scenario has Rust and JavaScript sign independent PSBT copies, combines their
partial signatures, and requires Core to finalize the union. A four-library chain proves
byte-semantic preservation across BDK, Rust, Go, and JavaScript. A parallel path has Rust sign input
zero and Go sign input one, then requires bitcoinjs to combine the union before Core accepts it.

The transaction-intent scenario roundtrips a multi-output P2WPKH fixture through all three current
adapters, signs it, and verifies transaction version, output amounts and scripts, RBF sequence,
non-zero locktime, explicit `SIGHASH_ALL`, and BIP32 derivation metadata before Core finalization.

Two sighash matrices exercise every standard ECDSA ALL, NONE, SINGLE, and ANYONECANPAY combination,
plus all standard Taproot key-path modes including DEFAULT. Native signature verification proves
which output and other-input mutations remain permitted and which committed-input mutations
invalidate the signature. The signer also refuses nonstandard ECDSA values and the invalid Taproot
DEFAULT plus ANYONECANPAY encoding.

An adversarial signer matrix submits wrong witness amounts and scripts, incorrect
`non_witness_utxo` transactions, redeem/witness script mismatches, derivation mismatches, and
Taproot internal-key or Merkle-root mismatches. A separate combiner matrix injects conflicting
UTXOs, scripts, sighash types, derivations, ECDSA partial signatures, and Taproot key signatures.
Every conflict has a deterministic classification and must be rejected before native combining.

The BIP373 MuSig2 scenario preserves its participant set through two independent PSBT parsers,
performs public-nonce exchange and partial signing in two isolated signer processes, rejects a
reused nonce session, verifies both partial signatures, aggregates a BIP340 signature, and requires
Core policy acceptance. The HWI scenario adds a fixed BIP84 key origin, proves an explicit
simulated-user refusal, signs through the separate device process, and permits only the expected
P2WPKH partial signature before Core finalization.

The rejection matrix runs five malformed or undeclared PSBT cases through all four native parser
paths. It currently records btcsuite 1.2.0 accepting a duplicate global unsigned-transaction key as
a compatibility finding. That one baseline cell may resolve to rejection without breaking the run;
another malformed acceptance, crash, or timeout still fails the scenario. Findings remain visible
in every report format and the CLI summary, while adapter cells show which implementation and
operation produced each failed, unsupported, or passed request. The metadata scenario injects both
generic unknown fields and valid BIP174 proprietary
entries into every global, input, and output map. It checks them through four roundtrips, three
independent signers, exact-union combining, Core PSBT finalization, and Core policy acceptance.
Three regression scenarios
reproduce BDK issue #488 after Rust, Go, or JavaScript prepares the same mixed finalized/partial
state; Core independently finalizes and policy-checks the same PSBT.

Two profile matrices roundtrip nested P2SH-P2WPKH and Taproot script-path PSBTs through the four
current PSBTv0 libraries. Two additional handoffs sign and finalize the exact committed Taproot
leaf in both Rust/BDK directions, while rejection canaries mutate its leaf and control block. The
general BIP371 finalization rule permits removal of Taproot output key-origin entries after every
input is final; removal during earlier transitions remains a preservation failure. The suite still
requires the exact committed script-path witness and Core policy acceptance.
PSBTv2 scenarios send all 14 valid and 21 invalid official BIP370 vectors through rust-psbt-v2 and
libwally, exercise native construction and locktime selection, then run bidirectional P2WPKH,
Taproot, and cross-library 2-of-3 workflows. The Taproot handoffs convert all six valid official
BIP371 vectors to v2 and back while checking key, signature, script, derivation, and Merkle-root
commitments through both native serializers. Separately, four PSBTv0 adapters parse all 17 official
BIP371 vectors. Core policy-checks every completed signing transaction. The rust-psbt-v2 adapter
serializes finalized SegWit PSBTs with canonical omission of empty final scriptSig fields and accepts
the same canonical form before native extraction; strict rejection of an explicit zero-length field
remains expected BIP174 behavior.

A bounded BIP375 sender scenario begins from the pre-sign form of official valid vector 01. The
rust-psbt-v2 adapter derives the ECDH share, BIP374 DLEQ proof, and BIP352 output script, locks input
and output mutation, signs with `SIGHASH_ALL`, verifies the finalized P2PKH script, and extracts the
transaction. The orchestrator independently validates the cryptography and exact field transition,
then confirms that mainnet and transaction-intent mutations are rejected before signing.

The advanced BIP375 sender scenario uses SHA256-pinned official valid vectors 02, 03, 06, 07, and
13. It covers global aggregation over multiple eligible inputs, per-input share placement, multiple
recipient scan keys, labels alongside ordinary change, and deterministic `k` ordering when a
recipient is repeated. The orchestrator independently validates every derived share, proof, and
output script, requires partial-signature fields in every returned input map, then requires exact
stable classifications for invalid vectors 11, 16, 18, 20, and 21. Those official fixtures'
supplied signing keys do not control their declared funding scripts, so the partial-signature
fields are not claimed as spend-valid and the scenario explicitly reports that finalization is
unavailable. The valid-01 sender is finalized, extracted, parsed by Core, and checked against
Core's txid calculation. Its official fixture spends an external parent that is not present in the
isolated regtest chain, so the lab explicitly reports policy acceptance as unavailable. The
Core-funded BIP376 receiver-spend scenario is finalized and Core-policy accepted.

A separate bounded BIP376 receiver-spend scenario starts from a Core-funded deterministic Taproot
output and uses libwally for the PSBTv0-to-v2 handoff. The rust-psbt-v2 adapter reads the registered
spend-key and output-tweak fields, derives and verifies the corresponding key-path output, signs and
finalizes it natively, and removes spent signing material. The orchestrator requires signature-only
mutation before finalization, rejects mainnet and a mismatched tweak, and asks Core to policy-check
the extracted transaction. This proves one deterministic receiver spend; it is not a wallet scanner
or a claim of arbitrary Silent Payment wallet support.

Scenario findings carry a stable rule ID and the actual observation into the central conformance
catalog. Classification adds normative level, authoritative source, expected behavior, severity,
repairability, and confidence before redaction and rendering to JSON, Markdown, and HTML. The
website imports generated catalog data, and CI fails if that checked-in view drifts from the source
catalog. See the [conformance classification policy](conformance-policy.md) for the diagnostic
contract.

`psbt-lab self-test` deliberately drops metadata, changes an output amount, changes an input
sequence, and removes a signature. It passes only when the semantic detectors identify every fault.

`psbt-lab run --scenario <id>` and `psbt-lab run --category <name>` execute a validated subset of
this catalog. The selection changes runtime cost, not assertion depth: each selected scenario keeps
the same adapter checks, semantic transitions, Core policy oracle, and report artifacts as a full
matrix run.

`psbt-lab fuzz --runtime local` generates up to 512 deterministic mutation recipes from a 32-bit
seed and compares the lossless lab parser with available native parsers. Structured mutations
operate on parsed PSBT maps; raw mutations are bounded XOR, truncation, and append operations.
Results normalize to accepted, rejected, unsupported, crashed, or timed out and include structural
facts for accepted parses. Interesting cases are minimized by deleting redundant operations and
shrinking payloads while preserving the exact parser outcome vector, then can be promoted into
SHA256-committed `psbt-lab.suite/0.2` parser regressions. Promoted expectations preserve both
classification and accepted structural facts. `psbt-lab parse-matrix --suite-manifest <path>`
replays those regressions through the same Dockerless provider and refuses any Core, signing,
combining, or finalization operation. Manifest validation also prevents parser fixtures from
entering those operations through the full matrix path.

`psbt-lab baseline` captures the standard comparison snapshot, and
`psbt-lab compare <base> <head>` checks whether a later run changed scenario outcomes, findings,
adapter cells, or checkpoint facts after replay-verifying both artifact directories.

`psbt-lab history <oldest> <...> <newest>` extends that comparison model across two or more
artifact directories supplied in oldest-to-newest order. It replay-verifies every run, compares
adjacent pairs, and classifies transitions as `unchanged`, `regression`, `improvement`, `mixed`, or
`changed`. The report records directional signals only for run outcomes, scenario outcomes,
assertion outcomes, and finding presence; coverage and structural differences stay neutral because
their direction is ambiguous. `--fail-on-regression` gates only a newest `regression` or `mixed`
transition. Optional output is an exclusive new directory containing deterministic `history.json`
and `history.md` files; caller paths, raw PSBTs, and checkpoint paths are not copied into either
report.

## Extension Points

`psbt-lab adapter check <manifest>` validates the external onboarding boundary. A strict versioned
manifest starts a trusted local command with `shell: false`, negotiates the JSONL protocol, verifies
the expected identity and baseline parser capabilities, probes valid and malformed native parsing,
and requires semantic roundtrip preservation.

`psbt-lab matrix --adapter-manifest <manifest>` then registers each external process by its manifest
ID while retaining the separately validated implementation identity. The runner preserves all 52
bundled scenarios and appends capability-gated P2WPKH, nested P2SH-P2WPKH, P2WSH, Taproot key-path,
and Taproot script-path parse and roundtrip scenarios, plus signing where declared. Run-scoped
unsigned-transaction commitments authorize only deterministic regtest fixtures. See
[the adapter guide](adapters.md).

`psbt-lab matrix --suite-manifest <manifest>` compiles a strict bounded manifest into Core-funded
fixtures and typed handoff scenarios. Users choose only fixed public descriptor templates and
structured operations. Suite 0.2 additionally permits SHA256-committed parser-only fixtures,
allowlisted mutations, and expected cross-parser classifications plus accepted structural facts.
Arbitrary commands, descriptors, keys, signing PSBTs, and adapter payloads remain rejected. Typed
dataflow prevents parser fixtures from entering signing operations and prevents a finalized
transaction result from being reused as a PSBT. Signing or input finalization of a custom
transaction fixture requires both the normal commitment feature and the separate
`user-fixture-template-v1` capability.
