The did:btcr2 Reference Implementation: Architecture of a DID Method Monorepo

Jul 17, 2026

The did:btcr2 Reference Implementation: Architecture of a DID Method Monorepo

Introduction

A specification defines the rules, and an implementation shows that the rules work. We built the did:btcr2 reference implementation in TypeScript. For this work, we changed a complex cryptographic protocol into code for production use. We made decisions about architecture, API design, and developer experience that the specification does not prescribe. This post describes the main technical decisions and the reasons for them.

Monorepo Structure

The implementation is a pnpm workspaces monorepo (dcdpr/did-btcr2-js). It has ten published packages in the @did-btcr2 scope:

  • @did-btcr2/method: Core DID operations: create, resolve, update, and deactivate
  • @did-btcr2/cryptosuite: The bip340-jcs-2025 Data Integrity cryptosuite
  • @did-btcr2/common: Shared types, interfaces, canonicalization, and JSON Patch functions
  • @did-btcr2/keypair: secp256k1 key pairs with ECDSA, BIP340 Schnorr, and BIP341 Taproot signatures
  • @did-btcr2/bitcoin: Bitcoin Core RPC and Esplora REST clients
  • @did-btcr2/smt: A full-depth (256-level) Sparse Merkle Tree. The depth matches the 256-bit key space.
  • @did-btcr2/key-manager: A key management interface with pluggable storage backends
  • @did-btcr2/api: A high-level SDK facade that combines all packages
  • @did-btcr2/cli: A CLI tool for create, resolve, update, and deactivate operations, plus key and configuration commands
  • @did-btcr2/aggregation: Multi-party coordination for Aggregate Beacons: cohort formation, MuSig2 (BIP-327) signature sessions, and pluggable transports

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

The monorepo structure is important because a DID method implementation touches many areas: cryptography, blockchain interaction, data structures, key management, and network protocols. Focused packages let consumers use only the parts that they need. For example, a project that only verifies credentials does not need the code that builds Bitcoin transactions.

Sans-I/O for Bitcoin

The BitcoinConnection class shows a pattern that was important for us: it keeps request construction separate from HTTP execution (connection.ts).

The protocol layer builds HTTP requests for Bitcoin Core RPC and Esplora REST endpoints. An injectable HttpExecutor does the HTTP calls (http.ts). The default executor uses the global fetch. In tests, a mock executor can return fixed responses, so the tests need no Bitcoin node.

This "sans-I/O" approach comes from the Python ecosystem. It gives these benefits:

  • Testability: Tests can cover each Bitcoin interaction without network access.
  • Portability: The code needs only a fetch-style executor, so it does not depend on APIs that only Node.js has. The project has a general rule: all code must be compatible with browsers (ADR 032).
  • Observability: A custom executor can log, meter, or retry requests without changes to the core code.

Explicit Dependency Injection

We made a deliberate design choice: the library packages never read environment variables for configuration. The caller gives the Bitcoin connection parameters (network, REST or RPC endpoint, and credentials). The transport layer (BitcoinConnection) holds no service URLs. The SDK facade fills a default endpoint for each network from a constant in the code (DEFAULT_BITCOIN_NETWORK_CONFIG), not from the environment. Only the CLI package reads environment variables. There, BTCR2_* settings follow a documented precedence: command-line flags first, then the environment, then the config file (config.ts).

This choice is a reaction to a pattern in some blockchain libraries: the library automatically reads BITCOIN_RPC_URL or a similar environment variable. Silent fallbacks cause these problems:

  • They hide dependencies. You do not know that a class needs a Bitcoin connection until it fails at runtime.
  • They couple configurations. Two instances that need different connections (for example, testnet and regtest) compete for the same environment variable.
  • They make tests more difficult. Tests must change the state of the process environment.

With explicit injection, each dependency is visible in the function signature. This removes the failure mode of ambient configuration. By itself, it is not a guarantee of network safety. The network name and the endpoint are separate fields, and the transport does not check that the host serves the chain that you named. The SDK facade does one related check. Before a resolution or an update, it refuses a Bitcoin connection whose network name is different from the network of the DID (method.ts).

Branded Types

TypeScript has a structural type system: a string is a string. A DID string and a transaction ID are both strings. The compiler does not stop you if you pass one where the code expects the other.

Branded (nominal) types are one TypeScript technique that separates strings with the same structure. The type is a string intersected with a readonly tag, so the compiler treats 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. The shared identifier type in @did-btcr2/common stays a plain alias (type Did = string, through DecentralizedIdentifier = string). The brands are opt-in: no function signature in the monorepo requires the branded form, so a caller casts a value to get the check. The package offers the guarantee but does not enforce it.

If a signature uses a brand, a function that expects DidString does not accept a raw string or a TxId without an explicit cast. This check finds a category of bugs at compile time that can otherwise occur at runtime. An example is a transaction ID that goes to a function that expects a DID, or the reverse.

How an Update Runs

DID updates have many parameters: the source DID document, the JSON Patch operations, the signing key, the beacon, and the Bitcoin connection. If you pass all of these as positional arguments, the code is fragile and difficult to read.

The method package uses an object-parameter factory, not 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. It returns a sans-I/O Updater state machine. The caller runs the machine with an advance()/provide() loop. advance() reports what the machine needs next: a signing key, funds for the beacon, or a broadcast. provide() gives that data, so all signatures, fund checks, and Bitcoin I/O stay outside the state machine.

The codebase also uses the builder pattern where a fluent chain is easier to read. DidDocumentBuilder offers a .withAuthentication(...).withService(...).build() chain to assemble DID documents.

The SDK layer puts one call over this state machine, and the shape of that call follows the specification. The spec defines the update operation with five inputs: the source document, the JSON Patch, the target version, the verification method, and a signer. The spec describes the beacon and the Bitcoin transaction in a separate section, Announce DID Update.

The SDK call api.updateDid(source, patch, signer, options) uses the same structure (api.ts):

  • source is one value: a DID, or a resolved document together with its version ID. If the source is a DID, the SDK resolves it first.
  • patch is one JSON Patch document.
  • signer makes the BIP340 signature for the update proof.
  • options holds the settings that have a default. These are the verification method ID and the announcement settings (the beacon, the fee, and the CAS policy).

The SDK calculates the target version from the resolved version. Thus a caller cannot give a wrong local count by mistake.

The project records the reasons for this design in an architecture decision record (ADR 123). Three rules apply to any protocol SDK:

  • Match the inputs of the specification. Then a reviewer can compare the call with the spec directly.
  • Give one value one type. If an API spreads one value, such as the source state, across several optional fields, it needs runtime checks for partial input. A single type for that value removes those checks.
  • Give one way to do each operation on each layer. A second API for the same operation, for example a fluent builder next to an options call, doubles the surface that must stay correct.

Stateful Facade

The @did-btcr2/api package gives a high-level SDK with sub-facades: CryptoApi, DidApi, KeyManagerApi, BitcoinApi, CasApi, and DidMethodApi (api.ts). Of these, the crypto facade is the stateful one. api.crypto.activate(multikey) sets a "current" multikey, cryptosuite, and proof instance that stay in place across calls. The key manager separately records an active key ID.

Thus, simple workflows do not need to pass context through each call. For example:

const api = createApi({ btc: { network: 'regtest' } });
const { did } = api.generateDid({ setActive: true });
// The key manager records the active key. api.kms.signer() with no ID uses that key.
// If you omit verificationMethodId and announce.beaconId, the api derives them.
await api.updateDid(did, patch, api.kms.signer());

For advanced cases, the caller can override each default in one call. For example, the caller can pass { verificationMethodId, announce: { beaconId } } as the fourth argument. The pattern balances convenience (little boilerplate for common operations) and flexibility (full control when necessary).

Resolution as Replay

The resolver does DID resolution as an iterative replay of the operation history. It follows the resolve algorithm of the specification (resolver.ts):

  1. Start with the initial DID document. For key-based identifiers, the resolver derives it from the DID. If the identifier encodes a genesis document hash, the document comes from sidecar data (or from CAS).
  2. Find beacon signals on Bitcoin. Get the transactions that spend from each beacon address, then read the Signal Bytes in the OP_RETURN output of each signal.
  3. For each signal, get the update from sidecar data or CAS. Confirm that the hash of the update matches the hash that the signal announces.
  4. Sort the updates by targetVersionId. Confirm that the source hash of each update matches the current document.
  5. Verify the Data Integrity proof of the update against the key set of the current document, not the final key set. Each update must verify against the keys that were active at that time.
  6. Apply the JSON Patch to the current document state. Then confirm that the patched document hashes to the targetHash that the update declares.

The resolver supports the versionId and versionTime options to resolve historical states. It also does duplicate detection and late-publishing rejection. The minConf option sets the number of confirmations that a Beacon Signal needs (6 by default).

Key Management

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

  • URN-style key identifiers for stable references (urn:kms:secp256k1:<fingerprint>)
  • Import and export for key portability. Export is optional: the canExport flag shows if a backend supports it. An HSM backend can set it to false.
  • An active-key pointer: the key that signs when the caller gives no key ID
  • Pluggable storage backends (an in-memory reference store plus a KeyValueStore abstraction for other stores)

Key management is separate from the DID method itself, because the key lifecycle is an independent concern. An organization can use an HSM, a secure enclave in a mobile device, or a simple file-based store. The DID method does not need to know which one.

Conclusion

A reference implementation forces decisions that a specification can defer. Sans-I/O keeps the code testable and portable. Branded types find category errors if signatures require them. Builders and options objects make complex operations easier to manage. Explicit injection makes dependencies visible.

An API that follows the inputs of the specification closely is easier to review against the specification. These lessons are not specific to btcr2: they apply to any complex protocol implementation. They are especially important when the protocol handles a sensitive function such as identity.

Jintek LLC