did-btcr2-js

ADR 009: Sans-I/O Foundation at the Bitcoin Transport Layer

Status: Accepted

Date: 2025-11-25

Commit: 190de07

Context

Once the Bitcoin client was its own package (ADR 005), the next problem surfaced: the package was still doing I/O inline. Every REST call and every RPC call:

Four concrete pains came from this shape:

  1. Tests monkey-patched global fetch. Every spec file had its own save/restore dance. A thrown error leaked a stub into the next test. CI flakes tracked back to this more than once.
  2. BitcoinNetworkConnection juggled multiple networks simultaneously. One object held connections for mainnet, testnet, signet, regtest: a leftover from earlier design where the resolver picked network at call time. In practice every caller used exactly one network, and the multi-network object was pure ceremony.
  3. RPC code was not ergonomic. RPC responses were loosely typed; JSON-RPC batching was simulated with sequential calls; error mapping (Bitcoin Core returns specific code to we want a typed error) happened in several places with minor variations.
  4. The API facade was monolithic. @did-btcr2/api exposed a single flat surface (api.create, api.resolve, api.update, api.keyManager, api.multikey, …). Adding a new concern meant growing that flat surface indefinitely.

The decision window was also the first opportunity to formalize a serialization convention across packages. Several classes (keypair, DID document, multikey, signed updates) were being passed into canonicalization and into JSON.stringify with inconsistent results: some had toJSON(), some didn’t, some leaked secret material. A ground-truth convention was overdue.

Options considered

  1. Keep direct fetch; add retry/timeout inline; leave tests monkey-patching. Minimal change. Every existing pain persists. Each new test adds another monkey-patch.
  2. Wrap fetch in a thin executor class with retry/timeout, inject through constructors. Solves the test-seam problem but still interleaves request-building with request-sending; still no way to unit-test request shape without running the executor.
  3. Separate “what request to make” from “how to send it”: a pure protocol layer that produces request descriptors, and a transport that executes them. Inject the transport; make the protocol layer pure. Tests drive the protocol layer directly; transports are swappable; the protocol layer is trivially fuzzable.

Decision

Option 3. Commit 190de07 lands six coordinated changes that together establish sans-I/O as the pattern for transport-adjacent code in the monorepo:

1. HttpExecutor abstraction. A minimal interface: effectively (HttpRequest) => Promise<HttpResponse>: that the Bitcoin package accepts via constructor injection. The default executor wraps globalThis.fetch with AbortSignal.timeout() for deadlines. Tests pass a scripted executor that returns canned responses. No global monkey-patching.

2. EsploraProtocol and RpcProtocol (sans-I/O). Pure request-builder classes. EsploraProtocol.getAddressTxs(address) returns a HttpRequest describing method, path, query, headers: and that’s all. The caller (the REST client) sends it through the HttpExecutor. Unit tests on the protocol layer don’t need a transport at all; they assert on the descriptor shape. This is the pattern that later migrates to the Resolver (ADR 016) and Updater (ADR 025) state machines, and to the HTTP server primitives (ADR 032).

3. Single-network BitcoinConnection replaces multi-network BitcoinNetworkConnection. One instance = one network. Callers that actually need more than one connection build more than one instance. The multi-network abstraction had zero callers that needed it.

4. Rewritten RPC client with typed method maps and real JSON-RPC batching. Each RPC method is a typed key/value in a methods map; responses flow through with full type inference. JSON-RPC [request, request, ...] batch arrays are now a first-class code path. RpcErrorType, EsploraBlock, NetworkName are proper types rather than loose strings.

5. API facade split into sub-facades. @did-btcr2/api restructures from a flat object into a tree of concern-specific sub-facades: CryptoApi (with nested MultikeyApi, CryptosuiteApi, DataIntegrityProofApi), BitcoinApi, KeyManagerApi, DidApi, DidMethodApi. Each sub-facade owns its configuration, its lifecycle (use() / clear() / current on the crypto sub-facades for optionally-stateful activation), and its dispose path. Top-level convenience shortcuts (api.sign(), api.signDocument()) delegate down the tree. This is the foundation that ADR 024 later extends with lazy construction and layered config.

6. toJSON() convention introduced across packages. Every class that participates in canonicalization or appears on the API boundary implements toJSON() returning a stable, safe JSON shape. Secret-bearing classes return redacted output from toJSON() and expose exportJSON() for explicit serialization. This convention is formalized as a cross-package rule in ADR 014; the foundation was laid here.

Supporting changes in the same commit:

Consequences

Positive

Negative

Explicitly accepted trade-offs

References