-
Notifications
You must be signed in to change notification settings - Fork 36
feat(did-ckb): identifier helpers, resolver, history walk, did:plc migration #376
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: releases/next
Are you sure you want to change the base?
Changes from all commits
cf000f3
833fcd8
3d8f587
b09f741
a45a5f3
c54c196
fc292d3
ce2b005
2a3ae10
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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"; |
| 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([]); | ||
| }); | ||
| }); |
| 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; | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In 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
|
||
|
|
||
| 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; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
props.liveCellis provided by the caller, there is no validation to ensure that its type script matches the expectedcodeHashandnormalizedId. Adding a defensive check prevents potential walk failures or incorrect history tracking if an invalid cell is passed.