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
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";
235 changes: 235 additions & 0 deletions packages/did-ckb/src/history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
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";
import { argsToDid } from "./identifier.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(),
getCell: 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,
};
}

// Set up a getCell mock that resolves outPoints to the cells we expect the
// walk to visit. `input.getCell(client)` calls `client.getCell` internally,
// which we replace wholesale to bypass the cache.
function cellAt(outPoint: ccc.OutPointLike): ccc.Cell | undefined {
const op = ccc.OutPoint.from(outPoint);
const hash = op.txHash.toLowerCase();
const index = Number(op.index);
if (hash === txUpdate1.toLowerCase() && index === 0) return update1Cell;
if (hash === txGenesis.toLowerCase() && index === 0) return genesisCell;
if (hash === txFunding.toLowerCase()) {
// Non-DID input that funded the genesis; getCell still works, but the
// type script doesn't match, so the walk treats this tx as the genesis.
return ccc.Cell.from({
outPoint: { txHash: txFunding, index: 0 },
cellOutput: {
capacity: ccc.fixedPointFrom(1000),
lock: fundingLock,
},
outputData: "0x",
});
}
return undefined;
}

it("returns CREATE, UPDATE entries newest-first for a normal mint + two transfers", async () => {
(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,
);
}
return undefined;
},
);
(client.getCell as Mock).mockImplementation(async (op: ccc.OutPointLike) =>
cellAt(op),
);

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,
);
}
return undefined;
},
);
(client.getCell as Mock).mockImplementation(async (op: ccc.OutPointLike) =>
cellAt(op),
);

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([]);
});

it("accepts a did:ckb URI in place of the Type ID args", async () => {
const migrated = didCell(txGenesis, { v: 1 }, "did:plc:abc");
(client.getTransaction as Mock).mockImplementation(
async (hash: ccc.HexLike) => {
if (ccc.hexFrom(hash) === txGenesis) {
return txResponse(
{
inputs: [
{ previousOutput: { txHash: txFunding, index: 0 }, since: 0 },
],
outputs: [migrated.cellOutput],
outputsData: [migrated.outputData],
},
50,
);
}
return undefined;
},
);
(client.getCell as Mock).mockImplementation(async (op: ccc.OutPointLike) =>
cellAt(op),
);

const history = await getDidCkbHistory({
client,
id: argsToDid(id),
liveCell: migrated,
});
expect(history.length).toBe(1);
expect(history[0].action).toBe("MIGRATE");
});
});
Loading
Loading