A code-first walkthrough of the entire did:btcr2 lifecycle through the @did-btcr2/api SDK: create an identifier offline, resolve it live from Bitcoin, update it on-chain, prove the update is private, and deactivate it. It runs on Mutinynet (a public Bitcoin signet with 30-second blocks and a free faucet), so nothing here costs real money.
This is the developer-facing companion to the CLI walkthrough at ../cli/docs/DEMO.md: same narrative arc, but every step is a few lines of TypeScript instead of a shell command. The lifecycle code blocks in Parts 0-5 mirror the runnable script lib/e2e-full-lifecycle.ts, which executes the whole lifecycle end-to-end (see the Appendix; the script factors its pauses and polling into helpers, so a few doc blocks inline what those helpers do). All output shown is illustrative: your identifiers, addresses, and txids will differ.
How to follow along. Three options, from least to most hands-on: (1) just read; (2) run the companion script, which pauses while you fund the beacon from the faucet and again while each broadcast confirms; (3) paste the blocks into a npx tsx REPL top-to-bottom (top-level await works, and variables persist between blocks).
Targets @did-btcr2/api v0.20.0.
This demo shows all three.
Node >= 22. The package is ESM-first; tsx is the easiest way to run TypeScript directly.
node --version # should print >= 22, not "command not found"
npm install @did-btcr2/api tsx
One facade object drives everything. Sub-facades (api.kms, api.did, api.crypto, api.btc, api.cas, api.btcr2) hang off it.
import { createApi, DEFAULT_CAS_GATEWAY } from '@did-btcr2/api';
const api = createApi({
btc : { network: 'mutinynet' },
// Read-only public IPFS gateway. The short timeout keeps Part 4's
// deliberate CAS miss snappy instead of hanging on a slow gateway.
cas : { gateway: DEFAULT_CAS_GATEWAY, timeoutMs: 5_000 },
});
Lazy facades.
api.btc,api.cas, andapi.btcr2instantiate on first access, andapi.btcthrows unless abtcconfig was passed tocreateApi(). Mutinynet needs no endpoint configuration: its REST default is alreadyhttps://mutinynet.com/api.
Generate a secp256k1 key. By default it lives in the bundled in-process LocalKeyManager; pass your own KeyManager implementation (AWS KMS, Vault, HSM) via createApi({ kms }) and nothing else in this demo changes.
const keyId = api.kms.generateKey({ setActive: true });
console.log(keyId);
// urn:kms:secp256k1:ca889f15082b4faf0367280f1fed15a2
Talking point: the secret never leaves the key manager. Everything downstream refers to the key by that id, and signing happens behind the KeyManager interface.
// Optional backup of a throwaway testnet key. Throws if the backing
// KMS does not advertise canExport (external HSM adapters typically don't).
const backup = api.kms.export(keyId);
A deterministic (k) DID is pure local computation over the compressed public key. No I/O happens on this line.
const did = api.createDid('deterministic', api.kms.getPublicKey(keyId));
console.log(did);
// did:btcr2:k1q5p8rn...qy2kh3v
That string is the identifier. It was produced in milliseconds, with no fee and no server. (Every version-1 mutinynet k DID starts did:btcr2:k1q5: the network is encoded right there in the string.)
The network is part of the identifier.
createDidmints the DID for the network of thebtcconnection: mutinynet here. With nobtcconfig the api mints aregtestDID, never a mainnet one. Pass{ network }as the third argument to override. The DID and the connection must agree:resolveDidandupdateDidrefuse a mismatch before any chain read.Shortcut:
api.generateDid()does Parts 1 and 2 in one call and returns{ did, keyId }. It uses the same network default.
There are two identifier flavors: k (deterministic, encodes the public key itself) and x (external, encodes the hash of a full genesis document for multi-key or service-rich starts). This demo uses k; an x DID additionally needs its genesis document at resolution time, supplied in the sidecar or fetched from a configured CAS.
The initial document of a k DID is a pure function of the key. The api derives it with no I/O and lists the beacon services with their bare Bitcoin addresses. Grab the beacon we fund in Part 4, and let the per-network presets (the same ones behind the CLI’s funding hint) print the links:
import { explorerAddressUrl, faucetUrl } from '@did-btcr2/api';
const beacons = api.btcr2.getBeacons(api.btcr2.getInitialDocument(did));
const beacon = beacons.find((b) => b.id.endsWith('#initialP2WPKH'));
if (!beacon) throw new Error('missing #initialP2WPKH beacon');
const beaconAddress = beacon.address;
console.log(`Beacon: ${beaconAddress}`);
console.log(`Faucet: ${faucetUrl('mutinynet')}`);
console.log(`Explorer: ${explorerAddressUrl('mutinynet', beaconAddress)}`);
// Beacon: tb1qme9lfnkgcqcfu2v43k9w0fy0zj43z8gdgp2ank
// Faucet: https://faucet.mutinynet.com/
// Explorer: https://mutinynet.com/address/tb1qme9lfnkgcqcfu2v43k9w0fy0zj43z8gdgp2ank
The preset helpers return
undefinedon networks without a public faucet or explorer (regtest, and mainnet has no faucet), so guard the log lines if you parameterize the network.
Resolution reads beacon signals from the chain and materializes the W3C DID document. tryResolveDid returns a discriminated result instead of throwing.
const v1 = await api.tryResolveDid(did);
if (!v1.ok) throw new Error(v1.errorMessage ?? v1.error);
console.log(v1.metadata?.versionId); // '1' - no updates yet
console.log(v1.metadata?.confirmations); // 0 - no update applied
console.log(v1.document);
Illustrative document (trimmed):
{
"@context": ["https://www.w3.org/ns/did/v1.1", "https://btcr2.dev/context/v1"],
"id": "did:btcr2:k1q5p8rn...qy2kh3v",
"verificationMethod": [{
"id": "did:btcr2:k1q5p8rn...qy2kh3v#initialKey",
"type": "Multikey",
"controller": "did:btcr2:k1q5p8rn...qy2kh3v",
"publicKeyMultibase": "zQ3s..."
}],
"service": [
{ "id": "...#initialP2PKH", "type": "SingletonBeacon", "serviceEndpoint": "bitcoin:m..." },
{ "id": "...#initialP2WPKH", "type": "SingletonBeacon", "serviceEndpoint": "bitcoin:tb1q..." },
{ "id": "...#initialP2TR", "type": "SingletonBeacon", "serviceEndpoint": "bitcoin:tb1p..." }
]
}
What to point at:
btc.signalDiscovery: 'indexer'; a 'fullnode' mode scans blocks over Bitcoin Core RPC instead, needing an rpc config plus -txindex=1, and is practical only on regtest.)getBeacons listed in Part 2, now read back from the chain. A beacon is a Bitcoin address whose transactions announce updates for this DID.versionId: '1': this document has never been updated.Here is what makes did:btcr2 different: an update writes only a 32-byte hash into an OP_RETURN output at the beacon address. The document change itself never touches the chain: it travels off-chain as a signed “sidecar” you keep and share deliberately.
An update needs two things: a funded beacon address, and one confirmation.
Open the faucet URL from Part 3, paste the beacon address, and request ~100,000 sats. Then wait for 1 confirmation (about 30-60 seconds). You can poll for it:
let utxos = await api.btc.getUtxos(beaconAddress);
while (!utxos.some((u) => u.status.confirmed)) {
await new Promise((r) => setTimeout(r, 5_000));
utxos = await api.btc.getUtxos(beaconAddress);
}
console.log('beacon funded and confirmed');
Why wait for a confirmation? The api spends only a confirmed beacon UTXO above the dust limit. An unconfirmed input can be replaced or reorged, which would un-anchor your update. If you update too early, the api refuses before it publishes or broadcasts anything:
Beacon address tb1q... cannot fund this update. No spendable UTXO at beacon address: all 1 UTXO(s) are unconfirmed. Wait for a confirmation, or fund the address above the dust limit, before you broadcast the update.Wait one block and retry.
Get a Signer for the key from Part 1 and apply a JSON Patch. updateDid resolves the current document, constructs the signed update, checks the beacon funding, builds and signs the Bitcoin transaction, and broadcasts it.
import { explorerTxUrl } from '@did-btcr2/api';
const signer = api.kms.signer(keyId);
const update1 = await api.updateDid(
did,
[{ op: 'add', path: '/alsoKnownAs', value: ['https://example.com/demo'] }],
signer,
);
console.log(update1.txid);
console.log(`Watch: ${explorerTxUrl('mutinynet', update1.txid)}`);
// KEEP THIS. It is the off-chain half of the update: the sidecar.
const signedUpdate = update1.signedUpdate;
verificationMethodId and announce.beaconId were omitted: the api derives them. The verification method is the one that publishes the signer’s key. The beacon is the one whose address holds a spendable UTXO: #initialP2WPKH, the one you funded. Pass both ids to choose explicitly.signer came from api.kms.signer(keyId). The same call works with an external KeyManager passed to createApi({ kms }).updateDid resolves it, which works here because a fresh k DID resolves deterministically with no sidecar.announce.publishToCas defaults to 'never': nothing about this update leaves your machine except the 32-byte hash in the transaction. That default is the privacy story of Step D.Give the update transaction about 1 block (30-60 seconds on Mutinynet). Watch it at the explorerTxUrl link, or reuse the polling loop from Step A.
Hand the signed update back as a sidecar and resolve:
const v2 = await api.tryResolveDid(did, { sidecar: { updates: [signedUpdate] }, minConf: 1 });
if (!v2.ok) throw new Error(v2.errorMessage ?? v2.error);
console.log(v2.metadata?.versionId); // '2'
console.log(v2.metadata?.confirmations); // 1 or more: the depth the resolver saw
console.log(v2.document.alsoKnownAs); // [ 'https://example.com/demo' ]
Same DID, version 2, patch applied, verified against the on-chain commitment.
Why
minConf: 1? The specification tells a resolver to apply a beacon signal only after six confirmations, andDEFAULT_MIN_CONFis6. Without the option, the same call returns version 1 until the update has six blocks on top of it, about three minutes on Mutinynet. The option lowers the threshold for the walkthrough. The trade-off: a one-deep signal is more exposed to a block reorganization.metadata.confirmationsshows the depth, so a consumer can judge it. Every resolve below passes the same value.
The privacy punchline. Now resolve the same DID without the sidecar:
try {
await api.resolveDid(did, { minConf: 1 });
} catch (err) {
console.log((err as Error).message);
// Failed to resolve DID did:btcr2:k1q5p8rn...qy2kh3v: Signed update not found in CAS (hash: ...)
// ...or, if the gateway stalls past the timeout instead of answering:
// Failed to resolve DID did:btcr2:k1q5p8rn...qy2kh3v: CAS operation timed out after 5000ms
}
The resolver found the on-chain update hash, looked for the update bytes in the sidecar (absent) and then in the configured CAS gateway (where your never-published update was never put), and failed. Either cause message is the same CAS miss. Bitcoin holds the commitment; you hold the contents. Only the parties you share the sidecar with can see what changed. The message carries the root cause. The original error stays on err.cause. tryResolveDid returns the same root cause in errorMessage and the original error in cause.
Deactivation is permanent and irreversible. It retires the DID through the same on-chain write path as an update. Do not run this against a DID you want to keep.
Deactivation is an update: deactivateDid broadcasts an update that carries the deactivation patch, DidMethodApi.DEACTIVATION_PATCH (add /deactivated true). It follows the same sign, publish, and broadcast path as updateDid.
No second faucet trip is needed: the update transaction in Part 4 returned its change to the beacon address, so the beacon still holds a confirmed UTXO.
const update2 = await api.deactivateDid(did, signer, {
resolutionOptions : { sidecar: { updates: [signedUpdate] }, minConf: 1 },
});
console.log(update2.txid);
This time the auto-resolution needs the sidecar: without it the api cannot see version 2. resolutionOptions hands the sidecar to that resolution, and the same minConf: 1 as Step D, so a one-deep update counts. You hold the history, so you are the source of truth for it: that is the model. As an alternative, pass a resolved state { document, versionId } as the source to skip the resolution.
Wait one block, then resolve with the full update history in the sidecar:
const final = await api.tryResolveDid(did, {
sidecar : { updates: [signedUpdate, update2.signedUpdate] },
minConf : 1,
});
if (final.ok) {
console.log(final.metadata?.versionId); // '3'
console.log(final.metadata?.deactivated); // true
}
The resolver applies both updates in block-height order, sees the document deactivate at version 3, and reports it in the metadata. The DID is retired, verifiably and forever.
The DID takes no further update. The api refuses one before any signature:
if (final.ok) {
await api.updateDid({ document: final.document, versionId: 3 }, [], signer)
.catch((err) => console.log((err as Error).message));
// DID document did:btcr2:k1q5p8rn...qy2kh3v is deactivated and cannot be updated. Deactivation is irreversible: ...
}
api.dispose();
KeyManager interface: swap the bundled in-process store for an HSM or cloud KMS without touching the lifecycle code.bip340-jcs-2025).lib/e2e-full-lifecycle.ts executes Parts 0-5 end-to-end and asserts every checkpoint (versionId 1 -> 2 -> 3, the applied patch, the expected CAS miss, the deactivation):
# Run from the monorepo root.
# Against Mutinynet: pauses for the faucet trip and each broadcast confirmation.
BITCOIN_NETWORK=mutinynet npx tsx packages/api/lib/e2e-full-lifecycle.ts
# Against a local regtest node: fully automatic, no faucet, no pauses. Needs
# bitcoind RPC (Polar defaults) plus an Esplora REST endpoint on localhost:3000.
npx tsx packages/api/lib/e2e-full-lifecycle.ts
Rough Mutinynet wall-clock: 3-5 minutes, dominated by three block confirmations (funding, update, deactivation) and the faucet trip. On public networks the script persists the generated secret key to lib/.e2e-keys/ (gitignored, mode 0600) so funds at the beacon address are recoverable.
api.btcr2.update takes the same arguments as updateDid, but it does not resolve: the source must be a resolved state. Pass the ids to choose them explicitly:
const { signedUpdate, txid } = await api.btcr2.update(
{ document: sourceDocument, versionId: 1 },
[{ op: 'add', path: '/alsoKnownAs', value: ['https://example.com/demo'] }],
signer,
{
verificationMethodId : `${did}#initialKey`,
announce : { beaconId: `${did}#initialP2WPKH` },
},
);
If you would rather make an update publicly resolvable than privately shared, configure a writable CAS and opt in:
const api = createApi({
btc : { network: 'mutinynet' },
cas : { rpcUrl: 'http://127.0.0.1:5001' }, // Kubo RPC: read-write
});
const patch = [{ op: 'add', path: '/alsoKnownAs', value: ['https://example.com/demo'] }];
await api.updateDid(did, patch, signer, { announce: { publishToCas: 'always' } });
Modes: 'never' (default: maximum privacy, sidecar-only), 'auto' (best-effort: publishes when a writable CAS is configured, never blocks the broadcast), 'always' (throws up-front if no writable CAS). Publication happens before the on-chain broadcast, so a published hash never dangles. Anyone can then resolve your DID without a sidecar, which is exactly the privacy trade you are opting into.
| Symptom | Cause and fix |
|---|---|
api.btc throws Bitcoin not configured |
Pass a btc config to createApi(), e.g. createApi({ btc: { network: 'mutinynet' } }). |
Beacon address ... is unfunded. Send BTC to this address before broadcasting the update. |
The faucet step was skipped or the funding tx has not landed. Fund the beacon and wait for it to be indexed. |
Beacon address ... cannot fund this update. No spendable UTXO at beacon address: all N UTXO(s) are unconfirmed. ... |
The api spends only a confirmed UTXO, for reorg and RBF safety. It refuses before it publishes or broadcasts. Wait one block (~30s on Mutinynet) and retry. The same wrapper reports a UTXO at or below the 546-sat dust limit. |
No beacon of DID ... holds a spendable UTXO. The api cannot derive beaconId. |
You omitted announce.beaconId, and no beacon address holds a confirmed UTXO above the dust limit. Fund one (Part 4, Step A), or pass announce.beaconId. |
N beacons of DID ... hold a spendable UTXO: ... Pass beaconId to choose which one spends. |
You funded more than one beacon address. Pass announce.beaconId. The api never picks one for you. |
No verification method on DID ... publishes the signer's key. |
The signer’s key is not on the document. Sign with the key from Part 1, or pass verificationMethodId. |
No key id given and no active key set. |
api.kms.signer() with no id needs an active key. Part 1 sets one with setActive: true. Or pass the key id. |
Failed to resolve DID <did>: Signed update not found in CAS (hash: ...) |
You resolved a DID that has an on-chain update without the sidecar. Pass { sidecar: { updates: [...] } }: that is the privacy feature, not a bug. tryResolveDid returns the same text in errorMessage. |
| A resolve returns the old version, with no error | The update transaction has fewer than six confirmations, and resolution excludes it under the default minConf. Wait for six blocks, or pass { minConf: 1 } as this walkthrough does. |
Failed to resolve DID <did>: Invalid resolution option minConf: ... |
minConf is not a positive integer. Pass 1 or more, or omit it for the default of 6. |
Failed to resolve DID <did>: Invalid update: verificationMethod is not authorized for capabilityInvocation |
New in v0.19.0: resolution only applies updates signed by a key the document lists under capabilityInvocation. Sign with an authorized verification method; #initialKey is authorized by default, so this demo never hits it. |
| Resolve hangs | Check reachability to https://mutinynet.com/api; override with btc: { rest: { host: '<url>' } }. Slow CAS lookups are bounded by cas.timeoutMs. |
The DID names the network "X", but the Bitcoin connection targets "Y". |
The DID and the btc.network of createApi disagree. The api refuses before any chain read, on resolve and on update (the update form starts with DID ... names the network). Create the api with the network of the DID. api.did.decode(did).network shows it. |
DID document ... is deactivated and cannot be updated. |
Expected after Part 5. Deactivation is permanent. A second deactivateDid is refused with is already deactivated. |