Skip to content
Open
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
13 changes: 13 additions & 0 deletions .changeset/did-ckb-advanced.md
Original file line number Diff line number Diff line change
@@ -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
7 changes: 6 additions & 1 deletion packages/did-ckb/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions packages/did-ckb/src/barrel.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
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";
197 changes: 197 additions & 0 deletions packages/did-ckb/src/history.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
146 changes: 146 additions & 0 deletions packages/did-ckb/src/history.ts
Original file line number Diff line number Diff line change
@@ -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<HistoryEntry[]> {
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;
Comment on lines +50 to +52

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When props.liveCell is provided by the caller, there is no validation to ensure that its type script matches the expected codeHash and normalizedId. Adding a defensive check prevents potential walk failures or incorrect history tracking if an invalid cell is passed.

  if (props.liveCell) {
    const type = props.liveCell.cellOutput.type;
    if (
      !type ||
      type.codeHash.toLowerCase() !== codeHash ||
      ccc.hexFrom(type.args).toLowerCase() !== normalizedId
    ) {
      throw new Error("Provided liveCell does not match the expected DID script or ID");
    }
  }

  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<ccc.Cell | undefined> {
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;
}
Comment on lines +96 to +127

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In findPriorDidCell, executing sequential await client.getTransaction(prevHash) calls inside a for loop introduces a significant performance bottleneck (N+1 query pattern). Fetching the transactions in parallel using Promise.all and de-duplicating the transaction hashes beforehand will greatly improve performance. Additionally, following our guidelines, the function should accept more generic types like ccc.HexLike for codeHash and id and perform the necessary type conversions internally, rather than requiring the caller to pass pre-formatted strings.

async function findPriorDidCell(
  client: ccc.Client,
  tx: ccc.Transaction,
  codeHash: ccc.HexLike,
  id: ccc.HexLike,
): Promise<ccc.Cell | undefined> {
  const normalizedCodeHash = ccc.hexFrom(codeHash).toLowerCase();
  const normalizedId = ccc.hexFrom(id).toLowerCase();

  const uniqueTxHashes = Array.from(
    new Set(tx.inputs.map((input) => input.previousOutput.txHash)),
  );
  const txs = await Promise.all(
    uniqueTxHashes.map((hash) => client.getTransaction(hash)),
  );
  const txMap = new Map(
    uniqueTxHashes.map((hash, i) => [hash, txs[i]]),
  );

  for (const input of tx.inputs) {
    const prevHash = input.previousOutput.txHash;
    const prevIdx = input.previousOutput.index;
    const prevTx = txMap.get(prevHash);
    if (!prevTx) {
      continue;
    }
    const prevOutput = prevTx.transaction.outputs[Number(prevIdx)];
    if (!prevOutput?.type) {
      continue;
    }
    if (ccc.hexFrom(prevOutput.type.codeHash).toLowerCase() !== normalizedCodeHash) {
      continue;
    }
    if (ccc.hexFrom(prevOutput.type.args).toLowerCase() !== normalizedId) {
      continue;
    }
    const data = prevTx.transaction.outputsData[Number(prevIdx)] ?? "0x";
    return ccc.Cell.from({
      outPoint: { txHash: prevHash, index: prevIdx },
      cellOutput: prevOutput,
      outputData: data,
    });
  }
  return undefined;
}
References
  1. Functions should accept more generic types like HexLike and perform necessary type conversions internally, rather than requiring the caller to do so.


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;
}
}
Loading
Loading