Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 52 additions & 56 deletions docs/.vitepress/theme/demos/Create.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { NetworkName } from '@did-btcr2/api';
import DemoCard from '../components/DemoCard.vue';
import { useDidBtcr2 } from '../composables/useDidBtcr2';
import { bytesToHex, hexToBytes, isHex } from './hex';
import { formatError } from './errors';
import './demo-fields.css';

const networks: readonly NetworkName[] = ['bitcoin', 'testnet3', 'testnet4', 'signet', 'mutinynet', 'regtest'];
Expand All @@ -19,7 +20,7 @@ const intermediateDocError = ref<string | null>(null);

const running = ref(false);
const response = ref<unknown>(null);
const initialDocument = ref<unknown>(null);
const resolveSidecar = ref<unknown>(null);

const isKeyValid = computed(() => {
if (idType.value !== 'KEY') return false;
Expand Down Expand Up @@ -67,10 +68,14 @@ console.log(did);`;
import { canonicalHashBytes } from '@did-btcr2/common';

const api = createApi({ btc: { network: '${net}' } });
const intermediateDocument = ${intermediateDocText.value.trim() || '{ /* intermediate DID doc */ }'};
// Genesis document: placeholder ids (did:btcr2:_) and at least one beacon
// service. GenesisDocument.fromPublicKey(pubkey, network) builds a valid one.
const genesisDocument = ${intermediateDocText.value.trim() || '{ /* genesis document */ }'};
// EXTERNAL identifiers encode the SHA-256 hash of the canonicalized document.
const genesisHash = canonicalHashBytes(intermediateDocument);
const genesisHash = canonicalHashBytes(genesisDocument);
const did = api.createDid('external', genesisHash, { network: '${net}' });
// Resolving this DID later needs the same document back via sidecar:
// api.resolveDid(did, { sidecar: { genesisDocument } })
console.log(did);`;
}
return '// Choose network and idType, then fill the fields to see the call';
Expand All @@ -86,40 +91,45 @@ async function randomize() {
intermediateDocText.value = '';
} else {
pubKeyHex.value = '';
// Build a minimal intermediate DID document from the public key. The
// identifier fields are placeholders that the create() call will replace
// when it returns the resolved DID.
const PLACEHOLDER = 'did:btcr2:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
intermediateDocText.value = JSON.stringify(
{
'@context': ['https://www.w3.org/TR/did-1.1', 'https://btcr2.dev/context'],
id: PLACEHOLDER,
controller: [PLACEHOLDER],
verificationMethod: [
{
id: `${PLACEHOLDER}#key-0`,
type: 'Multikey',
controller: PLACEHOLDER,
// Multikey requires a base58btc multibase string (zQ3s… prefix).
publicKeyMultibase: keys.publicKey.encode(),
},
],
authentication: [`${PLACEHOLDER}#key-0`],
assertionMethod: [`${PLACEHOLDER}#key-0`],
capabilityInvocation: [`${PLACEHOLDER}#key-0`],
capabilityDelegation: [`${PLACEHOLDER}#key-0`],
},
null,
2,
);
generateGenesisDoc(keys.publicKey.compressed, selectedNetwork.value as Network);
}
}

// Pubkey behind the last auto-generated genesis doc, kept so a network change
// can regenerate the doc (its beacon address is network-specific). The text is
// kept too so we never clobber a document the user has hand-edited.
let lastGenPubKey: Uint8Array | null = null;
let lastGenDocText = '';

/**
* Build the genesis document via the library, not by hand: resolution
* validates that it uses placeholder ids (did:btcr2:_) AND carries at least
* one beacon service; docs without a `service` fail with "Invalid service".
*/
function generateGenesisDoc(pubKey: Uint8Array, network: Network) {
if (!modules.value) return;
const genesis = modules.value.api.GenesisDocument.fromPublicKey(pubKey, network);
lastGenPubKey = pubKey;
lastGenDocText = JSON.stringify(genesis, null, 2);
intermediateDocText.value = lastGenDocText;
}

watch(selectedNetwork, () => {
if (
idType.value === 'EXTERNAL' &&
selectedNetwork.value &&
lastGenPubKey &&
intermediateDocText.value === lastGenDocText
) {
generateGenesisDoc(lastGenPubKey, selectedNetwork.value as Network);
}
});

async function run() {
if (!modules.value || !canRun.value) return;
running.value = true;
response.value = null;
initialDocument.value = null;
resolveSidecar.value = null;
try {
const network = selectedNetwork.value as Network;
const api = createApiForNetwork(network);
Expand All @@ -134,40 +144,25 @@ async function run() {
const genesisHash = modules.value.common.canonicalHashBytes(doc);
const did = api.createDid('external', genesisHash, { network });
response.value = { did };
initialDocument.value = substitutePlaceholder(doc, did);
// The hash is one-way, so resolving this DID requires this exact
// placeholder-form document back, under the `genesisDocument` sidecar
// key. Hand the user a ready-to-paste payload for the Resolve demo.
resolveSidecar.value = { genesisDocument: doc };
}
} finally {
api.dispose();
}
} catch (err: unknown) {
response.value = err instanceof Error ? err.stack || err.message : String(err);
response.value = formatError(err);
} finally {
running.value = false;
}
}

/**
* Walk a parsed JSON doc and replace every placeholder DID string with the
* resolved one. Operates on the parsed object, not a stringified copy — the
* previous implementation double-stringified and then JSON.parsed a string,
* which produced a string instead of the doc object.
*/
function substitutePlaceholder(doc: unknown, realDid: string): unknown {
const PLACEHOLDER = 'did:btcr2:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
if (typeof doc === 'string') return doc.split(PLACEHOLDER).join(realDid);
if (Array.isArray(doc)) return doc.map((v) => substitutePlaceholder(v, realDid));
if (doc && typeof doc === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(doc)) {
out[k] = substitutePlaceholder(v, realDid);
}
return out;
}
return doc;
}

const extra = computed(() =>
initialDocument.value ? { label: 'Initial Document', value: initialDocument.value } : null,
resolveSidecar.value
? { label: 'Sidecar for Resolve (paste into the Resolve demo)', value: resolveSidecar.value }
: null,
);
</script>

Expand Down Expand Up @@ -217,15 +212,16 @@ const extra = computed(() =>
</div>

<div v-else-if="idType === 'EXTERNAL'" class="demo-field">
<span class="demo-label">Intermediate DID Document (JSON)</span>
<span class="demo-label">Genesis Document (JSON, placeholder ids + a beacon service; Random Inputs builds one)</span>
<textarea
class="demo-textarea"
v-model="intermediateDocText"
rows="10"
spellcheck="false"
placeholder="{
&quot;@context&quot;: [&quot;https://www.w3.org/TR/did-1.1&quot;],
&quot;id&quot;: &quot;did:btcr2:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&quot;
&quot;id&quot;: &quot;did:btcr2:_&quot;,
&quot;verificationMethod&quot;: [{ &quot;id&quot;: &quot;did:btcr2:_#key-0&quot;, … }],
&quot;service&quot;: [{ &quot;type&quot;: &quot;SingletonBeacon&quot;, … }]
}"
/>
<p v-if="intermediateDocText && intermediateDocError" class="demo-error">
Expand Down
47 changes: 36 additions & 11 deletions docs/.vitepress/theme/demos/Resolve.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ref, computed, watch } from 'vue';
import type { NetworkName } from '@did-btcr2/api';
import DemoCard from '../components/DemoCard.vue';
import { useDidBtcr2 } from '../composables/useDidBtcr2';
import { formatError } from './errors';
import './demo-fields.css';

const networks: readonly NetworkName[] = ['bitcoin', 'testnet3', 'testnet4', 'signet', 'mutinynet', 'regtest'];
Expand Down Expand Up @@ -44,15 +45,42 @@ const canRun = computed(
!!(selectedNetwork.value || inferredNetwork.value),
);

const SIDECAR_KEYS = ['genesisDocument', 'updates', 'casUpdates', 'smtProofs'];

/**
* The library reads the genesis document from `sidecar.genesisDocument`.
* Accept either a full sidecar object or a bare placeholder-form genesis
* document (the Create demo's textarea content) and wrap the latter.
* Returns undefined when the text is empty, invalid JSON, or an empty object.
*/
function normalizeSidecar(raw: string): Record<string, unknown> | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return undefined;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
const obj = parsed as Record<string, unknown>;
if (Object.keys(obj).length === 0) return undefined;
if (SIDECAR_KEYS.some((k) => k in obj)) return obj;
if (typeof obj.id === 'string') return { genesisDocument: obj };
return obj;
}

const snippet = computed(() => {
const id = did.value || 'did:btcr2:k1...';
const net = selectedNetwork.value || inferredNetwork.value || 'regtest';
const trimmedSidecar = sidecarText.value.trim();
if (isExternal.value && trimmedSidecar && trimmedSidecar !== '{}') {
const sidecar = isExternal.value ? normalizeSidecar(sidecarText.value) : undefined;
if (sidecar) {
return `import { createApi } from '@did-btcr2/api';

const api = createApi({ btc: { network: '${net}' } });
const result = await api.resolveDid('${id}', { sidecar: ${trimmedSidecar} });
// x1 DIDs resolve from the placeholder-form genesis document, supplied via
// sidecar.genesisDocument (or fetched from a configured CAS).
const result = await api.resolveDid('${id}', { sidecar: ${JSON.stringify(sidecar, null, 2)} });
console.log(result);`;
}
return `import { createApi } from '@did-btcr2/api';
Expand All @@ -69,13 +97,10 @@ async function run() {
const net = (selectedNetwork.value || inferredNetwork.value) as Network;
const api = createApiForNetwork(net);
try {
const opts =
isExternal.value && sidecarText.value.trim()
? { sidecar: JSON.parse(sidecarText.value) }
: undefined;
response.value = await api.resolveDid(did.value, opts);
const sidecar = isExternal.value ? normalizeSidecar(sidecarText.value) : undefined;
response.value = await api.resolveDid(did.value, sidecar ? { sidecar } : undefined);
} catch (err: unknown) {
response.value = err instanceof Error ? err.stack || err.message : String(err);
response.value = formatError(err);
} finally {
api.dispose();
running.value = false;
Expand Down Expand Up @@ -116,13 +141,13 @@ async function run() {
</div>

<div v-if="isExternal" class="demo-field">
<span class="demo-label">Sidecar Data (JSON, optional — required for x1 DIDs without CAS)</span>
<span class="demo-label">Sidecar Data (JSON; x1 DIDs need the placeholder-form genesis document from Create)</span>
<textarea
class="demo-textarea"
v-model="sidecarText"
rows="6"
spellcheck="false"
placeholder="{ &quot;initialDocument&quot;: { ... } }"
placeholder="{ &quot;genesisDocument&quot;: { &quot;id&quot;: &quot;did:btcr2:_&quot;, … } }"
/>
<p v-if="sidecarText && sidecarError" class="demo-error">
JSON error: {{ sidecarError }}
Expand Down
3 changes: 2 additions & 1 deletion docs/.vitepress/theme/demos/Update.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { NetworkName } from '@did-btcr2/api';
import DemoCard from '../components/DemoCard.vue';
import { useDidBtcr2 } from '../composables/useDidBtcr2';
import { hexToBytes, isHex } from './hex';
import { formatError } from './errors';
import './demo-fields.css';

const networks: readonly NetworkName[] = ['bitcoin', 'testnet3', 'testnet4', 'signet', 'mutinynet', 'regtest'];
Expand Down Expand Up @@ -111,7 +112,7 @@ async function run() {
});
response.value = result;
} catch (err: unknown) {
response.value = err instanceof Error ? err.stack || err.message : String(err);
response.value = formatError(err);
} finally {
api.dispose();
running.value = false;
Expand Down
16 changes: 16 additions & 0 deletions docs/.vitepress/theme/demos/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Format an error for display in a demo response pane. The @did-btcr2/api
* facade wraps every failure in a generic Error ("Failed to resolve DID: ...")
* and puts the actual reason in `error.cause`, which neither `message` nor
* `stack` includes, so walk the cause chain explicitly.
*/
export function formatError(err: unknown): string {
if (!(err instanceof Error)) return String(err);
const lines = [err.message];
let cause: unknown = err.cause;
while (cause !== undefined && cause !== null) {
lines.push(`caused by: ${cause instanceof Error ? cause.message : String(cause)}`);
cause = cause instanceof Error ? cause.cause : undefined;
}
return lines.join('\n');
}
19 changes: 12 additions & 7 deletions docs/demo.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ exercise the **TypeScript** reference implementation (`@did-btcr2/api`, backed b
`@did-btcr2/method`, `@did-btcr2/keypair`, and `@did-btcr2/common`) directly in
your browser via dynamic imports.

* [Create](#create) — produce a new `did:btcr2` identifier from a public key or an intermediate DID document.
* [Create](#create) — produce a new `did:btcr2` identifier from a public key or a Genesis Document.
* [Resolve](#resolve) — resolve an identifier using Bitcoin beacon signals and optional sidecar data.
* [Update](#update) — apply a JSON Patch to the DID document and announce it on-chain.
* [Deactivate](#deactivate) — special-case Update that adds `{"deactivated": true}` to the DID document.
Expand All @@ -20,10 +20,12 @@ Creating a `did:btcr2` identifier is fully off-chain — no network round-trip i
needed. The Create operation accepts either:

* **`KEY` (deterministic)** — a compressed secp256k1 public key (33 bytes, SEC-encoded).
* **`EXTERNAL`** — an [intermediate DID document](https://dcdpr.github.io/did-btcr2/#def-intermediate-did-document)
with every identifier replaced by the placeholder
`did:btcr2:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`.
The identifier encodes the SHA-256 hash of the canonicalized document.
* **`EXTERNAL`** — the SHA-256 hash of a JCS-canonicalized
[Genesis Document](https://dcdpr.github.io/did-btcr2/terminology.html#genesis-document):
a DID document written against the placeholder identifier `did:btcr2:_` that must
include at least one beacon `service` entry. **Random Inputs** builds a valid one via
`GenesisDocument.fromPublicKey(pubkey, network)`. Keep the document: the hash is
one-way, so resolving the resulting `x1…` identifier requires it as sidecar data.

Supported networks: `bitcoin`, `testnet3`, `testnet4`, `signet`, `mutinynet`, `regtest`.

Expand All @@ -34,8 +36,11 @@ Supported networks: `bitcoin`, `testnet3`, `testnet4`, `signet`, `mutinynet`, `r
Resolution drives the [`Resolver`](https://dcdpr.github.io/did-btcr2/operations/resolve.html)
state machine. The `@did-btcr2/api` facade injects the Bitcoin connection for
the configured network so beacon signals can be fetched automatically. For
`did:btcr2:x1…` identifiers you may also provide sidecar data containing the
initial document, signed updates, CAS announcements and/or SMT proofs.
`did:btcr2:x1…` identifiers the identifier encodes only a hash, so resolution
also needs the Genesis Document supplied as sidecar data:
`{ "genesisDocument": … }` (the Create demo's "Sidecar for Resolve" output
pastes straight in; a bare genesis document is wrapped automatically), plus any
signed updates, CAS announcements and/or SMT proofs the DID's history requires.

<DemoResolve />

Expand Down
2 changes: 2 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild
Loading