Building the did:btcr2 Reference Implementation: Architecture of a DID Method Monorepo

Jul 17, 2026

Building the did:btcr2 Reference Implementation: Architecture of a DID Method Monorepo

Introduction

A specification is a promise. An implementation is the proof. Building the did:btcr2 reference implementation in TypeScript meant translating a complex cryptographic protocol into production-quality code, with decisions about architecture, API design, and developer experience that the specification doesn't prescribe. This post covers the key engineering decisions and why we made them.

Monorepo Structure

The implementation lives in a pnpm workspaces monorepo (dcdpr/did-btcr2-js) with ten published packages under the @did-btcr2 scope:

  • @did-btcr2/method: Core DID operations: create, resolve, update, signing method resolution
  • @did-btcr2/cryptosuite: The bip340-jcs-2025 Data Integrity cryptosuite
  • @did-btcr2/common: Shared types, interfaces, canonicalization, JSON patching
  • @did-btcr2/keypair: secp256k1 key pairs with BIP340 Schnorr signing
  • @did-btcr2/bitcoin: Bitcoin Core RPC and Esplora REST clients
  • @did-btcr2/smt: 256-level Sparse Merkle Tree implementation (tree depth matches the 256-bit key space)
  • @did-btcr2/key-manager: Key management interface with pluggable storage backends
  • @did-btcr2/api: High-level SDK facade combining all packages
  • @did-btcr2/cli: CLI tool for create/resolve/update/deactivate
  • @did-btcr2/aggregation: Multi-party coordination for Aggregate Beacons: cohort formation, MuSig2 (BIP-327) signing sessions, and pluggable transports

Each package ships ESM and CommonJS builds. The method, API, and aggregation packages also ship browser bundles. TypeDoc generates API documentation from the source.

The monorepo structure matters because DID method implementations touch many concerns: cryptography, blockchain interaction, data structures, key management, network protocols. Splitting these into focused packages means consumers can pull in only what they need. A project that only verifies credentials doesn't need the Bitcoin transaction construction code.

Sans-I/O for Bitcoin

The BitcoinConnection class demonstrates a pattern we found essential: separating request construction from HTTP execution.

The class builds HTTP requests for Bitcoin Core RPC and Esplora REST endpoints but delegates actual execution to an injectable HttpExecutor. By default, it uses the global fetch. But in tests, the executor can be replaced with a mock that returns canned responses, no Bitcoin node required.

This "sans-I/O" approach (borrowed from the Python ecosystem) provides:

  • Testability: Every Bitcoin interaction can be tested without network access
  • Portability: The same code runs in Node.js, Deno, browsers, and edge runtimes that provide different fetch implementations
  • Observability: A custom executor can log, meter, or retry requests without modifying the core code

Explicit Dependency Injection

A deliberate design choice: the library packages never read environment variables for configuration. Bitcoin connection parameters (RPC URL, credentials, network) must be passed explicitly. Environment variables live at the CLI edge instead, where BTCR2_* settings resolve under a documented precedence chain: command-line flags first, then environment, then the config file.

This is a reaction to a common pattern in blockchain libraries where BITCOIN_RPC_URL or similar environment variables are auto-detected. The problem with silent fallbacks:

  • They make dependencies invisible: you don't know a class needs a Bitcoin connection until it fails at runtime
  • They create configuration coupling: two instances that need different connections (testnet vs. regtest) fight over the same environment variable
  • They complicate testing: tests need to manipulate process environment state

With explicit injection, every dependency is visible in the function signature. That removes the ambient-configuration failure mode, though it isn't by itself a network-safety guarantee: the network name and the endpoint are separate fields, and nothing in the transport checks that the host you point at actually serves the chain you named.

Branded Types

TypeScript's structural typing means a string is a string. A DID string and a transaction ID are both strings, and the compiler won't stop you from passing one where the other is expected.

Branded (nominal) typing is one TypeScript technique for distinguishing structurally identical strings, intersecting a string with a readonly tag so the compiler treats, say, a DID and a transaction id as different types:

type DidString = string & { readonly __brand: 'DidString' };
type TxId = string & { readonly __brand: 'TxId' };

The did:btcr2 reference implementation ships exactly these two brands in @did-btcr2/api, while the shared identifier type in @did-btcr2/common stays a plain alias (type Did = string, via DecentralizedIdentifier = string). No function signature in the monorepo requires the branded form yet, so callers opt in with a cast: the guarantee is offered rather than enforced. Where it is applied, a function expecting DidString won't accept a raw string or a TxId without an explicit cast, catching a category of bugs at compile time that would otherwise surface at runtime: passing a transaction id to a function that expects a DID, or vice versa.

Driving an Update

DID updates involve many parameters: the target DID, the JSON Patch operations, the signing key, the beacon type, the Bitcoin connection. Passing all of these as positional arguments creates fragile, hard-to-read code.

Updates are driven through an object-parameter factory rather than a positional argument list. DidBtcr2.update({...}) takes the source document, the JSON Patch operations, the source version id, the verification method id, and the beacon id, then returns a sans-I/O Updater state machine. The caller drives it with an advance()/provide() loop: advance() reports what the machine needs next (a signing key, funding for the beacon, a broadcast), and provide() supplies it, so all signing, funding checks, and Bitcoin I/O stay outside the state machine. The codebase also reaches for the builder pattern where a fluent chain reads better: DidDocumentBuilder offers a .withAuthentication(...).withService(...).build() chain for assembling DID documents, and the SDK layer wraps the same update path in an UpdateBuilder, reached through api.btcr2.buildUpdate(doc), which collects the patch, version, verification method, beacon, and signer before .execute(). Either way, naming each parameter (through an options object or a builder) keeps a multi-step operation readable and lets required fields be enforced.

Stateful Facade

The @did-btcr2/api package provides a high-level SDK with sub-facades (CryptoApi, DidApi, BitcoinApi). Of these, the crypto facade is the stateful one: api.crypto.activate(multikey) sets a "current" multikey, cryptosuite, and proof instance that persist across calls, while the key manager separately tracks an active key id.

This means simple workflows don't require threading context through every call:

const api = createApi({ btc: { network: 'regtest' } });
const { did } = api.generateDid({ setActive: true });
// The key manager remembers the active key; api.crypto.activate() sets the cryptosuite
await api.updateDid({ did, patches, verificationMethodId, beaconId, signer });

For advanced use cases, any default can be overridden per-call. The pattern balances convenience (minimal boilerplate for common operations) with flexibility (full control when needed).

Resolution as Replay

DID resolution is implemented as an iterative replay of the operation history, following the specification's resolve algorithm:

  1. Start with the initial DID document: deterministically derived from the DID for key-based identifiers, or supplied as sidecar data (or fetched from CAS) when the identifier encodes a genesis document hash
  2. Discover beacon signals on Bitcoin (fetch the transactions associated with each beacon's address, then read the OP_RETURN payload each signal carries)
  3. For each signal, fetch and verify the update operation
  4. Verify the update's Data Integrity proof against the document's current key set (not the final key set, each update is verified against the keys that were active at the time)
  5. Apply the JSON Patch to the current document state, then confirm the patched document hashes to the targetHash the update declares

The resolver supports versionId and versionTime parameters for resolving historical states, plus duplicate detection and late-publishing rejection.

Key Management

The @did-btcr2/key-manager package provides:

  • URN-style key identifiers for stable references
  • Import/export for key portability
  • Active key tracking (which key is "current" for a given purpose)
  • Pluggable storage backends (an in-memory reference store plus a KeyValueStore abstraction for swapping in others)

Key management is separated from the DID method itself because key lifecycle management is an orthogonal concern. An organization might use an HSM, a mobile secure enclave, or a simple file-based store; the DID method doesn't need to know.

Conclusion

Building a reference implementation forces decisions that a specification can defer. Sans-I/O keeps the code testable and portable. Branded types catch category errors once signatures require them. Builder patterns manage complexity. Explicit injection makes dependencies visible. These aren't btcr2-specific lessons: they're applicable to any complex protocol implementation. But they're especially important when the protocol handles something as sensitive as identity.

Jintek LLC