From 3da97770b015e043fa2e5985c9fde711ace557d9 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 10 Apr 2026 15:40:54 +0800 Subject: [PATCH] feat: replace ckb-transaction-dumper with ccc-based implementation (#420) * feat: replace ckb-transaction-dumper with ccc-based implementation - Rewrite src/tools/ckb-tx-dumper.ts to use ccc Client and molecule codecs - Implement dep_group unpacking using ccc.mol - Remove ckb-transaction-dumper from dependencies - Eliminates external npm dependency for transaction dumping * chore: add changeset for ckb-transaction-dumper replacement * fix: address PR review comments for ckb-tx-dumper - Remove .sisyphus/boulder.json and add to .gitignore - Fix type field serialization: emit null instead of undefined - Fix dep_type format: convert camelCase to snake_case (depGroup -> dep_group) - Fix depGroup error handling: throw error when refCell not found - Fix async race condition: make buildTxFileOptionBy and callers async * fix: fix CCC-based transaction dumper for debug command - Use cccA.JsonRpcTransformers.transactionTo() to handle snake_case input format - Convert all numeric values to hex format (since, index, version, capacity) - Add missing dep_group cell to mock_info.cell_deps before expansion - Add fs.mkdirSync to ensure output directory exists Fixes issues where debug command failed with 'undefined is not iterable', 'unprovided cell dep', or 'since is not a legal u64' errors. * fix: address Copilot PR review comments for ccc-based tx dumper - Select ClientPublicTestnet/Mainnet based on RPC URL pattern - Use proper error type checking with instanceof Error --- .changeset/wise-candies-stop.md | 9 ++ .gitignore | 3 + .sisyphus/plans/ckb-tx-dumper.md | 175 +++++++++++++++++++++ package.json | 1 - pnpm-lock.yaml | 17 +- src/cli.ts | 4 +- src/cmd/debug.ts | 12 +- src/tools/ckb-tx-dumper.ts | 260 ++++++++++++++++++++++++++++++- 8 files changed, 456 insertions(+), 25 deletions(-) create mode 100644 .changeset/wise-candies-stop.md create mode 100644 .sisyphus/plans/ckb-tx-dumper.md diff --git a/.changeset/wise-candies-stop.md b/.changeset/wise-candies-stop.md new file mode 100644 index 0000000..9fec1cb --- /dev/null +++ b/.changeset/wise-candies-stop.md @@ -0,0 +1,9 @@ +--- +'@offckb/cli': minor +--- + +Replace ckb-transaction-dumper with ccc-based implementation + +- Rewrite transaction dumper to use ccc Client and molecule codecs +- Implement dep_group unpacking using ccc.mol +- Remove ckb-transaction-dumper npm dependency diff --git a/.gitignore b/.gitignore index 10e5f20..4a2881d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ package-lock.json # Test coverage coverage/ *.lcov + +# Sisyphus plans and sessions +.sisyphus/ diff --git a/.sisyphus/plans/ckb-tx-dumper.md b/.sisyphus/plans/ckb-tx-dumper.md new file mode 100644 index 0000000..ec6ac60 --- /dev/null +++ b/.sisyphus/plans/ckb-tx-dumper.md @@ -0,0 +1,175 @@ +# Replace ckb-transaction-dumper with ccc-based implementation + +## TL;DR + +> **Quick Summary**: Replace `ckb-transaction-dumper` npm package with a pure ccc-based implementation. +> +> **Deliverables**: +> +> - New `src/tools/ckb-tx-dumper.ts` (replaces old implementation) +> - Removed `ckb-transaction-dumper` from package.json +> +> **Estimated Effort**: Medium (2-3 hours) +> **Parallel Execution**: NO - sequential + +--- + +## Context + +### Request + +Replace `ckb-transaction-dumper` with ccc-based implementation (no external dependencies, use ccc throughout). + +### Current Implementation + +- `src/tools/ckb-tx-dumper.ts` spawns `ckb-transaction-dumper` binary +- Depends on npm package `ckb-transaction-dumper@0.4.2` + +### What TransactionDumper Does + +1. Load transaction (from file or fetch by hash) +2. Resolve cell deps (handle dep_group type) +3. Resolve inputs +4. Output mock transaction JSON for ckb-debugger + +### ccc Molecule Support + +ccc provides full molecule codec: + +- `ccc.molecule.struct()` - for OutPoint { tx_hash, index } +- `ccc.molecule.vector()` - for OutPointVec +- `ccc.Byte32`, `ccc.Uint32LE` - predefined codecs + +No manual bytes parsing needed! + +--- + +## Work Objectives + +### Core Objective + +Replace `ckb-transaction-dumper` with pure ccc implementation. + +### Must Have + +- Keep `DumpOption` interface +- Keep `dumpTransaction()` signature +- Same JSON output format + +### Must NOT Have + +- Breaking API changes +- New dependencies + +--- + +## TODOs + +- [ ] 1. Implement ccc-based transaction dumper + + **What to do**: + + - Rewrite `src/tools/ckb-tx-dumper.ts` + - Use ccc Client for RPC calls + - Use ccc molecule codecs for dep_group unpacking + + **Key implementation**: + + ```typescript + import { ccc } from '@ckb-ccc/core'; + + // OutPoint codec for dep_group unpacking + const OutPointCodec = ccc.molecule.struct({ + txHash: ccc.Byte32, + index: ccc.Uint32LE, + }); + + const OutPointVecCodec = ccc.molecule.vector(OutPointCodec); + + // Unpack dep_group data + function unpackDepGroup(data: string): ccc.OutPoint[] { + return OutPointVecCodec.decode(data).map((o) => + ccc.OutPoint.from({ txHash: o.txHash, index: '0x' + o.index.toString(16) }), + ); + } + ``` + + **Acceptance Criteria**: + + - [ ] Uses ccc Client for RPC + - [ ] Uses ccc molecule for dep_group + - [ ] Same output format + + **QA Scenarios**: + + ``` + Scenario: Compiles successfully + Tool: Bash + Steps: npm run typecheck + Expected: No errors + ``` + + **Commit**: `feat: implement transaction dumper with ccc` + +--- + +- [ ] 2. Remove ckb-transaction-dumper dependency + + **What to do**: + + - Remove from `package.json` + - Run `pnpm install` + + **Commit**: `chore: remove ckb-transaction-dumper` + +--- + +## Verification + +```bash +npm run typecheck +npm run lint +grep -c "ckb-transaction-dumper" package.json || echo "Clean" +``` + +## Key Implementation Notes + +### Dep Group Unpacking with ccc + +```typescript +const OutPointCodec = ccc.molecule.struct({ + txHash: ccc.Byte32, + index: ccc.Uint32LE, +}); +const OutPointVecCodec = ccc.molecule.vector(OutPointCodec); + +// Usage +const outpoints = OutPointVecCodec.decode(cellData); +``` + +### Mock Transaction Structure + +```typescript +interface MockTransaction { + mock_info: { + inputs: MockInput[]; + cell_deps: MockCellDep[]; + header_deps: any[]; + }; + tx: Transaction; +} +``` + +### Algorithm + +1. Load tx from file +2. For each cell_dep: + - Fetch cell + - If dep_type === 'dep_group': + - Decode cell.data as OutPointVec + - Fetch each referenced cell + - Add to mock_info.cell_deps +3. For each input: + - Fetch referenced cell + - Add to mock_info.inputs +4. Write JSON output diff --git a/package.json b/package.json index 6dd29ce..8bef32e 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,6 @@ "blessed": "0.1.81", "chalk": "4.1.2", "child_process": "^1.0.2", - "ckb-transaction-dumper": "^0.4.2", "commander": "^12.0.0", "http-proxy": "^1.18.1", "https-proxy-agent": "^7.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d68b9c..6323bd8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,9 +32,6 @@ importers: child_process: specifier: ^1.0.2 version: 1.0.2 - ckb-transaction-dumper: - specifier: ^0.4.2 - version: 0.4.2 commander: specifier: ^12.0.0 version: 12.1.0 @@ -953,41 +950,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -1344,10 +1349,6 @@ packages: cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} - ckb-transaction-dumper@0.4.2: - resolution: {integrity: sha512-0frB1FYY3dlKLlef6ps8dfuwTXitdm4myYTc/+hAP3RCo/OrC1/MYXEedUCLXgqcXBnlOQ8VbK5PseNZovWPHQ==} - hasBin: true - cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -4753,8 +4754,6 @@ snapshots: cjs-module-lexer@2.2.0: {} - ckb-transaction-dumper@0.4.2: {} - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 diff --git a/src/cli.ts b/src/cli.ts index dc82791..c1ffa21 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -81,9 +81,9 @@ program const txHash = option.txHash; if (option.singleScript) { const { cellType, cellIndex, scriptType } = parseSingleScriptOption(option.singleScript); - return debugSingleScript(txHash, cellIndex, cellType, scriptType, option.network, option.bin); + return await debugSingleScript(txHash, cellIndex, cellType, scriptType, option.network, option.bin); } - return debugTransaction(txHash, option.network); + return await debugTransaction(txHash, option.network); }); program diff --git a/src/cmd/debug.ts b/src/cmd/debug.ts index 5acbcdc..5564f6c 100644 --- a/src/cmd/debug.ts +++ b/src/cmd/debug.ts @@ -8,8 +8,8 @@ import { Network } from '../type/base'; import { encodeBinPathForTerminal } from '../util/encoding'; import { logger } from '../util/logger'; -export function debugTransaction(txHash: string, network: Network) { - const txFile = buildTxFileOptionBy(txHash, network); +export async function debugTransaction(txHash: string, network: Network) { + const txFile = await buildTxFileOptionBy(txHash, network); const opts = buildTransactionDebugOptions(txHash, network); for (const opt of opts) { logger.section(opt.name, [], 'info'); @@ -48,7 +48,7 @@ export function buildTransactionDebugOptions(txHash: string, network: Network) { return result; } -export function debugSingleScript( +export async function debugSingleScript( txHash: string, cellIndex: number, cellType: 'input' | 'output', @@ -56,7 +56,7 @@ export function debugSingleScript( network: Network, bin?: string, ) { - const txFile = buildTxFileOptionBy(txHash, network); + const txFile = await buildTxFileOptionBy(txHash, network); let opt = `--cell-index ${cellIndex} --cell-type ${cellType} --script-group-type ${scriptType}`; if (bin) { opt = opt + ` --bin ${bin}`; @@ -81,7 +81,7 @@ export function parseSingleScriptOption(value: string) { }; } -export function buildTxFileOptionBy(txHash: string, network: Network) { +export async function buildTxFileOptionBy(txHash: string, network: Network) { const settings = readSettings(); const outputFilePath = buildDebugFullTransactionFilePath(network, txHash); if (!fs.existsSync(outputFilePath)) { @@ -90,7 +90,7 @@ export function buildTxFileOptionBy(txHash: string, network: Network) { if (!fs.existsSync(outputFilePath)) { fs.mkdirSync(path.dirname(outputFilePath), { recursive: true }); } - dumpTransaction({ rpc, txJsonFilePath, outputFilePath }); + await dumpTransaction({ rpc, txJsonFilePath, outputFilePath }); } const opt = `--tx-file ${encodeBinPathForTerminal(outputFilePath)}`; return opt; diff --git a/src/tools/ckb-tx-dumper.ts b/src/tools/ckb-tx-dumper.ts index 212aa33..c97ffaf 100644 --- a/src/tools/ckb-tx-dumper.ts +++ b/src/tools/ckb-tx-dumper.ts @@ -1,6 +1,7 @@ +import fs from 'fs'; import path from 'path'; -import { execSync } from 'child_process'; -import { packageRootPath } from '../cfg/setting'; +import { ccc } from '@ckb-ccc/core'; +import { cccA } from '@ckb-ccc/core/advanced'; import { logger } from '../util/logger'; export interface DumpOption { @@ -9,15 +10,260 @@ export interface DumpOption { outputFilePath: string; } -export function dumpTransaction({ rpc, txJsonFilePath, outputFilePath }: DumpOption) { - const ckbTransactionDumperPath = path.resolve(packageRootPath, 'node_modules/.bin/ckb-transaction-dumper'); +const OutPointCodec = ccc.mol.struct({ + txHash: ccc.mol.Byte32, + index: ccc.mol.Uint32LE, +}); - const command = `${ckbTransactionDumperPath} --rpc ${rpc} --tx "${txJsonFilePath}" --output "${outputFilePath}"`; +const OutPointVecCodec = ccc.mol.vector(OutPointCodec); +interface MockCellDep { + cell_dep: { + out_point: { + tx_hash: string; + index: string; + }; + dep_type: string; + }; + output: MockOutput; + data: string; +} + +interface MockInput { + input: { + previous_output: { + tx_hash: string; + index: string; + }; + since: string; + }; + output: MockOutput; + data: string; +} + +interface MockOutput { + capacity: string; + lock: MockScript; + type: MockScript | null; +} + +interface MockScript { + code_hash: string; + hash_type: string; + args: string; +} + +interface MockTransaction { + mock_info: { + inputs: MockInput[]; + cell_deps: MockCellDep[]; + header_deps: string[]; + }; + tx: { + version: string; + cell_deps: { + out_point: { + tx_hash: string; + index: string; + }; + dep_type: string; + }[]; + header_deps: string[]; + inputs: { + previous_output: { + tx_hash: string; + index: string; + }; + since: string; + }[]; + outputs: MockOutput[]; + outputs_data: string[]; + witnesses: string[]; + }; +} + +function toMockScript(script: ccc.Script | undefined): MockScript | null { + if (!script) return null; + return { + code_hash: script.codeHash, + hash_type: script.hashType, + args: script.args, + }; +} + +function toDepType(depType: string): string { + // Convert camelCase to snake_case for CKB JSON format + if (depType === 'depGroup') return 'dep_group'; + return depType; +} + +async function resolveCellDeps(client: ccc.Client, cellDeps: ccc.CellDep[]): Promise { + const resolved: MockCellDep[] = []; + + for (const cellDep of cellDeps) { + const cell = await client.getCell(cellDep.outPoint); + if (!cell) { + throw new Error(`Cell not found: ${JSON.stringify(cellDep.outPoint)}`); + } + + if (cellDep.depType === 'depGroup') { + resolved.push({ + cell_dep: { + out_point: { + tx_hash: cellDep.outPoint.txHash, + index: '0x' + cellDep.outPoint.index.toString(16), + }, + dep_type: toDepType(cellDep.depType), + }, + output: { + capacity: '0x' + cell.cellOutput.capacity.toString(16), + lock: toMockScript(cell.cellOutput.lock)!, + type: toMockScript(cell.cellOutput.type), + }, + data: cell.outputData, + }); + const data = cell.outputData; + if (data && data !== '0x') { + const outpoints = OutPointVecCodec.decode(data); + for (const op of outpoints) { + const outPoint = ccc.OutPoint.from({ + txHash: op.txHash, + index: '0x' + op.index.toString(16), + }); + const refCell = await client.getCell(outPoint); + if (!refCell) { + logger.error( + `Failed to resolve cell for depGroup out_point: tx_hash=${outPoint.txHash}, index=${outPoint.index.toString()}`, + ); + throw new Error('Failed to resolve all cells referenced by depGroup.'); + } + resolved.push({ + cell_dep: { + out_point: { + tx_hash: outPoint.txHash, + index: '0x' + outPoint.index.toString(16), + }, + dep_type: 'code', + }, + output: { + capacity: '0x' + refCell.cellOutput.capacity.toString(16), + lock: toMockScript(refCell.cellOutput.lock)!, + type: toMockScript(refCell.cellOutput.type), + }, + data: refCell.outputData, + }); + } + } + } else { + resolved.push({ + cell_dep: { + out_point: { + tx_hash: cellDep.outPoint.txHash, + index: '0x' + cellDep.outPoint.index.toString(16), + }, + dep_type: toDepType(cellDep.depType), + }, + output: { + capacity: '0x' + cell.cellOutput.capacity.toString(16), + lock: toMockScript(cell.cellOutput.lock)!, + type: toMockScript(cell.cellOutput.type), + }, + data: cell.outputData, + }); + } + } + + return resolved; +} + +async function resolveInputs(client: ccc.Client, inputs: ccc.CellInput[]): Promise { + const resolved: MockInput[] = []; + + for (const input of inputs) { + const cell = await client.getCell(input.previousOutput); + if (!cell) { + throw new Error(`Input cell not found: ${JSON.stringify(input.previousOutput)}`); + } + + resolved.push({ + input: { + previous_output: { + tx_hash: input.previousOutput.txHash, + index: '0x' + input.previousOutput.index.toString(16), + }, + since: '0x' + input.since.toString(16), + }, + output: { + capacity: '0x' + cell.cellOutput.capacity.toString(16), + lock: toMockScript(cell.cellOutput.lock)!, + type: toMockScript(cell.cellOutput.type), + }, + data: cell.outputData, + }); + } + + return resolved; +} + +export async function dumpTransaction({ rpc, txJsonFilePath, outputFilePath }: DumpOption) { try { - execSync(command, { stdio: 'inherit' }); + const isTestnet = /testnet/i.test(rpc); + const client = isTestnet + ? new ccc.ClientPublicTestnet({ + url: rpc, + fallbacks: [], + }) + : new ccc.ClientPublicMainnet({ + url: rpc, + fallbacks: [], + }); + + const txJson = JSON.parse(fs.readFileSync(txJsonFilePath, 'utf-8')); + const tx = cccA.JsonRpcTransformers.transactionTo(txJson); + + const [cell_deps, inputs] = await Promise.all([ + resolveCellDeps(client, tx.cellDeps), + resolveInputs(client, tx.inputs), + ]); + + const mockTx: MockTransaction = { + mock_info: { + inputs, + cell_deps, + header_deps: tx.headerDeps.map((h) => h.toString()), + }, + tx: { + version: '0x' + tx.version.toString(16), + cell_deps: tx.cellDeps.map((dep) => ({ + out_point: { + tx_hash: dep.outPoint.txHash, + index: '0x' + dep.outPoint.index.toString(16), + }, + dep_type: toDepType(dep.depType), + })), + header_deps: tx.headerDeps.map((h) => h.toString()), + inputs: tx.inputs.map((input) => ({ + previous_output: { + tx_hash: input.previousOutput.txHash, + index: '0x' + input.previousOutput.index.toString(16), + }, + since: '0x' + input.since.toString(16), + })), + outputs: tx.outputs.map((output) => ({ + capacity: '0x' + output.capacity.toString(16), + lock: toMockScript(output.lock)!, + type: toMockScript(output.type), + })), + outputs_data: tx.outputsData, + witnesses: tx.witnesses.map((w) => w.toString()), + }, + }; + + fs.mkdirSync(path.dirname(outputFilePath), { recursive: true }); + fs.writeFileSync(outputFilePath, JSON.stringify(mockTx, null, 2)); logger.debug('Dump transaction successfully'); } catch (error: unknown) { - logger.error('Command failed:', (error as Error).message); + logger.error('Failed to dump transaction:', error instanceof Error ? error.message : String(error)); + throw error; } }