From cf000f3a319cac4ca4e5ae311b48792cde714f3c Mon Sep 17 00:00:00 2001 From: truthixify Date: Sun, 14 Jun 2026 00:11:01 +0100 Subject: [PATCH 1/9] feat(did-ckb): add did:ckb identifier helpers base32 (RFC 4648 lowercase no-padding) plus argsToDid, didToArgs, and isDidCkb for converting between the 20-byte Type ID args returned by createDidCkb and the human-readable did:ckb URI form per WIP-01. Vitest covers known vectors and edge cases. --- packages/did-ckb/src/barrel.ts | 1 + packages/did-ckb/src/identifier.test.ts | 76 +++++++++++++++++ packages/did-ckb/src/identifier.ts | 105 ++++++++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 packages/did-ckb/src/identifier.test.ts create mode 100644 packages/did-ckb/src/identifier.ts diff --git a/packages/did-ckb/src/barrel.ts b/packages/did-ckb/src/barrel.ts index 6112c76ce..42805ed59 100644 --- a/packages/did-ckb/src/barrel.ts +++ b/packages/did-ckb/src/barrel.ts @@ -1,2 +1,3 @@ export * from "./codec.js"; export * from "./didCkb.js"; +export * from "./identifier.js"; diff --git a/packages/did-ckb/src/identifier.test.ts b/packages/did-ckb/src/identifier.test.ts new file mode 100644 index 000000000..9bb0f6ccf --- /dev/null +++ b/packages/did-ckb/src/identifier.test.ts @@ -0,0 +1,76 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it } from "vitest"; +import { + argsToDid, + base32Decode, + base32Encode, + didToArgs, + isDidCkb, +} from "./identifier.js"; + +describe("base32", () => { + it("round-trips known vectors", () => { + const cases: [string, string][] = [ + ["", ""], + ["f", "my"], + ["fo", "mzxq"], + ["foo", "mzxw6"], + ["foob", "mzxw6yq"], + ["fooba", "mzxw6ytb"], + ["foobar", "mzxw6ytboi"], + ]; + for (const [input, expected] of cases) { + const bytes = new TextEncoder().encode(input); + expect(base32Encode(bytes)).toBe(expected); + expect(new TextDecoder().decode(base32Decode(expected))).toBe(input); + } + }); + + it("rejects invalid characters", () => { + expect(() => base32Decode("!!!")).toThrow(/Invalid base32 character/); + }); + + it("accepts hex input via ccc.bytesFrom", () => { + expect(base32Encode("0xdeadbeef")).toBe(base32Encode(ccc.bytesFrom("0xdeadbeef"))); + }); +}); + +describe("did:ckb identifier", () => { + // 20 zero bytes -> 32 'a's + const zeros = "0x" + "00".repeat(20); + const zerosDid = "did:ckb:" + "a".repeat(32); + + it("converts args <-> did", () => { + expect(argsToDid(zeros)).toBe(zerosDid); + expect(didToArgs(zerosDid)).toBe(zeros); + }); + + it("rejects args with wrong length", () => { + expect(() => argsToDid("0x" + "00".repeat(19))).toThrow(); + expect(() => argsToDid("0x" + "00".repeat(21))).toThrow(); + }); + + it("rejects did without prefix", () => { + expect(() => didToArgs("ckb:" + "a".repeat(32))).toThrow(); + }); + + it("rejects did with wrong body length", () => { + expect(() => didToArgs("did:ckb:" + "a".repeat(31))).toThrow(); + expect(() => didToArgs("did:ckb:" + "a".repeat(33))).toThrow(); + }); + + it("isDidCkb is true only for well-formed values", () => { + expect(isDidCkb(zerosDid)).toBe(true); + expect(isDidCkb("did:plc:xyz")).toBe(false); + expect(isDidCkb("did:ckb:tooShort")).toBe(false); + expect(isDidCkb("did:ckb:" + "!".repeat(32))).toBe(false); + }); + + it("round-trips a random-looking args vector", () => { + const args = "0x0123456789abcdef0123456789abcdef01234567"; + const did = argsToDid(args); + expect(did.startsWith("did:ckb:")).toBe(true); + expect(did.length).toBe("did:ckb:".length + 32); + expect(didToArgs(did)).toBe(args); + }); +}); diff --git a/packages/did-ckb/src/identifier.ts b/packages/did-ckb/src/identifier.ts new file mode 100644 index 000000000..67b8bce48 --- /dev/null +++ b/packages/did-ckb/src/identifier.ts @@ -0,0 +1,105 @@ +import { ccc } from "@ckb-ccc/core"; + +const DID_PREFIX = "did:ckb:"; +const ARGS_LEN_BYTES = 20; +const DID_BODY_LEN = 32; + +// RFC 4648 base32, lowercase, no padding (WIP-01 §2.2.3). +const ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"; +const REVERSE = (() => { + const map: Record = {}; + for (let i = 0; i < ALPHABET.length; i++) { + map[ALPHABET[i]] = i; + } + return map; +})(); + +export function base32Encode(bytes: ccc.BytesLike): string { + const buf = ccc.bytesFrom(bytes); + let bits = 0; + let value = 0; + let output = ""; + for (let i = 0; i < buf.length; i++) { + value = (value << 8) | buf[i]; + bits += 8; + while (bits >= 5) { + output += ALPHABET[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) { + output += ALPHABET[(value << (5 - bits)) & 31]; + } + return output; +} + +export function base32Decode(input: string): ccc.Bytes { + const cleaned = input.toLowerCase().replace(/=+$/g, ""); + let bits = 0; + let value = 0; + const output: number[] = []; + for (const char of cleaned) { + const v = REVERSE[char]; + if (v === undefined) { + throw new Error(`Invalid base32 character "${char}" in input`); + } + value = (value << 5) | v; + bits += 5; + if (bits >= 8) { + output.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + return Uint8Array.from(output); +} + +/** + * Convert 20-byte Type ID args (the `id` returned from `createDidCkb`) to a + * `did:ckb:` identifier string per WIP-01 §2.2. + */ +export function argsToDid(args: ccc.HexLike): string { + const bytes = ccc.bytesFrom(args); + if (bytes.length !== ARGS_LEN_BYTES) { + throw new Error( + `did:ckb args must be ${ARGS_LEN_BYTES} bytes, got ${bytes.length}`, + ); + } + return DID_PREFIX + base32Encode(bytes); +} + +/** + * Reverse of `argsToDid`. Validates the prefix, base32 length, and decoded + * byte count. + */ +export function didToArgs(did: string): ccc.Hex { + if (!did.startsWith(DID_PREFIX)) { + throw new Error(`Expected did:ckb:..., got "${did}"`); + } + const body = did.slice(DID_PREFIX.length); + // 20 bytes encode to exactly 32 base32 chars without padding. Any other + // length silently truncates leftover bits, so reject it up front. + if (body.length !== DID_BODY_LEN) { + throw new Error( + `did:ckb identifier must be ${DID_BODY_LEN} base32 chars, got ${body.length}`, + ); + } + const bytes = base32Decode(body); + if (bytes.length !== ARGS_LEN_BYTES) { + throw new Error( + `did:ckb identifier must decode to ${ARGS_LEN_BYTES} bytes, got ${bytes.length}`, + ); + } + return ccc.hexFrom(bytes); +} + +export function isDidCkb(value: string): boolean { + if (!value.startsWith(DID_PREFIX)) { + return false; + } + try { + didToArgs(value); + return true; + } catch { + return false; + } +} From 833fcd8e4afa5031d6500b80ea70a7246836c5eb Mon Sep 17 00:00:00 2001 From: truthixify Date: Sun, 14 Jun 2026 00:11:09 +0100 Subject: [PATCH 2/9] feat(did-ckb): add resolver helpers (findDidCkbCell, resolveDidCkb, listDidCkbsByLock) findDidCkbCell wraps findSingletonCellByType and decodes DidCkbData. resolveDidCkb takes a did:ckb string instead of raw Type ID args. listDidCkbsByLock enumerates every live DID owned by a lock, useful for wallet/dashboard reverse lookup. Bad cells are skipped rather than fail the whole listing. --- packages/did-ckb/src/barrel.ts | 1 + packages/did-ckb/src/resolver.ts | 123 +++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 packages/did-ckb/src/resolver.ts diff --git a/packages/did-ckb/src/barrel.ts b/packages/did-ckb/src/barrel.ts index 42805ed59..c9a60772a 100644 --- a/packages/did-ckb/src/barrel.ts +++ b/packages/did-ckb/src/barrel.ts @@ -1,3 +1,4 @@ export * from "./codec.js"; export * from "./didCkb.js"; export * from "./identifier.js"; +export * from "./resolver.js"; diff --git a/packages/did-ckb/src/resolver.ts b/packages/did-ckb/src/resolver.ts new file mode 100644 index 000000000..16a41023b --- /dev/null +++ b/packages/did-ckb/src/resolver.ts @@ -0,0 +1,123 @@ +import { ccc } from "@ckb-ccc/core"; +import { DidCkbData } from "./codec"; +import { argsToDid, didToArgs, isDidCkb } from "./identifier"; + +export type DidCkbRecord = { + /** The `did:ckb:` string identifying this cell. */ + did: string; + /** 20-byte Type ID args (same value `createDidCkb` returned as `id`). */ + id: ccc.Hex; + /** Decoded DidCkbData; `.value.document` is the CBOR-decoded document. */ + data: DidCkbData; + /** The live DID Metadata Cell itself. */ + cell: ccc.Cell; +}; + +async function didCkbTypeScript( + client: ccc.Client, + id: ccc.HexLike, +): Promise { + const scriptInfo = await client.getKnownScript(ccc.KnownScript.DidCkb); + return ccc.Script.from({ + codeHash: scriptInfo.codeHash, + hashType: scriptInfo.hashType, + args: ccc.hexFrom(id), + }); +} + +function decodeRecord(cell: ccc.Cell, id: ccc.Hex): DidCkbRecord { + return { + did: argsToDid(id), + id, + data: DidCkbData.decode(cell.outputData), + cell, + }; +} + +/** + * Find the live DID Metadata Cell for a given Type ID and decode its data. + * + * Returns `undefined` when no live cell exists (the DID was never created or + * has been destroyed). + */ +export async function findDidCkbCell(props: { + client: ccc.Client; + id: ccc.HexLike; +}): Promise { + const id = ccc.hexFrom(props.id); + const type = await didCkbTypeScript(props.client, id); + const cell = await props.client.findSingletonCellByType(type, true); + if (!cell) { + return undefined; + } + return decodeRecord(cell, id); +} + +/** + * Resolve a `did:ckb:` string to its live cell + decoded document. + * + * Throws if `did` is not a syntactically valid did:ckb identifier. Returns + * `undefined` if the DID was created and then destroyed, or never existed. + */ +export async function resolveDidCkb(props: { + client: ccc.Client; + did: string; +}): Promise { + if (!isDidCkb(props.did)) { + throw new Error(`Not a did:ckb identifier: ${props.did}`); + } + return findDidCkbCell({ client: props.client, id: didToArgs(props.did) }); +} + +/** + * List every live DID Metadata Cell owned by the given lock. Useful for + * dashboards that want to enumerate the DIDs an address controls. + * + * Cells whose data fails to decode (e.g. a future on-chain schema version) are + * skipped, not thrown, so a single bad cell can't break the whole listing. + */ +export async function listDidCkbsByLock(props: { + client: ccc.Client; + lock: ccc.ScriptLike; + limit?: number; + order?: "asc" | "desc"; +}): Promise { + const scriptInfo = await props.client.getKnownScript(ccc.KnownScript.DidCkb); + const records: DidCkbRecord[] = []; + + // Filter by type.codeHash + hashType (args is prefix-matched against "0x", + // which matches any args). This is the standard indexer pattern for + // "any cell of this type, regardless of identifier". + for await (const cell of props.client.findCells( + { + script: ccc.Script.from(props.lock), + scriptType: "lock", + scriptSearchMode: "exact", + filter: { + script: ccc.Script.from({ + codeHash: scriptInfo.codeHash, + hashType: scriptInfo.hashType, + args: "0x", + }), + }, + withData: true, + }, + props.order, + props.limit, + )) { + const type = cell.cellOutput.type; + if (!type) { + continue; + } + const argsBytes = ccc.bytesFrom(type.args); + if (argsBytes.length !== 20) { + continue; + } + try { + records.push(decodeRecord(cell, ccc.hexFrom(type.args))); + } catch { + // Skip unparseable cells rather than fail the whole listing. + } + } + return records; +} From 3d8f587c311226e18d5eacb8f414f0683f9122d6 Mon Sep 17 00:00:00 2001 From: truthixify Date: Sun, 14 Jun 2026 00:11:41 +0100 Subject: [PATCH 3/9] feat(did-ckb): add getDidCkbHistory cell-chain walk Walks backward through the DID cell chain by reading each transferring tx and matching its inputs against the DID Type ID. Returns ordered entries with action (CREATE for fresh mint, MIGRATE for did:plc import, UPDATE for transfers), tx hash, output index, block number, capacity, and decoded DidCkbData. maxSteps caps the walk to prevent runaways. --- packages/did-ckb/src/barrel.ts | 1 + packages/did-ckb/src/history.ts | 146 ++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 packages/did-ckb/src/history.ts diff --git a/packages/did-ckb/src/barrel.ts b/packages/did-ckb/src/barrel.ts index c9a60772a..5312cc386 100644 --- a/packages/did-ckb/src/barrel.ts +++ b/packages/did-ckb/src/barrel.ts @@ -1,4 +1,5 @@ export * from "./codec.js"; export * from "./didCkb.js"; +export * from "./history.js"; export * from "./identifier.js"; export * from "./resolver.js"; diff --git a/packages/did-ckb/src/history.ts b/packages/did-ckb/src/history.ts new file mode 100644 index 000000000..fdacb0866 --- /dev/null +++ b/packages/did-ckb/src/history.ts @@ -0,0 +1,146 @@ +import { ccc } from "@ckb-ccc/core"; +import { DidCkbData } from "./codec"; +import { findDidCkbCell } from "./resolver"; + +export type HistoryAction = "CREATE" | "UPDATE" | "MIGRATE"; + +export type HistoryEntry = { + /** + * `CREATE` for a fresh mint, `MIGRATE` for a did:plc import (genesis cell + * with a `localId` set), `UPDATE` for every subsequent transfer. + */ + action: HistoryAction; + txHash: ccc.Hex; + outputIndex: ccc.Num; + blockNumber?: ccc.Num; + capacity: ccc.Num; + data: DidCkbData; +}; + +const DEFAULT_MAX_STEPS = 50; + +/** + * Walk the DID cell chain backwards to produce the ordered list of operations + * applied to a DID. + * + * Each `transferDidCkb` consumes the previous DID cell as an input and creates + * a new one with the same Type ID args; the genesis (`createDidCkb` / + * `createDidCkb` with localId) has no DID input. We start from the live cell, + * read its tx, look for the prior DID cell among the inputs, and repeat. The + * first entry returned is the newest (most recent transfer); the last is the + * genesis. + * + * Cost: roughly one `getTransaction` call per step, plus up to one call per + * non-DID input on each step to verify it isn't the prior DID cell. For typical + * DIDs with a handful of updates that's a small handful of RPC calls. + */ +export async function getDidCkbHistory(props: { + client: ccc.Client; + id: ccc.HexLike; + /** Pre-resolved live cell; if omitted, we fetch it. */ + liveCell?: ccc.Cell; + /** Safety bound to prevent runaway walks. Default 50. */ + maxSteps?: number; +}): Promise { + const id = ccc.hexFrom(props.id); + const scriptInfo = await props.client.getKnownScript(ccc.KnownScript.DidCkb); + const codeHash = scriptInfo.codeHash.toLowerCase(); + const normalizedId = id.toLowerCase(); + + let cell: ccc.Cell | undefined = + props.liveCell ?? + (await findDidCkbCell({ client: props.client, id }))?.cell; + if (!cell) { + return []; + } + + const history: HistoryEntry[] = []; + const maxSteps = props.maxSteps ?? DEFAULT_MAX_STEPS; + let steps = 0; + + while (cell && steps < maxSteps) { + steps++; + const tx = await props.client.getTransaction(cell.outPoint.txHash); + if (!tx) { + break; + } + + const entry = decodeEntry(cell, tx.blockNumber); + if (!entry) { + break; + } + + const prior = await findPriorDidCell( + props.client, + tx.transaction, + codeHash, + normalizedId, + ); + + if (!prior) { + // No DID input means this tx is the genesis. If the genesis carries a + // `localId` it's a did:plc migration; otherwise a plain CREATE. + entry.action = entry.data.value.localId ? "MIGRATE" : "CREATE"; + history.push(entry); + break; + } + + entry.action = "UPDATE"; + history.push(entry); + cell = prior; + } + + return history; +} + +async function findPriorDidCell( + client: ccc.Client, + tx: ccc.Transaction, + codeHash: string, + id: string, +): Promise { + for (const input of tx.inputs) { + const prevHash = input.previousOutput.txHash; + const prevIdx = input.previousOutput.index; + const prevTx = await client.getTransaction(prevHash); + if (!prevTx) { + continue; + } + const prevOutput = prevTx.transaction.outputs[Number(prevIdx)]; + if (!prevOutput?.type) { + continue; + } + if (prevOutput.type.codeHash.toLowerCase() !== codeHash) { + continue; + } + if (ccc.hexFrom(prevOutput.type.args).toLowerCase() !== id) { + continue; + } + const data = prevTx.transaction.outputsData[Number(prevIdx)]; + return ccc.Cell.from({ + outPoint: { txHash: prevHash, index: prevIdx }, + cellOutput: prevOutput, + outputData: data, + }); + } + return undefined; +} + +function decodeEntry( + cell: ccc.Cell, + blockNumber?: ccc.Num, +): HistoryEntry | undefined { + try { + const data = DidCkbData.decode(cell.outputData); + return { + action: "UPDATE", + txHash: cell.outPoint.txHash, + outputIndex: cell.outPoint.index, + blockNumber, + capacity: cell.cellOutput.capacity, + data, + }; + } catch { + return undefined; + } +} From b09f741a917856ae57ae81a2039ceb309434f11c Mon Sep 17 00:00:00 2001 From: truthixify Date: Sun, 14 Jun 2026 00:12:14 +0100 Subject: [PATCH 4/9] feat(did-ckb): add @ckb-ccc/did-ckb/plc subpath for did:plc helpers Minimal did:plc surface scoped to what a did:ckb migration needs. - fetchPlcLog: read the op log from the public PLC directory - getGenesisOperation / getRotationKeys: extract genesis and parse modern (rotationKeys) and legacy (signingKey + recoveryKey) shapes - parseDidKey: decode multicodec-tagged did:key (secp256k1 / p256) - signRotationHash: ECDSA sign a 32-byte CKB tx hash with prehash enabled so SHA-256 is applied internally, matching the on-chain k256/p256 Verifier - verifyPrivateKeyMatch: sanity check before sending Adds @noble/curves as a dependency. Exposed via the ./plc subpath so consumers who only need history or resolution don't pull the curve code into their bundle. --- packages/did-ckb/package.json | 7 +- packages/did-ckb/src/barrel.ts | 1 + packages/did-ckb/src/plc/index.ts | 225 +++++++++++++++++++++++++++ packages/did-ckb/src/plc/plc.test.ts | 98 ++++++++++++ packages/did-ckb/tsdown.config.mts | 2 + 5 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 packages/did-ckb/src/plc/index.ts create mode 100644 packages/did-ckb/src/plc/plc.test.ts diff --git a/packages/did-ckb/package.json b/packages/did-ckb/package.json index e61651208..d8a210895 100644 --- a/packages/did-ckb/package.json +++ b/packages/did-ckb/package.json @@ -21,6 +21,10 @@ "require": "./dist.commonjs/barrel.js", "import": "./dist/barrel.mjs" }, + "./plc": { + "require": "./dist.commonjs/plc.js", + "import": "./dist/plc.mjs" + }, "./package.json": "./package.json" }, "scripts": { @@ -49,7 +53,8 @@ "dependencies": { "@ckb-ccc/core": "workspace:*", "@ckb-ccc/type-id": "workspace:*", - "@ipld/dag-cbor": "^9.2.5" + "@ipld/dag-cbor": "^9.2.5", + "@noble/curves": "^1.9.7" }, "packageManager": "pnpm@10.8.1", "types": "./dist.commonjs/index.d.ts" diff --git a/packages/did-ckb/src/barrel.ts b/packages/did-ckb/src/barrel.ts index 5312cc386..f4ba1edf1 100644 --- a/packages/did-ckb/src/barrel.ts +++ b/packages/did-ckb/src/barrel.ts @@ -2,4 +2,5 @@ export * from "./codec.js"; export * from "./didCkb.js"; export * from "./history.js"; export * from "./identifier.js"; +export * as plc from "./plc/index.js"; export * from "./resolver.js"; diff --git a/packages/did-ckb/src/plc/index.ts b/packages/did-ckb/src/plc/index.ts new file mode 100644 index 000000000..d5d86805f --- /dev/null +++ b/packages/did-ckb/src/plc/index.ts @@ -0,0 +1,225 @@ +import { ccc } from "@ckb-ccc/core"; +import { p256 } from "@noble/curves/p256"; +import { secp256k1 } from "@noble/curves/secp256k1"; + +/** + * Minimal did:plc helpers, scoped to what a did:ckb migration needs: + * fetch the op log from the public PLC directory, parse rotation keys, and + * sign a 32-byte CKB transaction hash with a rotation private key. + * + * The on-chain contract is happy with a history of length 1 (WIP-02 §3.1.1 + * RECOMMENDs the genesis-only form), so we don't walk the full op chain + * client-side. + */ + +const PLC_DIRECTORY = "https://plc.directory"; + +export type Curve = "secp256k1" | "p256"; + +export type PlcRotationKey = { + didKey: string; + curve: Curve; + compressedPubkey: ccc.Bytes; +}; + +export type PlcOperation = { + type: string; + rotationKeys?: string[]; + verificationMethods?: Record; + alsoKnownAs?: string[]; + services?: Record; + prev: string | null; + sig: string; + // Legacy `create` op fields: + signingKey?: string; + recoveryKey?: string; + handle?: string; + service?: string; + [key: string]: unknown; +}; + +/** + * Fetch the operation log for a did:plc identifier from the public directory. + * Pass a custom directory URL when targeting a staging environment. + */ +export async function fetchPlcLog( + did: string, + directory: string = PLC_DIRECTORY, +): Promise { + if (!did.startsWith("did:plc:")) { + throw new Error(`Expected did:plc identifier, got "${did}"`); + } + const res = await fetch(`${directory}/${did}/log`); + if (!res.ok) { + throw new Error(`PLC log fetch failed (${res.status} ${res.statusText})`); + } + const data: unknown = await res.json(); + if (!Array.isArray(data) || data.length === 0) { + throw new Error("PLC log empty or malformed"); + } + return data as PlcOperation[]; +} + +export function getGenesisOperation(log: PlcOperation[]): PlcOperation { + if (log.length === 0) { + throw new Error("Empty PLC log"); + } + return log[0]; +} + +/** + * Surface the rotation keys declared in a genesis op as a uniform list. PLC + * genesis comes in two shapes: the modern `plc_operation` (with + * `rotationKeys`) and the deprecated `create` (with `signingKey` + + * `recoveryKey`). The on-chain contract treats the legacy form as + * `[signingKey, recoveryKey]`, so we expose the same order. + */ +export function getRotationKeys(op: PlcOperation): PlcRotationKey[] { + const keys: string[] = []; + if (op.rotationKeys?.length) { + keys.push(...op.rotationKeys); + } else if (op.signingKey || op.recoveryKey) { + if (op.signingKey) { + keys.push(op.signingKey); + } + if (op.recoveryKey) { + keys.push(op.recoveryKey); + } + } + return keys.map((didKey) => { + const parsed = parseDidKey(didKey); + return { + didKey, + curve: parsed.curve, + compressedPubkey: parsed.compressedPubkey, + }; + }); +} + +export function parseDidKey(didKey: string): { + curve: Curve; + compressedPubkey: ccc.Bytes; +} { + if (!didKey.startsWith("did:key:z")) { + throw new Error(`Expected did:key:z..., got "${didKey}"`); + } + const raw = base58btcDecode(didKey.slice("did:key:z".length)); + if (raw.length !== 35) { + throw new Error( + `did:key payload must be 35 bytes (2-byte multicodec tag + 33-byte compressed pubkey), got ${raw.length}`, + ); + } + const tag1 = raw[0]; + const tag2 = raw[1]; + let curve: Curve; + if (tag1 === 0xe7 && tag2 === 0x01) { + curve = "secp256k1"; + } else if (tag1 === 0x80 && tag2 === 0x24) { + curve = "p256"; + } else { + throw new Error( + `Unrecognised did:key multicodec tag: 0x${tag1.toString(16).padStart(2, "0")} 0x${tag2.toString(16).padStart(2, "0")}`, + ); + } + return { curve, compressedPubkey: raw.slice(2) }; +} + +/** + * Sign a 32-byte CKB tx hash with a PLC rotation private key. + * + * The on-chain validator runs k256/p256's `Verifier::verify` which internally + * SHA-256 hashes the message before checking the ECDSA signature. Noble's + * default is the opposite (treat input as already-hashed), so we pass + * `prehash: true` to make noble apply SHA-256 internally and match the + * contract. Output is canonical low-s 64-byte compact form, identical to + * what @atproto/crypto's Keypair.sign produces. + */ +export function signRotationHash( + privateKey: ccc.BytesLike, + txHash: ccc.BytesLike, + curve: Curve, +): ccc.Bytes { + const hash = ccc.bytesFrom(txHash); + if (hash.length !== 32) { + throw new Error(`Expected 32-byte tx hash, got ${hash.length}`); + } + const priv = ccc.bytesFrom(privateKey); + if (priv.length !== 32) { + throw new Error(`Expected 32-byte private key, got ${priv.length}`); + } + if (curve === "secp256k1") { + return secp256k1 + .sign(hash, priv, { prehash: true, lowS: true }) + .toCompactRawBytes(); + } + return p256 + .sign(hash, priv, { prehash: true, lowS: true }) + .toCompactRawBytes(); +} + +/** + * Quick sanity check: derive the public key from `privateKey` and compare + * against `expectedPubkey`. Lets a UI tell the user "wrong key for this + * rotation slot" without waiting for an on-chain rejection. + */ +export function verifyPrivateKeyMatch( + privateKey: ccc.BytesLike, + expectedPubkey: ccc.BytesLike, + curve: Curve, +): boolean { + const priv = ccc.bytesFrom(privateKey); + if (priv.length !== 32) { + return false; + } + const expected = ccc.bytesFrom(expectedPubkey); + const pub = + curve === "secp256k1" + ? secp256k1.getPublicKey(priv, true) + : p256.getPublicKey(priv, true); + if (pub.length !== expected.length) { + return false; + } + for (let i = 0; i < pub.length; i++) { + if (pub[i] !== expected[i]) { + return false; + } + } + return true; +} + +// Inline base58btc decode (Bitcoin alphabet) so we don't pull in multiformats +// as a direct dependency. Audited by the multicodec tag round-trip tests. +const BASE58_ALPHABET = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +const BASE58_REVERSE: Record = (() => { + const m: Record = {}; + for (let i = 0; i < BASE58_ALPHABET.length; i++) { + m[BASE58_ALPHABET[i]] = i; + } + return m; +})(); + +function base58btcDecode(input: string): ccc.Bytes { + if (input.length === 0) { + return new Uint8Array(0); + } + let leadingOnes = 0; + while (leadingOnes < input.length && input[leadingOnes] === "1") { + leadingOnes++; + } + let value = 0n; + for (let i = leadingOnes; i < input.length; i++) { + const c = input[i]; + const v = BASE58_REVERSE[c]; + if (v === undefined) { + throw new Error(`Invalid base58 character "${c}"`); + } + value = value * 58n + BigInt(v); + } + const tail: number[] = []; + while (value > 0n) { + tail.unshift(Number(value & 0xffn)); + value >>= 8n; + } + return Uint8Array.from([...Array(leadingOnes).fill(0), ...tail]); +} diff --git a/packages/did-ckb/src/plc/plc.test.ts b/packages/did-ckb/src/plc/plc.test.ts new file mode 100644 index 000000000..e061262fb --- /dev/null +++ b/packages/did-ckb/src/plc/plc.test.ts @@ -0,0 +1,98 @@ +import { secp256k1 } from "@noble/curves/secp256k1"; +import { describe, expect, it } from "vitest"; +import { + getGenesisOperation, + getRotationKeys, + parseDidKey, + signRotationHash, + verifyPrivateKeyMatch, + type PlcOperation, +} from "./index.js"; + +describe("parseDidKey", () => { + it("recognises secp256k1 multicodec tag (0xe7 0x01)", () => { + // Real did:key from the bluesky network — secp256k1 public key. + const didKey = "did:key:zQ3shqtXEdagupBhLzL2vFUACfdVjDEvciip79uY8iHBuu7FD"; + const { curve, compressedPubkey } = parseDidKey(didKey); + expect(curve).toBe("secp256k1"); + expect(compressedPubkey.length).toBe(33); + }); + + it("recognises p256 multicodec tag (0x80 0x24)", () => { + const didKey = "did:key:zDnaefn5fMKvoZ1n4vyxJ9npjWE5P3D8GkM9zNqaGbLqdDrtX"; + const { curve, compressedPubkey } = parseDidKey(didKey); + expect(curve).toBe("p256"); + expect(compressedPubkey.length).toBe(33); + }); + + it("rejects unknown prefixes", () => { + expect(() => parseDidKey("did:key:abc")).toThrow(); + expect(() => parseDidKey("did:plc:abc")).toThrow(); + }); +}); + +describe("getRotationKeys", () => { + it("returns rotationKeys from modern plc_operation", () => { + const op: PlcOperation = { + type: "plc_operation", + rotationKeys: [ + "did:key:zQ3shqtXEdagupBhLzL2vFUACfdVjDEvciip79uY8iHBuu7FD", + "did:key:zDnaefn5fMKvoZ1n4vyxJ9npjWE5P3D8GkM9zNqaGbLqdDrtX", + ], + prev: null, + sig: "x", + }; + const keys = getRotationKeys(op); + expect(keys.map((k) => k.curve)).toEqual(["secp256k1", "p256"]); + }); + + it("falls back to signingKey + recoveryKey from legacy create op", () => { + const op: PlcOperation = { + type: "create", + signingKey: "did:key:zQ3shqtXEdagupBhLzL2vFUACfdVjDEvciip79uY8iHBuu7FD", + recoveryKey: "did:key:zQ3shqtXEdagupBhLzL2vFUACfdVjDEvciip79uY8iHBuu7FD", + prev: null, + sig: "x", + }; + const keys = getRotationKeys(op); + expect(keys.length).toBe(2); + }); +}); + +describe("getGenesisOperation", () => { + it("returns the first op", () => { + const op: PlcOperation = { type: "x", prev: null, sig: "y" }; + expect(getGenesisOperation([op])).toBe(op); + }); + it("throws on empty log", () => { + expect(() => getGenesisOperation([])).toThrow(); + }); +}); + +describe("signRotationHash + verifyPrivateKeyMatch", () => { + it("round-trips: signature verifies, private key matches its public", () => { + const priv = secp256k1.utils.randomSecretKey(); + const pub = secp256k1.getPublicKey(priv, true); + expect(verifyPrivateKeyMatch(priv, pub, "secp256k1")).toBe(true); + + const txHash = new Uint8Array(32).fill(7); + const sig = signRotationHash(priv, txHash, "secp256k1"); + expect(sig.length).toBe(64); + expect( + secp256k1.verify(sig, txHash, pub, { prehash: true, lowS: true }), + ).toBe(true); + }); + + it("rejects a private key whose pubkey doesn't match", () => { + const a = secp256k1.utils.randomSecretKey(); + const b = secp256k1.utils.randomSecretKey(); + expect( + verifyPrivateKeyMatch(a, secp256k1.getPublicKey(b, true), "secp256k1"), + ).toBe(false); + }); + + it("throws on wrong tx hash length", () => { + const priv = secp256k1.utils.randomSecretKey(); + expect(() => signRotationHash(priv, new Uint8Array(31), "secp256k1")).toThrow(); + }); +}); diff --git a/packages/did-ckb/tsdown.config.mts b/packages/did-ckb/tsdown.config.mts index d952699a0..a8e2efae5 100644 --- a/packages/did-ckb/tsdown.config.mts +++ b/packages/did-ckb/tsdown.config.mts @@ -14,6 +14,7 @@ export default defineConfig( entry: { index: "src/index.ts", barrel: "src/barrel.ts", + plc: "src/plc/index.ts", }, format: "esm", copy: "./misc/basedirs/dist/*", @@ -22,6 +23,7 @@ export default defineConfig( entry: { index: "src/index.ts", barrel: "src/barrel.ts", + plc: "src/plc/index.ts", }, noExternal: ["@ipld/dag-cbor"] as string[], format: "cjs", From a45a5f31b7f5a0634437fe07d5bfa1ffcec7c734 Mon Sep 17 00:00:00 2001 From: truthixify Date: Sun, 14 Jun 2026 00:12:23 +0100 Subject: [PATCH 5/9] feat(did-ckb): add migrateDidCkb and buildMigrationWitness migrateDidCkb wraps createDidCkb and stamps the source did:plc into the DidCkbDataV1.localId field. Returns the same {tx, id, index} shape so callers can drop it in wherever createDidCkb fits. buildMigrationWitness is a pure helper: takes a tx hash, the genesis PLC operation, and the chosen rotation key, signs the hash via the PLC subpath, and returns a typed DidCkbWitness ready to be set on the first input. The curve is inferred from the genesis op's rotation key unless the caller overrides it. --- packages/did-ckb/src/barrel.ts | 1 + packages/did-ckb/src/migrate.ts | 96 +++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 packages/did-ckb/src/migrate.ts diff --git a/packages/did-ckb/src/barrel.ts b/packages/did-ckb/src/barrel.ts index f4ba1edf1..f3d44ae35 100644 --- a/packages/did-ckb/src/barrel.ts +++ b/packages/did-ckb/src/barrel.ts @@ -2,5 +2,6 @@ export * from "./codec.js"; export * from "./didCkb.js"; export * from "./history.js"; export * from "./identifier.js"; +export * from "./migrate.js"; export * as plc from "./plc/index.js"; export * from "./resolver.js"; diff --git a/packages/did-ckb/src/migrate.ts b/packages/did-ckb/src/migrate.ts new file mode 100644 index 000000000..9de59b1db --- /dev/null +++ b/packages/did-ckb/src/migrate.ts @@ -0,0 +1,96 @@ +import { ccc } from "@ckb-ccc/core"; +import { DidCkbDataLike, DidCkbWitness } from "./codec"; +import { createDidCkb } from "./didCkb"; +import { + getRotationKeys, + signRotationHash, + type Curve, + type PlcOperation, +} from "./plc"; + +/** + * Build the migration witness payload that authorizes a did:plc -> did:ckb + * import. Pure: takes a tx hash + signing material, returns a typed + * `DidCkbWitness` that the caller wraps in a `WitnessArgs.outputType` field. + * + * Per WIP-02 §3.1.1 we send only the genesis operation in `history`. The + * contract recomputes the genesis CID over the CBOR-encoded op, verifies the + * self-signature inside it, then verifies `sig` against + * `history[0].rotationKeys[rotationKeyIndex]`. + * + * `selfSigIndex` defaults to 0 because PLC genesis ops are conventionally + * self-signed by the first rotation key; override if the genesis you're + * migrating used a different one. + */ +export function buildMigrationWitness(props: { + txHash: ccc.HexLike; + genesisOperation: PlcOperation; + rotationKeyIndex: number; + rotationPrivateKey: ccc.BytesLike; + curve?: Curve; + selfSigIndex?: number; +}): DidCkbWitness { + const rotationKeys = getRotationKeys(props.genesisOperation); + if (!rotationKeys[props.rotationKeyIndex]) { + throw new Error( + `rotationKeyIndex ${props.rotationKeyIndex} out of range (genesis has ${rotationKeys.length} keys)`, + ); + } + const curve = props.curve ?? rotationKeys[props.rotationKeyIndex].curve; + const sig = signRotationHash( + props.rotationPrivateKey, + ccc.bytesFrom(props.txHash), + curve, + ); + return DidCkbWitness.from({ + localIdAuthorization: { + history: [props.genesisOperation as unknown as object], + sig: ccc.hexFrom(sig), + rotationKeyIndices: [props.selfSigIndex ?? 0, props.rotationKeyIndex], + }, + }); +} + +/** + * Build a create tx whose output declares an imported did:plc identifier in + * its `localId` field. Equivalent to calling `createDidCkb` with + * `data.value.localId = sourceDid`; provided as a named helper for symmetry + * with the other DID operations and so `did:plc:` migrations have an + * obvious entry point. + * + * The caller is responsible for: completing inputs + fee, building the + * migration witness with `buildMigrationWitness`, and setting it at the + * witness slot of input 0. See `packages/examples/src/migrateDid.ts` for the + * full flow. + */ +export async function migrateDidCkb(props: { + signer: ccc.Signer; + sourceDid: string; + data?: DidCkbDataLike | null; + receiver?: ccc.ScriptLike | null; + tx?: ccc.TransactionLike | null; +}): Promise<{ + tx: ccc.Transaction; + id: ccc.Hex; + index: number; +}> { + if (!props.sourceDid.startsWith("did:plc:")) { + throw new Error( + `sourceDid must be did:plc:..., got "${props.sourceDid}"`, + ); + } + const document = props.data?.value?.document ?? {}; + const data: DidCkbDataLike = { + type: props.data?.type ?? "v1", + value: { + document, + localId: props.sourceDid, + }, + }; + return createDidCkb({ + signer: props.signer, + data, + receiver: props.receiver, + tx: props.tx, + }); +} From c54c19678e13926c13895ea5d2126cad525076f0 Mon Sep 17 00:00:00 2001 From: truthixify Date: Sun, 14 Jun 2026 00:14:44 +0100 Subject: [PATCH 6/9] chore(did-ckb): prettier format and refresh lockfile after adding @noble/curves --- packages/did-ckb/src/identifier.test.ts | 4 +++- packages/did-ckb/src/migrate.ts | 4 +--- packages/did-ckb/src/plc/plc.test.ts | 4 +++- pnpm-lock.yaml | 3 +++ 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/did-ckb/src/identifier.test.ts b/packages/did-ckb/src/identifier.test.ts index 9bb0f6ccf..39ab24d71 100644 --- a/packages/did-ckb/src/identifier.test.ts +++ b/packages/did-ckb/src/identifier.test.ts @@ -31,7 +31,9 @@ describe("base32", () => { }); it("accepts hex input via ccc.bytesFrom", () => { - expect(base32Encode("0xdeadbeef")).toBe(base32Encode(ccc.bytesFrom("0xdeadbeef"))); + expect(base32Encode("0xdeadbeef")).toBe( + base32Encode(ccc.bytesFrom("0xdeadbeef")), + ); }); }); diff --git a/packages/did-ckb/src/migrate.ts b/packages/did-ckb/src/migrate.ts index 9de59b1db..5414d17c7 100644 --- a/packages/did-ckb/src/migrate.ts +++ b/packages/did-ckb/src/migrate.ts @@ -75,9 +75,7 @@ export async function migrateDidCkb(props: { index: number; }> { if (!props.sourceDid.startsWith("did:plc:")) { - throw new Error( - `sourceDid must be did:plc:..., got "${props.sourceDid}"`, - ); + throw new Error(`sourceDid must be did:plc:..., got "${props.sourceDid}"`); } const document = props.data?.value?.document ?? {}; const data: DidCkbDataLike = { diff --git a/packages/did-ckb/src/plc/plc.test.ts b/packages/did-ckb/src/plc/plc.test.ts index e061262fb..5614245cf 100644 --- a/packages/did-ckb/src/plc/plc.test.ts +++ b/packages/did-ckb/src/plc/plc.test.ts @@ -93,6 +93,8 @@ describe("signRotationHash + verifyPrivateKeyMatch", () => { it("throws on wrong tx hash length", () => { const priv = secp256k1.utils.randomSecretKey(); - expect(() => signRotationHash(priv, new Uint8Array(31), "secp256k1")).toThrow(); + expect(() => + signRotationHash(priv, new Uint8Array(31), "secp256k1"), + ).toThrow(); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bab1068e1..1e7d89fec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -415,6 +415,9 @@ importers: '@ipld/dag-cbor': specifier: ^9.2.5 version: 9.2.5 + '@noble/curves': + specifier: ^1.9.7 + version: 1.9.7 devDependencies: '@eslint/js': specifier: ^9.34.0 From fc292d30c9152355d31207acf8d7d22f4a35d8cd Mon Sep 17 00:00:00 2001 From: truthixify Date: Sun, 14 Jun 2026 00:16:45 +0100 Subject: [PATCH 7/9] feat(examples): add resolveDid, didHistory, migrateDid playground samples Three runnable examples for the new did-ckb advanced surface. - resolveDid: resolveDidCkb on a known identifier and listDidCkbsByLock on the signer's address - didHistory: getDidCkbHistory walking the cell chain back to genesis - migrateDid: end-to-end did:plc import via fetchPlcLog, migrateDidCkb, and buildMigrationWitness, including the verifyPrivateKeyMatch sanity check before sending --- packages/examples/src/didHistory.ts | 26 +++++++++++++ packages/examples/src/migrateDid.ts | 58 +++++++++++++++++++++++++++++ packages/examples/src/resolveDid.ts | 33 ++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 packages/examples/src/didHistory.ts create mode 100644 packages/examples/src/migrateDid.ts create mode 100644 packages/examples/src/resolveDid.ts diff --git a/packages/examples/src/didHistory.ts b/packages/examples/src/didHistory.ts new file mode 100644 index 000000000..7a4cb0cd0 --- /dev/null +++ b/packages/examples/src/didHistory.ts @@ -0,0 +1,26 @@ +import { ccc } from "@ckb-ccc/ccc"; +import { signer } from "@ckb-ccc/playground"; + +// Walk the cell chain for an existing did:ckb back to its genesis. Newest +// entry is first; the last entry is CREATE (fresh mint) or MIGRATE (did:plc +// import). +const did = "did:ckb:qq2m72a2vas4e5ovcpxoedscguuu4nba"; + +const history = await ccc.didCkb.getDidCkbHistory({ + client: signer.client, + id: ccc.didCkb.didToArgs(did), +}); + +if (history.length === 0) { + console.log(`No history for ${did}; DID does not exist on this network.`); +} else { + console.log(`History of ${did}:`); + for (const entry of history) { + console.log( + ` [${entry.action}] tx=${entry.txHash} out=${entry.outputIndex} block=${entry.blockNumber ?? "?"}`, + ); + if (entry.data.value.localId) { + console.log(` localId: ${entry.data.value.localId}`); + } + } +} diff --git a/packages/examples/src/migrateDid.ts b/packages/examples/src/migrateDid.ts new file mode 100644 index 000000000..65781e1da --- /dev/null +++ b/packages/examples/src/migrateDid.ts @@ -0,0 +1,58 @@ +import { ccc } from "@ckb-ccc/ccc"; +import { render, signer } from "@ckb-ccc/playground"; + +// Import an existing did:plc into did:ckb. The on-chain contract requires a +// single PLC genesis operation in the witness plus a signature over the CKB +// transaction hash made by one of the genesis rotation keys. +const sourceDid = "did:plc:yunkr6vorfgzmvzeoofbkhq5"; +const rotationKeyIndex = 0; +const rotationPrivateKey = + "0x806d1925698097c64bc70f629e25b91b48a15eee4e492bb239402cee85356a10"; + +// Pull the genesis op from the public PLC directory. If you've already cached +// it locally, skip the fetch and pass the JSON object directly. +const log = await ccc.didCkb.plc.fetchPlcLog(sourceDid); +const genesisOperation = ccc.didCkb.plc.getGenesisOperation(log); + +// Quick sanity check before we burn capacity: does the private key match the +// rotation slot we intend to use? +const rotationKeys = ccc.didCkb.plc.getRotationKeys(genesisOperation); +const slot = rotationKeys[rotationKeyIndex]; +if ( + !ccc.didCkb.plc.verifyPrivateKeyMatch( + rotationPrivateKey, + slot.compressedPubkey, + slot.curve, + ) +) { + throw new Error( + `Private key does not match rotation key #${rotationKeyIndex} (${slot.curve})`, + ); +} + +// Build a create tx with localId stamped to sourceDid. +const { tx } = await ccc.didCkb.migrateDidCkb({ + signer, + sourceDid, + data: { value: { document: {} } }, +}); + +await tx.completeInputsByCapacity(signer); +await render(tx); +await tx.completeFeeBy(signer); +await render(tx); + +// Sign over the final tx hash and attach the migration witness on input 0. +const witness = ccc.didCkb.buildMigrationWitness({ + txHash: tx.hash(), + genesisOperation, + rotationKeyIndex, + rotationPrivateKey, +}); +tx.setWitnessArgsAt( + 0, + ccc.WitnessArgs.from({ outputType: ccc.hexFrom(witness.toBytes()) }), +); + +const txHash = await signer.sendTransaction(tx); +console.log(`Migration tx ${txHash} sent`); diff --git a/packages/examples/src/resolveDid.ts b/packages/examples/src/resolveDid.ts new file mode 100644 index 000000000..83190014b --- /dev/null +++ b/packages/examples/src/resolveDid.ts @@ -0,0 +1,33 @@ +import { ccc } from "@ckb-ccc/ccc"; +import { signer } from "@ckb-ccc/playground"; + +// Resolve a did:ckb identifier to its live cell + decoded document. +// Replace the value below with an existing identifier on whichever network +// the signer is configured for. +const did = "did:ckb:qq2m72a2vas4e5ovcpxoedscguuu4nba"; + +const record = await ccc.didCkb.resolveDidCkb({ + client: signer.client, + did, +}); +if (!record) { + console.log(`No live cell for ${did}`); +} else { + console.log(`Resolved ${record.did}`); + console.log(` Type ID args: ${record.id}`); + console.log(` Document:`, record.data.value.document); + if (record.data.value.localId) { + console.log(` Imported from: ${record.data.value.localId}`); + } +} + +// You can also enumerate every DID owned by an address. +const owner = (await signer.getRecommendedAddressObj()).script; +const owned = await ccc.didCkb.listDidCkbsByLock({ + client: signer.client, + lock: owner, +}); +console.log(`Address controls ${owned.length} DID(s):`); +for (const entry of owned) { + console.log(` ${entry.did}`); +} From ce2b005b2c20bdd8736f78e7f286b8acb40e80e5 Mon Sep 17 00:00:00 2001 From: truthixify Date: Sun, 14 Jun 2026 00:16:56 +0100 Subject: [PATCH 8/9] chore(changeset): record minor bump for did-ckb advanced features --- .changeset/did-ckb-advanced.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/did-ckb-advanced.md diff --git a/.changeset/did-ckb-advanced.md b/.changeset/did-ckb-advanced.md new file mode 100644 index 000000000..26c4d5eb6 --- /dev/null +++ b/.changeset/did-ckb-advanced.md @@ -0,0 +1,13 @@ +--- +"@ckb-ccc/did-ckb": minor +--- + +feat(did-ckb): identifier helpers, resolver, history walk, and did:plc migration + +Layered on top of the basic create/transfer/destroy operations: + +- `argsToDid`, `didToArgs`, `isDidCkb`, plus RFC 4648 base32 helpers for converting between Type ID args and the human readable `did:ckb:` URI form (WIP-01 §2.2) +- `findDidCkbCell`, `resolveDidCkb`, `listDidCkbsByLock` for resolving a DID by id or by owning lock +- `getDidCkbHistory` walks the cell chain backwards to produce an ordered list of CREATE / UPDATE / MIGRATE entries with tx hash, block number, capacity, and decoded data +- `migrateDidCkb` + `buildMigrationWitness` for importing a `did:plc` into `did:ckb` (WIP-02 §3.1.1) +- `@ckb-ccc/did-ckb/plc` subpath with `fetchPlcLog`, `parseDidKey`, `signRotationHash`, `verifyPrivateKeyMatch` so the curve code only ships to consumers that need it From 2a3ae10767a09f0dd5759fbcf8dc1508fbcbbc52 Mon Sep 17 00:00:00 2001 From: truthixify Date: Sun, 14 Jun 2026 00:18:36 +0100 Subject: [PATCH 9/9] test(did-ckb): mocked-client tests for resolver and history walk resolver.test covers findDidCkbCell hit/miss, resolveDidCkb rejecting non-did:ckb strings, and listDidCkbsByLock decoding the cells findCells yields (skipping any without a type script). history.test simulates a three-step cell chain (genesis + two transfers) and asserts the walk returns UPDATE, UPDATE, CREATE newest-first. A second case flips the genesis to MIGRATE when localId is set. --- packages/did-ckb/src/history.test.ts | 197 ++++++++++++++++++++++++++ packages/did-ckb/src/resolver.test.ts | 138 ++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 packages/did-ckb/src/history.test.ts create mode 100644 packages/did-ckb/src/resolver.test.ts diff --git a/packages/did-ckb/src/history.test.ts b/packages/did-ckb/src/history.test.ts new file mode 100644 index 000000000..499fa5ad6 --- /dev/null +++ b/packages/did-ckb/src/history.test.ts @@ -0,0 +1,197 @@ +import { ccc } from "@ckb-ccc/core"; +import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; +import { DidCkbData } from "./codec.js"; +import { getDidCkbHistory } from "./history.js"; + +describe("getDidCkbHistory", () => { + let client: ccc.Client; + + const codeHash = + "0x510150477b10d6ab551a509b71265f3164e9fd4137fcb5a4322f49f03092c7c5"; + const id = ("0x" + "ab".repeat(20)) as ccc.Hex; + + const fundingLock = ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0xdeadbeef", + }); + const didTypeScript = ccc.Script.from({ + codeHash, + hashType: "type", + args: id, + }); + + // Build a cell + the tx that produced it. Each transferDidCkb consumes the + // prior DID cell as an input and emits a new one with the same Type ID. + function didCell( + txHash: ccc.Hex, + document: object, + localId?: string, + ): ccc.Cell { + const data = DidCkbData.fromV1({ document, localId }); + return ccc.Cell.from({ + outPoint: { txHash, index: 0 }, + cellOutput: { + capacity: ccc.fixedPointFrom(300), + lock: fundingLock, + type: didTypeScript, + }, + outputData: ccc.hexFrom(data.toBytes()), + }); + } + + const txGenesis = ("0x" + "11".repeat(32)) as ccc.Hex; + const txUpdate1 = ("0x" + "22".repeat(32)) as ccc.Hex; + const txUpdate2 = ("0x" + "33".repeat(32)) as ccc.Hex; + const txFunding = ("0x" + "ff".repeat(32)) as ccc.Hex; + + const genesisCell = didCell(txGenesis, { v: 1 }); + const update1Cell = didCell(txUpdate1, { v: 2 }); + const update2Cell = didCell(txUpdate2, { v: 3 }); + + beforeEach(() => { + client = { + getKnownScript: vi.fn(), + findSingletonCellByType: vi.fn(), + getTransaction: vi.fn(), + } as unknown as ccc.Client; + + (client.getKnownScript as Mock).mockResolvedValue({ + codeHash, + hashType: "type", + cellDeps: [], + }); + }); + + function txResponse( + tx: ccc.TransactionLike, + blockNumber?: ccc.NumLike, + ): { transaction: ccc.Transaction; blockNumber?: ccc.Num } { + return { + transaction: ccc.Transaction.from(tx), + blockNumber: + blockNumber !== undefined ? ccc.numFrom(blockNumber) : undefined, + }; + } + + it("returns CREATE, UPDATE entries newest-first for a normal mint + two transfers", async () => { + // Funding tx that produced the input used to create the genesis. Not a + // DID cell, so the walk should stop at the genesis tx. + const fundingPrevOut = ccc.CellOutput.from({ + capacity: ccc.fixedPointFrom(1000), + lock: fundingLock, + }); + + (client.getTransaction as Mock).mockImplementation( + async (hash: ccc.HexLike) => { + const h = ccc.hexFrom(hash); + if (h === txUpdate2) { + return txResponse( + { + inputs: [ + { previousOutput: { txHash: txUpdate1, index: 0 }, since: 0 }, + ], + outputs: [update2Cell.cellOutput], + outputsData: [update2Cell.outputData], + }, + 300, + ); + } + if (h === txUpdate1) { + return txResponse( + { + inputs: [ + { previousOutput: { txHash: txGenesis, index: 0 }, since: 0 }, + ], + outputs: [update1Cell.cellOutput], + outputsData: [update1Cell.outputData], + }, + 200, + ); + } + if (h === txGenesis) { + return txResponse( + { + inputs: [ + { previousOutput: { txHash: txFunding, index: 0 }, since: 0 }, + ], + outputs: [genesisCell.cellOutput], + outputsData: [genesisCell.outputData], + }, + 100, + ); + } + if (h === txFunding) { + return txResponse({ + inputs: [], + outputs: [fundingPrevOut], + outputsData: ["0x"], + }); + } + return undefined; + }, + ); + + const history = await getDidCkbHistory({ + client, + id, + liveCell: update2Cell, + }); + + expect(history.map((h) => h.action)).toEqual([ + "UPDATE", + "UPDATE", + "CREATE", + ]); + expect(history[0].txHash).toBe(txUpdate2); + expect(history[0].blockNumber).toBe(300n); + expect(history[2].txHash).toBe(txGenesis); + expect(history[2].data.value.document).toEqual({ v: 1 }); + }); + + it("flags the genesis as MIGRATE when localId is set", async () => { + const migrated = didCell(txGenesis, { v: 1 }, "did:plc:abc"); + + (client.getTransaction as Mock).mockImplementation( + async (hash: ccc.HexLike) => { + const h = ccc.hexFrom(hash); + if (h === txGenesis) { + return txResponse( + { + inputs: [ + { previousOutput: { txHash: txFunding, index: 0 }, since: 0 }, + ], + outputs: [migrated.cellOutput], + outputsData: [migrated.outputData], + }, + 50, + ); + } + if (h === txFunding) { + return txResponse({ + inputs: [], + outputs: [ + ccc.CellOutput.from({ + capacity: ccc.fixedPointFrom(1000), + lock: fundingLock, + }), + ], + outputsData: ["0x"], + }); + } + return undefined; + }, + ); + + const history = await getDidCkbHistory({ client, id, liveCell: migrated }); + expect(history.length).toBe(1); + expect(history[0].action).toBe("MIGRATE"); + expect(history[0].data.value.localId).toBe("did:plc:abc"); + }); + + it("returns an empty array when no live cell exists", async () => { + (client.findSingletonCellByType as Mock).mockResolvedValue(undefined); + const history = await getDidCkbHistory({ client, id }); + expect(history).toEqual([]); + }); +}); diff --git a/packages/did-ckb/src/resolver.test.ts b/packages/did-ckb/src/resolver.test.ts new file mode 100644 index 000000000..0dff1bef5 --- /dev/null +++ b/packages/did-ckb/src/resolver.test.ts @@ -0,0 +1,138 @@ +import { ccc } from "@ckb-ccc/core"; +import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; +import { DidCkbData } from "./codec.js"; +import { argsToDid } from "./identifier.js"; +import { + findDidCkbCell, + listDidCkbsByLock, + resolveDidCkb, +} from "./resolver.js"; + +describe("resolver", () => { + let client: ccc.Client; + + const codeHash = + "0x510150477b10d6ab551a509b71265f3164e9fd4137fcb5a4322f49f03092c7c5"; + const id = ("0x" + "ab".repeat(20)) as ccc.Hex; + const did = argsToDid(id); + + const sampleData = DidCkbData.fromV1({ + document: { hello: "world" }, + localId: undefined, + }); + + function fakeCell(args: ccc.Hex = id, data = sampleData): ccc.Cell { + return ccc.Cell.from({ + outPoint: { + txHash: + "0x1111111111111111111111111111111111111111111111111111111111111111", + index: 0, + }, + cellOutput: { + capacity: ccc.fixedPointFrom(300), + lock: ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0xdeadbeef", + }), + type: ccc.Script.from({ + codeHash, + hashType: "type", + args, + }), + }, + outputData: ccc.hexFrom(data.toBytes()), + }); + } + + beforeEach(() => { + client = { + getKnownScript: vi.fn(), + findSingletonCellByType: vi.fn(), + findCells: vi.fn(), + } as unknown as ccc.Client; + + (client.getKnownScript as Mock).mockResolvedValue({ + codeHash, + hashType: "type", + cellDeps: [], + }); + }); + + it("findDidCkbCell returns a decoded record when a live cell exists", async () => { + (client.findSingletonCellByType as Mock).mockResolvedValue(fakeCell()); + + const record = await findDidCkbCell({ client, id }); + expect(record?.did).toBe(did); + expect(record?.id).toBe(id); + expect(record?.data.value.document).toEqual({ hello: "world" }); + }); + + it("findDidCkbCell returns undefined when no live cell exists", async () => { + (client.findSingletonCellByType as Mock).mockResolvedValue(undefined); + expect(await findDidCkbCell({ client, id })).toBeUndefined(); + }); + + it("resolveDidCkb rejects non-did:ckb strings", async () => { + await expect( + resolveDidCkb({ client, did: "did:plc:abc" }), + ).rejects.toThrow(); + }); + + it("resolveDidCkb resolves the same record as findDidCkbCell", async () => { + (client.findSingletonCellByType as Mock).mockResolvedValue(fakeCell()); + const record = await resolveDidCkb({ client, did }); + expect(record?.did).toBe(did); + }); + + it("listDidCkbsByLock decodes every cell yielded by findCells", async () => { + const other = ("0x" + "cd".repeat(20)) as ccc.Hex; + (client.findCells as Mock).mockImplementation(async function* () { + yield fakeCell(id); + yield fakeCell(other); + }); + + const lock = ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0xdeadbeef", + }); + + const records = await listDidCkbsByLock({ client, lock }); + expect(records.map((r) => r.id)).toEqual([id, other]); + expect(records.map((r) => r.did)).toEqual([did, argsToDid(other)]); + }); + + it("listDidCkbsByLock skips cells without a type script", async () => { + const cellWithoutType = ccc.Cell.from({ + outPoint: { + txHash: + "0x2222222222222222222222222222222222222222222222222222222222222222", + index: 0, + }, + cellOutput: { + capacity: ccc.fixedPointFrom(100), + lock: ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0x", + }), + }, + outputData: "0x", + }); + (client.findCells as Mock).mockImplementation(async function* () { + yield cellWithoutType; + yield fakeCell(id); + }); + + const lock = ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0xdeadbeef", + }); + + const records = await listDidCkbsByLock({ client, lock }); + expect(records.length).toBe(1); + expect(records[0].id).toBe(id); + }); +});