Status: Accepted
Date: 2025-11-25
Commit: 190de07
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:
fetch() directly.fetch.Four concrete pains came from this shape:
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.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.@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.
fetch; add retry/timeout inline; leave tests monkey-patching. Minimal change. Every existing pain persists. Each new test adds another monkey-patch.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.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:
DidString, TxId) replace loose string types where the distinction is load-bearing.Logger interface with a NOOP_LOGGER default: first opt-in logging seam.dispose() lifecycle with #disposed guard on resource-holding sub-facades.assertString(), assertBytes(), assertCompressedPubkey(). Internal code trusts internal code; the library validates at the edge.tryResolveDid(): non-throwing resolution returning a discriminated union, for callers who prefer result types over exceptions.Positive
HttpExecutor seam means the same code runs under fetch, under a mocked executor, under a proxying executor for corporate environments, under a caching executor, or under a Workers/Deno runtime’s native fetch: without any changes inside the Bitcoin package.BitcoinConnection makes every caller’s intent explicit and removes a class of “wrong network picked by default” bugs.toJSON() as a cross-cutting convention gives canonicalization a consistent input shape, which is the root-cause fix for a whole class of hash-mismatch bugs that later ADR 014 codifies.Negative
Explicitly accepted trade-offs
HttpExecutor is pull-based, not streaming. The Bitcoin package doesn’t need streaming semantics today; every response fits in memory. When a streaming need appears (e.g., large block downloads), it’ll need a separate streaming executor contract. Designing for that now is premature.toJSON() is a convention, not enforced by the type system. A class that forgets toJSON() canonicalizes against its own enumerable fields: which is the pre-fix bug. Code review and the JSON.parse(JSON.stringify(...)) round-trip inside canonicalize() round-trip (added later in ADR 014) together keep this honest.HttpExecutor default doesn’t retry. Retries: if needed: are the caller’s concern, since retry-safety depends on the operation’s idempotency, which only the caller knows. A RetryingHttpExecutor wrapping the default is a few lines of consumer code.fetch. In Node ≥ 22, fetch uses undici with its own connection pool; in browsers, the platform handles it. We don’t add a second layer.packages/bitcoin/src/connection.ts: BitcoinConnection (single-network).packages/bitcoin/src/: EsploraProtocol, RpcProtocol, HttpExecutor live in this package.packages/api/src/api.ts: sub-facade tree introduced here.toJSON() convention introduced here.