did-btcr2-js

@did-btcr2/api

High-level SDK facade for the did:btcr2 DID method. Wraps @did-btcr2/method and the surrounding crypto / bitcoin / key-management packages behind a single ergonomic entry point.

Part of the did-btcr2-js monorepo.

Summary

The lower-level packages (@did-btcr2/method, @did-btcr2/cryptosuite, @did-btcr2/key-manager, @did-btcr2/bitcoin) are designed to be composable and sans-I/O. This package is the thin layer above them: it owns Bitcoin endpoint configuration, CAS retrieval, key management, and the dispatch loop for the sans-I/O state machines.

If you’re integrating did:btcr2 into an app, start here. If you’re customizing the protocol, drop down to @did-btcr2/method directly.

The api wires the configured BitcoinApi into the sans-I/O Resolver and Updater state machines, fulfilling NeedBeaconSignals, NeedFunding, NeedBroadcast, and CAS-related needs (NeedGenesisDocument, NeedCASAnnouncement, NeedSignedUpdate) automatically. How NeedBeaconSignals is fulfilled follows the connection’s btc.signalDiscovery mode: 'indexer' (the default) reads beacon-address transaction listings from the Esplora-compatible REST backend, while 'fullnode' scans every block from genesis over Bitcoin Core RPC and needs an rpc config (rejected at construction without one), a node with -txindex=1, and Bitcoin Core >= 25; the linear scan makes it practical only on regtest. NeedSMTProof is not auto-fulfilled by the facade: an SMT proof has no content address on chain (the signal is the tree root, and the proof of one DID is not derivable from it), so it must be provided upfront via options.sidecar.smtProofs; resolution fails with MISSING_UPDATE_DATA and that pointer otherwise. Multi-party aggregation is out of scope here; drive the Updater directly and hand NeedBroadcast to the aggregation runner from @did-btcr2/aggregation. On the read path, a signal below minConf confirmations is excluded before any fetch: the api requests no update, announcement, or proof for it, and the resolved document does not show it until the transaction reaches the depth.

On the write path, announce.publishToCas ('never' 'auto' 'always', default 'never') controls whether update artifacts are published to the configured CAS before the on-chain broadcast. CAS publication is optional and never required: every update, for every beacon type, completes and is distributable via sidecar regardless. Publishing is opt-in: pass 'auto' (best-effort - publishes when a writable CAS is configured, otherwise skips silently and never blocks the update) or 'always' (requires a writable CAS and throws up-front when none is available). When publication happens, the canonical signed update (all beacon types) plus the CAS Announcement (CAS beacons) reach the CAS, so resolvers can fetch every OP_RETURN update hash from the CAS with no sidecar. Update calls return a DidUpdateResult carrying the signal txid and the per-beacon-type sidecar artifacts (announcement, SMT proof).

The write path refuses these inputs before any CAS publication or broadcast:

The first two refusals run before the signature. Each refusal is an UpdateError with type INVALID_DID_UPDATE. resolve() refuses the network mismatch too, with a ResolveError.

Install

npm install @did-btcr2/api

Or with pnpm:

pnpm add @did-btcr2/api

Runtime note: ESM-first package; a CJS build ships via the require export condition (some transitive deps are ESM-only, so import is the reliable path). Ships a browser bundle at dist/browser.mjs for bundler-based environments. Requires Node >= 22.

Key Exports

Concern Entry point
Main facade DidBtcr2Api, createApi(config?)
Sub-facades BitcoinApi, CasApi, CryptoApi, DidApi, KeyManagerApi, DidMethodApi
Config types ApiConfig, BitcoinApiConfig, SignalDiscoveryMode, CasConfig, Logger
Resolution result ResolutionResult (tryResolveDid return type)
Signers Signer, LocalSigner, KeyManagerSigner, LocalKeyManager, KeyManager, SchnorrKeyPair
Write inputs UpdateSource, SourceState, UpdateOptions, DidUpdateOptions, AnnounceOptions, PublishToCasMode
Write results DidUpdateResult, BeaconInfo
Re-exports from method/common Btcr2DidDocument, DidDocument, DidDocumentBuilder, Identifier, IdentifierTypes, ResolutionOptions, Sidecar, PatchOperation
Identifier validation DidComponents, IdentifierReport, IdentifierCheck, IdentifierCheckName, IdentifierValidateOptions
Vector tool steps canonicalHash (JSON Document Hashing), JSONPatch (the target document of an update), Appendix (deriveRootCapability)

Quick Start

Generate a DID and resolve it

import { createApi } from '@did-btcr2/api';

const api = createApi({ btc: { network: 'mutinynet' } });

// Generate a keypair, derive the DID, import the secret into the in-process KMS.
// The DID inherits the network of the connection: mutinynet here.
const { did, keyId } = api.generateDid();

// Resolve. Bitcoin signals are fetched automatically via the configured BitcoinApi.
const resolution = await api.resolveDid(did);
console.log(resolution.didDocument?.id);

Find the beacon address to fund, with no chain read

// The initial document of a `k` DID is a pure function of its key. The beacon
// addresses are known before the DID touches the chain.
const beacons = api.btcr2.getBeacons(api.btcr2.getInitialDocument(did));
const beacon = beacons.find((b) => b.id.endsWith('#initialP2WPKH'))!;
console.log(beacon.address); // fund this address before the first update

Build an EXTERNAL DID from a genesis document

// One key with all four relationships, one P2WPKH Singleton beacon on mutinynet.
const genesisDocument = api.btcr2.buildGenesisDocument({
  network             : 'mutinynet',
  verificationMethods : [{ publicKey: kp.publicKey.compressed }],
});
// Hash exactly the bytes you keep or publish.
const json = JSON.stringify(genesisDocument, null, 2);
const { did, didDocument } = api.btcr2.createExternalFromDocument(JSON.parse(json), { network: 'mutinynet' });
const [beacon] = api.btcr2.getBeacons(didDocument);
console.log(did, beacon.address); // fund this address before the first update
// Later: api.resolveDid(did, { sidecar: { genesisDocument: JSON.parse(json) } })

A spec can name several keys with chosen relationships, a CASBeacon or SMTBeacon with the address of a cohort, and other services. The builder refuses a spec with no capabilityInvocation method or no beacon.

Update

The update arguments follow the update operation of the specification:

// spec: update(didSourceDocument, jsonPatch, targetVersionId, verificationMethodId, signer)
api.updateDid(source, patch, signer, options?)
// spec: deactivate(didSourceDocument, targetVersionId, verificationMethodId, signer)
api.deactivateDid(source, signer, options?)
// The source is the DID: the api resolves it and takes the document and its
// versionId from the resolution. The api derives the verification method from
// the signer's key and the beacon from the one funded beacon.
const { signedUpdate, txid } = await api.updateDid(
  did,
  [{ op: 'add', path: '/service/-', value: newService }],
  api.kms.signer(keyId),
);

If you resolved the DID already, pass the state as the source. The api then does not resolve. The versionId must come from the resolution that returned the document:

const source = { document: currentDoc, versionId: 2 };
await api.updateDid(source, patch, signer, {
  // Ids are resolved against the document before matching, so a full DID URL
  // (`${did}#initialKey`) and a bare fragment (`#initialKey`) both work.
  verificationMethodId : `${did}#initialKey`,
  announce             : { beaconId: `${did}#initialP2WPKH` },
});

api.btcr2.update(source, patch, signer, options?) takes the same arguments, but the source must be a SourceState.

Publish update artifacts to a CAS before broadcasting

// A writable CAS (an IPFS node's RPC endpoint) makes updates resolvable
// without sidecar data: the signed update (and, for CAS beacons, the
// announcement) is published before the beacon transaction is broadcast.
const api = createApi({
  btc : { network: 'mutinynet' },
  cas : { rpcUrl: 'http://127.0.0.1:5001' },
});

const first = await api.updateDid(
  did,
  [{ op: 'add', path: '/service/-', value: newService }],
  api.kms.signer(keyId),
  // publishToCas defaults to 'never' (opt-in): update artifacts are returned
  // for sidecar distribution and nothing is published. Opt in with 'auto' to
  // publish to the writable CAS configured above. Note 'auto'/'always' publish
  // canonical signed updates to the configured (possibly public) CAS before the
  // on-chain anchor, so keep the 'never' default for sidecar-only privacy.
  { announce: { publishToCas: 'auto' } },
);
console.log(first.txid, first.publishedToCas); // e.g. { update: true, announcement: false }

Resolve without throwing

const result = await api.tryResolveDid(did);
if (result.ok) {
  console.log(result.document);
} else {
  console.warn(`resolve failed: ${result.error} - ${result.errorMessage}`);
  // result.cause holds the original error, for instanceof checks.
}

Update a DID that already has updates, with the facade’s signer

// api.kms.signer(keyId) wraps the KMS key behind the Signer interface. It also
// works with an external KeyManager (HSM, cloud KMS) passed to createApi({ kms }).
const signer = api.kms.signer(keyId);

// A DID with prior updates resolves only with its sidecar. resolutionOptions
// hands the sidecar to the auto-resolution. The api derives the two ids.
const second = await api.updateDid(did, [{ op: 'add', path: '/service/-', value: newService }], signer, {
  resolutionOptions : { sidecar: { updates: [first.signedUpdate] } },
});

Deactivate

// Deactivation is permanent. It is an ordinary update that carries the
// deactivation patch. Pass the full update history in the sidecar.
const { txid } = await api.deactivateDid(did, signer, {
  resolutionOptions : { sidecar: { updates: [first.signedUpdate, second.signedUpdate] } },
});

A later updateDid on a deactivated DID is refused before any signature or broadcast. The patch is DEACTIVATION_PATCH of @did-btcr2/method; DidMethodApi.DEACTIVATION_PATCH is the same object.

Every update failure that the specification names is an UpdateError of type INVALID_DID_UPDATE. Examples: a verification method that no capabilityInvocation entry identifies, a reference with no verificationMethod member, a patch that fails to apply, a patched document that changes the id or does not conform to DID Core, and a proof that does not verify with the published key. The method package applies the patch strictly per RFC 6902: a remove of a missing path or a failed test fails the whole patch. A method that capabilityInvocation embeds as an object signs an update like a referenced one, also when verificationMethod does not list it.

Architecture Principles

Build & Test

# From packages/api/
pnpm build              # Compile ESM + CJS + browser bundle + type declarations
pnpm build:tests        # Compile tests to tests/compiled/
pnpm test               # Run the test suite with coverage
pnpm lint               # ESLint (zero warnings tolerated)

The lib/ directory contains end-to-end scripts that exercise the full update path against regtest, mutinynet, signet, testnet3, and testnet4. Run with bun packages/api/lib/e2e-*.ts or tsx. On non-regtest networks the scripts persist generated secret keys to lib/.e2e-keys/ (gitignored) so funds at beacon addresses can be recovered.

Documentation

License

MPL-2.0