diff --git a/.changeset/devnet-fork.md b/.changeset/devnet-fork.md new file mode 100644 index 0000000..9530c58 --- /dev/null +++ b/.changeset/devnet-fork.md @@ -0,0 +1,10 @@ +--- +'@offckb/cli': minor +--- + +Add `offckb devnet fork` to fork an existing mainnet/testnet data directory into the local devnet ([Devnet From Existing Data](https://docs.nervos.org/docs/node/devnet-from-existing-data) flow), plus fork-aware system scripts and local-first debugging. + +- `offckb devnet fork --from [--source mainnet|testnet] [--spec-file ] [--force]` copies the source chain data, imports the matching chain spec, patches it for local mining (Dummy PoW, `cellbase_maturity = 0`, correct `genesis_epoch_length` per chain), verifies the genesis hash, and records the fork state. The first `offckb node` run boots with `--skip-spec-check --overwrite-spec` automatically; `offckb clean` resets back to a pure devnet. +- Devnet system scripts now self-identify the chain via the genesis hash in `ckb list-hashes`: on a mainnet/testnet fork, genesis scripts come from the chain's own spec and post-genesis deployments (sudt/xudt/omnilock/spore/…) are filled from the well-known static records, so `system-scripts`, transfers, deploys and fee estimation keep working on a fork. +- The devnet ccc client follows the forked chain too: a mainnet fork uses the `ckb` address prefix. +- `offckb debug` falls back to fetching the transaction from the node when it is not in the local proxy cache, and the tx dumper now embeds full header objects in `mock_info.header_deps` (previously bare hashes, which broke debugging for transactions with header deps). diff --git a/README.md b/README.md index 82680ad..6b48506 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ CKB local development network for your first try. There are BREAKING CHANGES between v0.3.x and v0.4.x, make sure to read the [migration guide](/docs/migration.md) before upgrading. ----- +--- - [OffCKB](#offckb) - [Install](#install) @@ -92,7 +92,7 @@ _Use `offckb [command] -h` to learn more about a specific command._ ## Get started ### 1. Run a Local CKB Devnet {#running-ckb} - + Start a local blockchain with one command: ```sh @@ -151,7 +151,7 @@ offckb node --daemon --json Each log line is a single JSON object: ```json -{"level":"info","message":"Launching CKB devnet Node...","timestamp":"2026-07-07T07:10:00.000Z"} +{ "level": "info", "message": "Launching CKB devnet Node...", "timestamp": "2026-07-07T07:10:00.000Z" } ``` **RPC & Proxy RPC** @@ -163,11 +163,12 @@ When the Devnet starts: The proxy RPC server forwards all requests to the RPC server and record every requests while automatically dumping failed transactions for easier debugging. -You can also start a proxy RPC server for public networks: +You can also start a proxy RPC server for public networks: ```sh offckb node --network ``` + Using a proxy RPC server for Testnet/Mainnet is especially helpful for debugging transactions, since failed transactions are dumped automatically. **Watch Network with TUI** @@ -175,11 +176,13 @@ Using a proxy RPC server for Testnet/Mainnet is especially helpful for debugging Once you start the CKB Node, you can use `offckb status --network devnet/testnet/mainnet` to start a CKB-TUI interface to monitor the CKB network from your node. ### 2. Create a New Contract Project {#create-project} - + Generate a ready-to-use smart-contract project in JS/TS using templates: + ```sh offckb create -c ``` + - The `-c` option is optional, if not provided, the contract name defaults to `hello-world`. **Note for Windows Users:** @@ -210,10 +213,11 @@ To run mock tests in the generated project, you need to manually install `ckb-de After completing these steps, `npm run test` should pass without mock test failures. ### 3. Deploy Your Contract {#deploy-contract} - + ```sh offckb deploy --network --target --output ``` + - Deployment info is written to the `output-folder-path` you specify. **Upgradable Scripts with `--type-id`** @@ -224,14 +228,14 @@ offckb deploy --type-id --network ``` - **Important**: Upgrades are keyed by the contract‘s artifact name. - - If you plan to upgrade with `--type-id`, do not rename your contract artifact (e.g. keep `hello-world.bc`). - - Renaming it makes the offckb unable to find the previous Type ID info from the `output-folder-path` and will create a new Type ID. + - If you plan to upgrade with `--type-id`, do not rename your contract artifact (e.g. keep `hello-world.bc`). + - Renaming it makes the offckb unable to find the previous Type ID info from the `output-folder-path` and will create a new Type ID. ### 4. Debug Your Contract {#debug-contract} - -When you interact with the CKB Devnet through the Proxy RPC server (localhost:28114), any failed transactions are automatically dumped and recorded for debugging. - -**Debug a Transaction:** + +When you interact with the CKB Devnet through the Proxy RPC server (localhost:28114), any failed transactions are automatically dumped and recorded for debugging. + +**Debug a Transaction:** ```sh offckb debug --tx-hash --network @@ -283,9 +287,9 @@ offckb debug --tx-hash --single-script input[0].lock ``` All debug utilities are powered by [ckb-debugger](https://github.com/nervosnetwork/ckb-standalone-debugger/tree/develop/ckb-debugger). - + ### 5. Explore Built-in Scripts {#explore-scripts} - + Print all the predefined Scripts for the local blockchain: ```sh @@ -311,9 +315,9 @@ offckb system-scripts --export-style ccc ```sh offckb system-scripts --output ``` - + ### 6. Tweak Devnet Config {#tweak-devnet-config} - + By default, OffCKB use a fixed Devnet config. You can customize it, for example by modifying the default log level (`warn,ckb-script=debug`). 1. Open the interactive Devnet config editor: @@ -370,6 +374,26 @@ Pay attention to the `devnet.configPath` and `devnet.dataPath`. 2. After modifications, run `offckb clean -d` to remove the chain data if needed while keeping the updated config files. 3. Restart local blockchain by running `offckb node` +### 7. Fork Mainnet/Testnet Into Your Devnet {#fork-devnet} + +You can fork an existing Mainnet/Testnet data directory into your local devnet, so it keeps the real on-chain state (deployed contracts, cells) while mining locally with Dummy PoW. This implements the same flow as [Devnet From Existing Data](https://docs.nervos.org/docs/node/devnet-from-existing-data). + +```sh +offckb devnet fork --from /path/to/ckb-data +offckb node +``` + +- `--from` points at the directory the source node runs with (`-C`), which must contain `data/db`. Stop the source node first. +- The source chain is auto-detected from the source `ckb.toml`; pass `--source mainnet|testnet` when it cannot be detected, and `--spec-file ` to use a local chain spec (e.g. offline). +- The command copies the chain `data/` (your original data is never modified), imports the matching chain spec, patches it for local mining (Dummy PoW, `cellbase_maturity = 0`), and verifies the genesis hash. +- The first `offckb node` run automatically boots with `--skip-spec-check --overwrite-spec`; later runs are normal. +- Forking replaces the current devnet; use `--force` to replace an existing devnet/fork, or `offckb clean` to reset back to a pure devnet. + +On a forked devnet, `offckb system-scripts`, transfers, deploys and `offckb debug --tx-hash ` work against the real source-chain state, e.g. debugging a failed mainnet transaction fully locally. + +> [!CAUTION] +> CKB transactions carry no chain id, so a transaction built on a mainnet fork that spends copied mainnet cells is also valid on mainnet (CKB provides no replay protection). offckb's own flows only use dev keys and fork-mined cells, which cannot replay. Never sign transactions with real mainnet keys against a fork unless you intend to broadcast them yourself. + ## Config Setting ### List All Settings @@ -455,4 +479,3 @@ npm install -g @offckb/cli ## Contributing check [development doc](/docs/develop.md) - diff --git a/src/cli.ts b/src/cli.ts index f5f0b25..73a84ec 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,6 +12,7 @@ import { udtIssue, udtDestroy, UdtIssueOption, UdtDestroyOption } from './cmd/ud import { createScriptProject, CreateScriptProjectOptions } from './cmd/create'; import { Config, ConfigItem } from './cmd/config'; import { devnetConfig } from './cmd/devnet-config'; +import { devnetFork } from './cmd/devnet-fork'; import { debugSingleScript, debugTransaction, parseSingleScriptOption } from './cmd/debug'; import { printSystemScripts } from './cmd/system-scripts'; import { transferAll } from './cmd/transfer-all'; @@ -234,6 +235,15 @@ devnetCommand ) .action(devnetConfig); +devnetCommand + .command('fork') + .description('Fork an existing mainnet/testnet chain data directory into the local devnet') + .requiredOption('--from ', 'Path to the source CKB node directory (the one passed to ckb -C)') + .option('--source ', 'Source chain: mainnet or testnet (auto-detected from the source ckb.toml when omitted)') + .option('--spec-file ', 'Use a local chain spec file instead of downloading it') + .option('--force', 'Replace the existing devnet (or a previous fork)') + .action(devnetFork); + program.parse(process.argv); // If no command is specified, display help diff --git a/src/cmd/debug.ts b/src/cmd/debug.ts index 5564f6c..59422dc 100644 --- a/src/cmd/debug.ts +++ b/src/cmd/debug.ts @@ -6,6 +6,7 @@ import path from 'path'; import { cccA } from '@ckb-ccc/core/advanced'; import { Network } from '../type/base'; import { encodeBinPathForTerminal } from '../util/encoding'; +import { callJsonRpc } from '../util/json-rpc'; import { logger } from '../util/logger'; export async function debugTransaction(txHash: string, network: Network) { @@ -87,8 +88,8 @@ export async function buildTxFileOptionBy(txHash: string, network: Network) { if (!fs.existsSync(outputFilePath)) { const rpc = settings[network].rpcUrl; const txJsonFilePath = buildTransactionJsonFilePath(network, txHash); - if (!fs.existsSync(outputFilePath)) { - fs.mkdirSync(path.dirname(outputFilePath), { recursive: true }); + if (!fs.existsSync(txJsonFilePath)) { + await fetchTransactionIntoCache(rpc, txHash, txJsonFilePath); } await dumpTransaction({ rpc, txJsonFilePath, outputFilePath }); } @@ -96,6 +97,24 @@ export async function buildTxFileOptionBy(txHash: string, network: Network) { return opt; } +// Fallback for transactions that never went through the local RPC proxy +// (e.g. historical transactions on a forked devnet): pull the transaction +// from the node and cache it in the same JSON-RPC format the proxy stores. +async function fetchTransactionIntoCache(rpc: string, txHash: string, txJsonFilePath: string) { + logger.info(`Transaction ${txHash} not found in local cache, fetching from ${rpc} ..`); + const result = await callJsonRpc(rpc, 'get_transaction', [txHash]).catch((error: Error) => { + throw new Error(`Failed to fetch transaction ${txHash} from ${rpc}: ${error.message}`); + }); + if (!result?.transaction) { + throw new Error( + `Transaction ${txHash} not found on ${rpc}. ` + + `Check the hash and the --network option, or send the transaction through the offckb RPC proxy first.`, + ); + } + fs.mkdirSync(path.dirname(txJsonFilePath), { recursive: true }); + fs.writeFileSync(txJsonFilePath, JSON.stringify(result.transaction, null, 2)); +} + export function buildTransactionJsonFilePath(network: Network, txHash: string) { const settings = readSettings(); if (network === Network.devnet) { diff --git a/src/cmd/devnet-fork.ts b/src/cmd/devnet-fork.ts new file mode 100644 index 0000000..11fa7b2 --- /dev/null +++ b/src/cmd/devnet-fork.ts @@ -0,0 +1,16 @@ +import { forkDevnet, ForkOptions } from '../devnet/fork'; +import { logger } from '../util/logger'; + +export async function devnetFork(options: ForkOptions) { + if (options.source && options.source !== 'mainnet' && options.source !== 'testnet') { + logger.error(`Invalid --source value: ${options.source}. Expected mainnet or testnet.`); + process.exit(1); + } + + try { + await forkDevnet(options); + } catch (error) { + logger.error((error as Error).message); + process.exit(1); + } +} diff --git a/src/cmd/node.ts b/src/cmd/node.ts index 0b5faf2..914e48e 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -1,4 +1,4 @@ -import { exec, spawn } from 'child_process'; +import { exec, spawn, ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import { initChainIfNeeded } from '../node/init-chain'; @@ -6,6 +6,8 @@ import { installCKBBinary } from '../node/install'; import { getCKBBinaryPath, readSettings } from '../cfg/setting'; import { encodeBinPathForTerminal } from '../util/encoding'; import { createRPCProxy } from '../tools/rpc-proxy'; +import { markForkFirstRunComplete, readForkState } from '../devnet/fork'; +import { callJsonRpc } from '../util/json-rpc'; import { Network } from '../type/base'; import { logger } from '../util/logger'; @@ -66,7 +68,15 @@ export async function nodeDevnet({ version, binaryPath, daemon }: NodeProp) { await initChainIfNeeded(); const devnetConfigPath = encodeBinPathForTerminal(settings.devnet.configPath); - const ckbCmd = `${ckbBinPath} run -C ${devnetConfigPath}`; + // A forked devnet must boot once with --skip-spec-check --overwrite-spec so + // the imported (and patched) spec replaces the source chain's stored spec. + const forkState = readForkState(settings.devnet.configPath); + const firstRunFlags = forkState?.firstRunPending ? ' --skip-spec-check --overwrite-spec' : ''; + if (forkState?.firstRunPending) { + logger.info(`Forked devnet (${forkState.source}) detected, first run uses --skip-spec-check --overwrite-spec.`); + } + + const ckbCmd = `${ckbBinPath} run -C ${devnetConfigPath}${firstRunFlags}`; const minerCmd = `${ckbBinPath} miner -C ${devnetConfigPath}`; logger.info(`Launching CKB devnet Node...`); try { @@ -81,6 +91,17 @@ export async function nodeDevnet({ version, binaryPath, daemon }: NodeProp) { logger.error(['CKB error:', data.toString()]); }); + if (forkState?.firstRunPending) { + // Only clear the flag once the spawned node is actually up and reports + // the fork's genesis; if startup fails, the next run retries the flags. + void clearForkFirstRunWhenNodeUp( + ckbProcess, + settings.devnet.rpcUrl, + settings.devnet.configPath, + forkState.genesisHash, + ); + } + // Start the second command after 3 seconds setTimeout(async () => { try { @@ -115,6 +136,51 @@ function resolveDaemonPaths() { return { logDir, logFile, pidFile }; } +// Poll the devnet RPC until the spawned node answers with the fork's genesis +// hash, then mark the first run as done so subsequent `offckb node` runs boot +// normally. Two guards against clearing the flag on the wrong signal: +// - the poll aborts when the spawned ckb process exits (e.g. failed boot), +// - an answering node is only trusted when its genesis matches the fork +// state — an unrelated node occupying the port must not clear the flag. +async function clearForkFirstRunWhenNodeUp( + ckbProcess: ChildProcess, + rpcUrl: string, + configPath: string, + expectedGenesisHash: string, +) { + let processExited = false; + const markExited = () => { + processExited = true; + }; + ckbProcess.once('exit', markExited); + ckbProcess.once('error', markExited); + + const timeoutMs = 10 * 60 * 1000; // large forks take a while to boot + const start = Date.now(); + while (!processExited && Date.now() - start < timeoutMs) { + try { + const genesisHash = String(await callJsonRpc(rpcUrl, 'get_block_hash', ['0x0'], 5000)).toLowerCase(); + if (genesisHash !== expectedGenesisHash.toLowerCase()) { + logger.warn( + `A node is answering at ${rpcUrl} but reports a different genesis (${genesisHash}); ` + + 'leaving the first-run flags in place.', + ); + return; + } + markForkFirstRunComplete(configPath); + logger.success('Forked devnet is up; first-run spec flags cleared.'); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + if (processExited) { + logger.warn('The CKB process exited before the forked devnet came up; first-run flags will be retried next time.'); + } else { + logger.warn('Timed out waiting for the forked devnet to start; first-run flags will be retried next time.'); + } +} + function readPidFile(pidFile: string): PidMetadata | null { let raw: string; try { diff --git a/src/cmd/system-scripts.ts b/src/cmd/system-scripts.ts index ebb3588..3d009f0 100644 --- a/src/cmd/system-scripts.ts +++ b/src/cmd/system-scripts.ts @@ -1,9 +1,8 @@ -import { SystemCell } from '../util/list-hashes'; -import { ScriptInfo, SystemScriptsRecord } from '../scripts/type'; +import { SystemScriptsRecord } from '../scripts/type'; import { Network, NetworkOption } from '../type/base'; import { MAINNET_SYSTEM_SCRIPTS, TESTNET_SYSTEM_SCRIPTS } from '../scripts/public'; import { logger } from '../util/logger'; -import { getDevnetSystemScriptsFromListHashes, toCCCKnownScripts } from '../scripts/private'; +import { resolveDevnetSystemScripts, toCCCKnownScripts } from '../scripts/private'; export enum PrintStyle { system = 'system', @@ -15,30 +14,41 @@ export interface PrintProps extends NetworkOption { } export async function printSystemScripts({ style = PrintStyle.system, network = Network.devnet }: PrintProps) { - const systemScripts = - network === Network.mainnet - ? MAINNET_SYSTEM_SCRIPTS - : network === Network.testnet - ? TESTNET_SYSTEM_SCRIPTS - : getDevnetSystemScriptsFromListHashes(); + let systemScripts: SystemScriptsRecord | null; + // Display label and address prefix follow the chain the devnet actually + // runs: a fork carries the source chain's genesis, scripts and prefix. + let label = network.toUpperCase(); + let addressPrefix: 'ckb' | 'ckt' = network === Network.mainnet ? 'ckb' : 'ckt'; + if (network === Network.mainnet) { + systemScripts = MAINNET_SYSTEM_SCRIPTS; + } else if (network === Network.testnet) { + systemScripts = TESTNET_SYSTEM_SCRIPTS; + } else { + const resolved = resolveDevnetSystemScripts(); + systemScripts = resolved?.scripts ?? null; + if (resolved?.forkedFrom) { + label = `DEVNET (fork of ${resolved.forkedFrom.toUpperCase()})`; + addressPrefix = resolved.forkedFrom === 'mainnet' ? 'ckb' : 'ckt'; + } + } if (!systemScripts) return logger.info(`SystemScripts is null, ${network}`); if (style === PrintStyle.system) { - return printInSystemStyle(systemScripts, network); + return printInSystemStyle(systemScripts, label); } if (style === PrintStyle.lumos) { - return printInLumosConfigStyle(systemScripts, network); + return printInLumosConfigStyle(systemScripts, label, addressPrefix); } if (style === PrintStyle.ccc) { - return printInCCCStyle(systemScripts, network); + return printInCCCStyle(systemScripts, label); } } -export function printInSystemStyle(systemScripts: SystemScriptsRecord, network: Network) { - logger.info(`*** CKB ${network.toUpperCase()} System Scripts ***\n`); +export function printInSystemStyle(systemScripts: SystemScriptsRecord, label: string) { + logger.info(`*** CKB ${label} System Scripts ***\n`); for (const [name, script] of Object.entries(systemScripts)) { logger.info(`- name: ${name}`); if (script == null) { @@ -52,87 +62,18 @@ export function printInSystemStyle(systemScripts: SystemScriptsRecord, network: } } -export function printInLumosConfigStyle(scripts: SystemScriptsRecord, network: Network) { - const config = toLumosConfig(scripts, network === Network.mainnet ? 'ckb' : 'ckt'); - logger.info(`*** CKB ${network.toUpperCase()} System Scripts As LumosConfig ***\n`); +export function printInLumosConfigStyle(scripts: SystemScriptsRecord, label: string, addressPrefix: 'ckb' | 'ckt') { + const config = toLumosConfig(scripts, addressPrefix); + logger.info(`*** CKB ${label} System Scripts As LumosConfig ***\n`); logger.info(JSON.stringify(config, null, 2)); } -export function printInCCCStyle(scripts: SystemScriptsRecord, network: Network) { +export function printInCCCStyle(scripts: SystemScriptsRecord, label: string) { const knownsScripts = toCCCKnownScripts(scripts); - logger.info(`*** CKB ${network.toUpperCase()} System Scripts As CCC KnownScripts ***\n`); + logger.info(`*** CKB ${label} System Scripts As CCC KnownScripts ***\n`); logger.info(JSON.stringify(knownsScripts, null, 2)); } -export function systemCellToScriptInfo({ - cell, - depType, - depGroup, - extraCellDeps, -}: { - cell: SystemCell; - depType: 'code' | 'depGroup'; - depGroup?: { - txHash: string; - index: number; - }; - extraCellDeps?: ScriptInfo['cellDeps']; -}): ScriptInfo { - // todo: we left the type in cellDepsInfo since it requires async fetching and - // chain running to get the full type script of the type-id deps. - // Also, in devnet there is no real need to auto upgrade the system scripts with type-id - if (depType === 'code') { - let cellDeps: ScriptInfo['cellDeps'] = [ - { - cellDep: { - outPoint: { - txHash: cell.tx_hash as `0x${string}`, - index: cell.index, - }, - depType, - }, - }, - ]; - if (extraCellDeps && extraCellDeps.length > 0) { - cellDeps = [...extraCellDeps, ...cellDeps]; - } - return { - codeHash: (cell.type_hash || cell.data_hash) as `0x${string}`, - hashType: cell.type_hash ? 'type' : 'data2', - cellDeps, - }; - } - - if (depType === 'depGroup') { - if (!depGroup) { - throw new Error('require depGroup info since the dep type is depGroup'); - } - - let cellDeps: ScriptInfo['cellDeps'] = [ - { - cellDep: { - outPoint: { - txHash: depGroup!.txHash as `0x${string}`, - index: depGroup!.index, - }, - depType, - }, - }, - ]; - if (extraCellDeps && extraCellDeps.length > 0) { - cellDeps = [...extraCellDeps, ...cellDeps]; - } - - return { - codeHash: (cell.type_hash || cell.data_hash) as `0x${string}`, - hashType: cell.type_hash ? 'type' : 'data2', - cellDeps, - }; - } - - throw new Error(`unknown DepType ${depType}`); -} - export function toLumosConfig(scripts: SystemScriptsRecord, addressPrefix: 'ckb' | 'ckt' = 'ckt') { const config = { PREFIX: addressPrefix, diff --git a/src/devnet/fork.ts b/src/devnet/fork.ts new file mode 100644 index 0000000..26e8836 --- /dev/null +++ b/src/devnet/fork.ts @@ -0,0 +1,413 @@ +import { execFileSync, execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import toml, { JsonMap } from '@iarna/toml'; +import { cachePath, getCKBBinaryPath, packageRootPath, readSettings } from '../cfg/setting'; +import { installCKBBinary } from '../node/install'; +import { isFolderExists } from '../util/fs'; +import { Request } from '../util/request'; +import { logger } from '../util/logger'; +import { MAINNET_GENESIS_HASH, TESTNET_GENESIS_HASH, identifyPublicChainByGenesisHash } from '../scripts/const'; + +export type ForkSource = 'mainnet' | 'testnet' | 'custom'; + +export interface ForkState { + source: ForkSource; + sourceDir: string; + ckbVersion: string; + genesisHash: string; + forkedAt: string; + firstRunPending: boolean; +} + +export interface ForkOptions { + from: string; + source?: 'mainnet' | 'testnet'; + specFile?: string; + force?: boolean; +} + +export const FORK_STATE_FILE = 'fork.json'; + +// The official guide requires genesis_epoch_length to stay at 1743 for a +// mainnet fork, and to fall back to the default 1000 for a testnet fork +// (i.e. the key must be absent). Getting this wrong produces a different +// genesis hash and the node refuses to start. See nervosnetwork/ckb#5205. +const MAINNET_GENESIS_EPOCH_LENGTH = 1743; + +export function getForkStatePath(configPath: string): string { + return path.join(configPath, FORK_STATE_FILE); +} + +export function readForkState(configPath: string): ForkState | null { + const statePath = getForkStatePath(configPath); + let raw: string; + try { + raw = fs.readFileSync(statePath, 'utf8'); + } catch { + return null; + } + try { + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed.source !== 'string' || typeof parsed.firstRunPending !== 'boolean') { + return null; + } + return parsed as ForkState; + } catch { + return null; + } +} + +export function writeForkState(configPath: string, state: ForkState): void { + const statePath = getForkStatePath(configPath); + const tempPath = `${statePath}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(state, null, 2)); + fs.renameSync(tempPath, statePath); +} + +export function markForkFirstRunComplete(configPath: string): void { + const state = readForkState(configPath); + if (!state || !state.firstRunPending) return; + writeForkState(configPath, { ...state, firstRunPending: false }); +} + +export function detectSourceFromCkbToml(ckbTomlContent: string): 'mainnet' | 'testnet' | null { + let parsed: Record; + try { + parsed = toml.parse(ckbTomlContent) as unknown as Record; + } catch { + return null; + } + const chain = parsed.chain; + if (chain == null || typeof chain !== 'object') return null; + const spec = (chain as Record).spec; + if (spec == null || typeof spec !== 'object') return null; + const bundled = (spec as Record).bundled; + if (typeof bundled !== 'string') return null; + if (bundled.includes('mainnet')) return 'mainnet'; + if (bundled.includes('testnet')) return 'testnet'; + return null; +} + +export function parseGenesisHashFromInitOutput(output: string): string | null { + const match = output.match(/Genesis Hash:\s*(0x[0-9a-fA-F]{64})/); + return match ? match[1].toLowerCase() : null; +} + +// `ckb list-hashes` prints TOML with a single table whose `genesis` field is +// the chain's genesis hash. +export function parseGenesisHashFromListHashes(output: string): string | null { + const match = output.match(/^\s*genesis\s*=\s*"(0x[0-9a-fA-F]{64})"\s*$/m); + return match ? match[1].toLowerCase() : null; +} + +export function expectedGenesisHash(source: 'mainnet' | 'testnet'): string { + return source === 'mainnet' ? MAINNET_GENESIS_HASH : TESTNET_GENESIS_HASH; +} + +// Patch the imported chain spec into a minable dev chain, following +// https://docs.nervos.org/docs/node/devnet-from-existing-data +export function patchDevSpecForFork(spec: Record, source: ForkSource): Record { + const pow = { ...((spec.pow as Record) ?? {}), func: 'Dummy' }; + + const params = { ...((spec.params as Record) ?? {}) }; + params.cellbase_maturity = 0; + params.permanent_difficulty_in_dummy = true; + if (source === 'mainnet') { + params.genesis_epoch_length = MAINNET_GENESIS_EPOCH_LENGTH; + } else if (source === 'testnet') { + // testnet derives genesis_epoch_length from the default (1000); a leftover + // mainnet value here is exactly the nervosnetwork/ckb#5205 trap. + delete params.genesis_epoch_length; + } + // custom specs keep whatever genesis_epoch_length they declared + + return { ...spec, pow, params }; +} + +function validateSourceDir(sourceDir: string): void { + if (!isFolderExists(sourceDir)) { + throw new Error(`Source directory does not exist: ${sourceDir}`); + } + if (!isFolderExists(path.join(sourceDir, 'data', 'db'))) { + throw new Error( + `${sourceDir} does not look like a CKB node directory (missing data/db). ` + + `Point --from at the directory the source node runs with (-C).`, + ); + } +} + +// Best-effort detection of a running ckb process using the given directory. +// Returns null when the check cannot be performed (Windows, no ps). +function isCkbNodeRunningOn(dir: string): boolean | null { + if (process.platform === 'win32') return null; + let processes = ''; + try { + processes = execSync('ps -eo args', { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); + } catch { + return null; + } + return processes.split('\n').some((line) => { + const tokens = line.trim().split(/\s+/); + // The program itself must be a ckb binary — a looser match would flag + // unrelated processes that merely mention the directory. + const program = path.basename(tokens[0] ?? ''); + if (program !== 'ckb' && program !== 'ckb.exe') return false; + return tokens.includes('run') && line.includes(dir); + }); +} + +function assertSourceNodeStopped(sourceDir: string): void { + const running = isCkbNodeRunningOn(sourceDir); + if (running === null) { + logger.warn('Make sure the source CKB node is stopped; copying a live database produces corrupted data.'); + return; + } + if (running) { + throw new Error( + `A CKB node appears to be running on ${sourceDir}. Stop it first; ` + + `copying a live database produces corrupted data.`, + ); + } +} + +function assertOffckbDevnetStopped(): void { + const settings = readSettings(); + + // Foreground `offckb node` (no daemon pid file): look for the ckb process. + if (isCkbNodeRunningOn(settings.devnet.configPath) === true) { + throw new Error('An offckb devnet node appears to be running. Stop it before forking.'); + } + + // Daemon mode: check the pid file. + const pidFile = path.join(settings.devnet.dataPath, 'logs', 'daemon.pid'); + let raw: string; + try { + raw = fs.readFileSync(pidFile, 'utf8').trim(); + } catch { + return; + } + let pid = Number(raw); + if (!Number.isInteger(pid) || pid <= 0) { + try { + pid = Number((JSON.parse(raw) as { pid?: number }).pid); + } catch { + return; + } + } + if (!Number.isInteger(pid) || pid <= 0) return; + + let alive = true; + try { + process.kill(pid, 0); + } catch { + alive = false; // ESRCH: stale pid file, no daemon running + } + if (alive) { + throw new Error(`The offckb devnet daemon is running (PID ${pid}). Stop it first with: offckb node stop`); + } +} + +async function resolveSpecFile( + options: ForkOptions, + source: 'mainnet' | 'testnet', + ckbVersion: string, +): Promise { + if (options.specFile) { + const specFile = path.resolve(options.specFile); + if (!fs.existsSync(specFile)) { + throw new Error(`Spec file not found: ${specFile}`); + } + return specFile; + } + + const cacheDir = path.join(cachePath, 'specs', ckbVersion); + const cachedSpec = path.join(cacheDir, `${source}.toml`); + if (fs.existsSync(cachedSpec)) { + logger.debug(`Using cached ${source} spec: ${cachedSpec}`); + return cachedSpec; + } + + const url = `https://raw.githubusercontent.com/nervosnetwork/ckb/v${ckbVersion}/resource/specs/${source}.toml`; + logger.info(`Downloading ${source} chain spec from ${url} ..`); + try { + const response = await Request.send(url); + const content = await response.text(); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(cachedSpec, content); + return cachedSpec; + } catch (error) { + throw new Error( + `Failed to download the ${source} chain spec for CKB v${ckbVersion}: ${(error as Error).message}. ` + + `Pass a local copy with --spec-file.`, + ); + } +} + +function copySourceData(sourceDir: string, configPath: string): void { + const sourceData = path.join(sourceDir, 'data'); + const targetData = path.join(configPath, 'data'); + logger.info(`Copying chain data from ${sourceData} to ${targetData} ..`); + logger.info('This can take a while for large chains.'); + fs.mkdirSync(configPath, { recursive: true }); + // Full copy on purpose: never hardlink — RocksDB appends to WAL/MANIFEST in + // place, and linked files would corrupt the source chain. + fs.cpSync(sourceData, targetData, { recursive: true }); +} + +function runCkbInit(ckbBinPath: string, configPath: string, specFile: string): string { + // argv form, never a shell: --spec-file is user input and quote-wrapping + // alone does not stop shell expansion (command substitution still runs + // inside double quotes). + const args = ['init', '-C', configPath, '--chain', 'dev', '--import-spec', specFile, '--force']; + logger.debug(`Running: ${ckbBinPath} ${args.join(' ')}`); + return execFileSync(ckbBinPath, args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); +} + +// Best-effort read of the source directory's own chain identity. `ckb +// list-hashes` resolves the chain spec of the given config dir (no database +// access needed), which tells us which chain the source node was configured +// for. Returns null when the source is not a standard config dir. +function readSourceGenesisHash(ckbBinPath: string, sourceDir: string): string | null { + try { + const output = execFileSync(ckbBinPath, ['list-hashes', '-C', sourceDir], { + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'ignore'], + }); + return parseGenesisHashFromListHashes(output); + } catch { + return null; + } +} + +// Overwrite the ckb-init-generated configs with offckb's own devnet configs so +// the fork behaves like a normal offckb devnet (miner account as block +// assembler, RPC on 8114 with the Indexer module, proxy on 28114). The +// imported specs/dev.toml is kept. +function alignConfigsWithOffckb(configPath: string): void { + const settings = readSettings(); + const devnetSourcePath = path.resolve(packageRootPath, './ckb/devnet'); + + fs.copyFileSync(path.join(devnetSourcePath, 'ckb.toml'), path.join(configPath, 'ckb.toml')); + fs.copyFileSync(path.join(devnetSourcePath, 'default.db-options'), path.join(configPath, 'default.db-options')); + + const minerToml = fs.readFileSync(path.join(devnetSourcePath, 'ckb-miner.toml'), 'utf8'); + fs.writeFileSync( + path.join(configPath, 'ckb-miner.toml'), + minerToml.replace('http://ckb:8114/', settings.devnet.rpcUrl), + ); +} + +export async function forkDevnet(options: ForkOptions): Promise { + const settings = readSettings(); + const configPath = settings.devnet.configPath; + const ckbVersion = settings.bins.defaultCKBVersion; + + const sourceDir = path.resolve(options.from); + validateSourceDir(sourceDir); + assertSourceNodeStopped(sourceDir); + assertOffckbDevnetStopped(); + + if (isFolderExists(configPath)) { + if (!options.force) { + throw new Error( + `A devnet already exists at ${configPath}. Re-run with --force to replace it, ` + + `or reset it first with: offckb clean`, + ); + } + logger.info(`Removing existing devnet at ${configPath} ..`); + fs.rmSync(configPath, { recursive: true, force: true }); + } + + // Identify the source chain: explicit flag > ckb.toml bundled spec. + let source: ForkSource | null = options.source ?? null; + if (source == null) { + const sourceCkbToml = path.join(sourceDir, 'ckb.toml'); + if (fs.existsSync(sourceCkbToml)) { + source = detectSourceFromCkbToml(fs.readFileSync(sourceCkbToml, 'utf8')); + } + } + if (source == null && !options.specFile) { + throw new Error( + 'Could not identify the source chain from the source directory. ' + + 'Pass --source mainnet|testnet, or provide the chain spec with --spec-file.', + ); + } + + await installCKBBinary(ckbVersion); + const ckbBinPath = getCKBBinaryPath(ckbVersion); + + try { + // Inside the try so a failed copy (disk full, permissions, I/O) gets the + // same rollback: a partial configPath would otherwise block the next + // attempt as an "existing devnet". + copySourceData(sourceDir, configPath); + + const specFile = await resolveSpecFile(options, source ?? 'mainnet', ckbVersion); + + const initOutput = runCkbInit(ckbBinPath, configPath, specFile); + const genesisHash = parseGenesisHashFromInitOutput(initOutput); + if (!genesisHash) { + throw new Error(`Could not parse the genesis hash from ckb init output:\n${initOutput}`); + } + + // A custom spec may still be a well-known chain; let the chain data + // self-identify via its genesis hash. + if (source == null) { + source = identifyPublicChainByGenesisHash(genesisHash) ?? 'custom'; + } + if (source !== 'custom') { + const expected = expectedGenesisHash(source); + if (genesisHash !== expected) { + throw new Error( + `Genesis hash mismatch: expected ${expected} for ${source}, got ${genesisHash}. ` + + `This usually means the chain spec does not match the source data. ` + + `(Importing a testnet spec with a CKB older than v0.207.0 sets a wrong genesis_epoch_length, ` + + `see nervosnetwork/ckb#5205.)`, + ); + } + } + + // The genesis above comes from the imported spec alone — it cannot see + // that --source/--spec-file contradicts the copied data (e.g. a mainnet + // spec over testnet data would pass and only fail when the node boots). + // Cross-check the source directory's own configured genesis and reject + // mismatches now. Skipped (null) when the source is not a standard + // config dir; the node's boot-time genesis check remains the backstop. + const sourceGenesisHash = readSourceGenesisHash(ckbBinPath, sourceDir); + if (sourceGenesisHash && sourceGenesisHash !== genesisHash) { + throw new Error( + `The source directory is configured for a different chain (genesis ${sourceGenesisHash}) ` + + `than the imported spec (genesis ${genesisHash}). Pass a matching --source or --spec-file.`, + ); + } + + const devSpecPath = path.join(configPath, 'specs', 'dev.toml'); + const spec = toml.parse(fs.readFileSync(devSpecPath, 'utf8')) as unknown as Record; + const patched = patchDevSpecForFork(spec, source); + const tempSpecPath = `${devSpecPath}.tmp`; + fs.writeFileSync(tempSpecPath, toml.stringify(patched as unknown as JsonMap)); + fs.renameSync(tempSpecPath, devSpecPath); + + alignConfigsWithOffckb(configPath); + + const state: ForkState = { + source, + sourceDir, + ckbVersion, + genesisHash, + forkedAt: new Date().toISOString(), + firstRunPending: true, + }; + writeForkState(configPath, state); + + logger.success(`Devnet forked from ${sourceDir} (${source}, genesis ${genesisHash}).`); + logger.info('Start it with: offckb node'); + logger.info('The first run applies --skip-spec-check --overwrite-spec automatically.'); + } catch (error) { + // Leave no half-forked devnet behind. + fs.rmSync(configPath, { recursive: true, force: true }); + throw error; + } +} diff --git a/src/scripts/const.ts b/src/scripts/const.ts index 620ca5b..f24b29a 100644 --- a/src/scripts/const.ts +++ b/src/scripts/const.ts @@ -8,3 +8,17 @@ export const TYPE_ID_SCRIPT: SystemScript = { cellDeps: [], }, }; + +// Well-known genesis block hashes, used to identify which chain a devnet +// actually runs (a forked devnet carries the source chain's genesis). +// https://github.com/nervosnetwork/ckb/tree/master/resource/specs +export const MAINNET_GENESIS_HASH = '0x92b197aa1fba0f63633922c61c92375c9c074a93e85963554f5499fe1450d0e5'; +export const TESTNET_GENESIS_HASH = '0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606'; + +export type PublicChainIdentity = 'mainnet' | 'testnet'; + +export function identifyPublicChainByGenesisHash(genesisHash: string | undefined | null): PublicChainIdentity | null { + if (genesisHash === MAINNET_GENESIS_HASH) return 'mainnet'; + if (genesisHash === TESTNET_GENESIS_HASH) return 'testnet'; + return null; +} diff --git a/src/scripts/private.ts b/src/scripts/private.ts index e9afc33..dcfcc6c 100644 --- a/src/scripts/private.ts +++ b/src/scripts/private.ts @@ -1,26 +1,22 @@ import { ccc, CellDepInfoLike, KnownScript, Script } from '@ckb-ccc/core'; import { readSettings } from '../cfg/setting'; -import { systemCellToScriptInfo } from '../cmd/system-scripts'; import { getDevnetListHashes, ListHashes, SpecHashes } from '../util/list-hashes'; import { logger } from '../util/logger'; -import { TYPE_ID_SCRIPT } from './const'; +import { TYPE_ID_SCRIPT, identifyPublicChainByGenesisHash, PublicChainIdentity } from './const'; +import { MAINNET_SYSTEM_SCRIPTS, TESTNET_SYSTEM_SCRIPTS } from './public'; import { SystemScriptsRecord, SystemScriptName, SystemScript } from './type'; import toml from '@iarna/toml'; -import { extractScriptNameFromPath } from './util'; +import { extractScriptNameFromPath, systemCellToScriptInfo } from './util'; -export function getDevnetSystemScriptsFromListHashes(): SystemScriptsRecord | null { - const settings = readSettings(); - const listHashesString = getDevnetListHashes(settings.bins.defaultCKBVersion); - if (!listHashesString) { - logger.info(`list-hashes not found!`); - return null; - } +export interface DevnetSystemScripts { + scripts: SystemScriptsRecord; + // Which well-known chain the devnet's genesis belongs to, or null for a + // pure/custom devnet. A forked devnet self-identifies via its genesis hash. + forkedFrom: PublicChainIdentity | null; + genesisHash: string; +} - const listHashes = toml.parse(listHashesString) as unknown as ListHashes; - const chainSpecHashes: SpecHashes | null = Object.values(listHashes)[0]; - if (chainSpecHashes == null) { - throw new Error(`invalid chain spec hashes file ${listHashesString}`); - } +function buildSystemScriptsFromSpecHashes(chainSpecHashes: SpecHashes): SystemScriptsRecord { const systemScriptArray = chainSpecHashes.system_cells .map((cell) => { // Extract the file name from the path using the helper function @@ -61,6 +57,50 @@ export function getDevnetSystemScriptsFromListHashes(): SystemScriptsRecord | nu return systemScripts; } +// `ckb list-hashes` only reports what the chain spec declares in genesis. +// Post-genesis deployments (sudt/xudt/omnilock/spore on mainnet and testnet) +// can never appear there, so for a forked devnet we fill the gaps from the +// well-known static records of the chain the genesis belongs to. Genesis +// scripts always come from list-hashes — the chain's own spec wins. +function supplementFromStaticRecord(scripts: SystemScriptsRecord, staticRecord: SystemScriptsRecord): void { + for (const [name, script] of Object.entries(staticRecord)) { + const key = name as SystemScriptName; + if (scripts[key] == null && script != null) { + // deep clone so callers can never mutate the shared static record + scripts[key] = JSON.parse(JSON.stringify(script)) as SystemScript; + } + } +} + +export function resolveDevnetSystemScripts(): DevnetSystemScripts | null { + const settings = readSettings(); + const listHashesString = getDevnetListHashes(settings.bins.defaultCKBVersion); + if (!listHashesString) { + logger.info(`list-hashes not found!`); + return null; + } + + const listHashes = toml.parse(listHashesString) as unknown as ListHashes; + const chainSpecHashes: SpecHashes | null = Object.values(listHashes)[0]; + if (chainSpecHashes == null) { + throw new Error(`invalid chain spec hashes file ${listHashesString}`); + } + + const scripts = buildSystemScriptsFromSpecHashes(chainSpecHashes); + const forkedFrom = identifyPublicChainByGenesisHash(chainSpecHashes.genesis); + if (forkedFrom === 'mainnet') { + supplementFromStaticRecord(scripts, MAINNET_SYSTEM_SCRIPTS); + } else if (forkedFrom === 'testnet') { + supplementFromStaticRecord(scripts, TESTNET_SYSTEM_SCRIPTS); + } + + return { scripts, forkedFrom, genesisHash: chainSpecHashes.genesis }; +} + +export function getDevnetSystemScriptsFromListHashes(): SystemScriptsRecord | null { + return resolveDevnetSystemScripts()?.scripts ?? null; +} + export function toCCCKnownScripts(scripts: SystemScriptsRecord) { const DEVNET_SCRIPTS: Record & { cellDeps: CellDepInfoLike[] }> = { [KnownScript.Secp256k1Blake160]: scripts.secp256k1_blake160_sighash_all!.script, @@ -96,3 +136,17 @@ export function buildCCCDevnetKnownScripts() { | undefined = toCCCKnownScripts(devnetSystemScripts); return devnetKnownScripts; } + +// Build the ccc client for the devnet. A forked devnet carries the source +// chain's genesis, so a mainnet fork must use the `ckb` address prefix +// (ClientPublicMainnet); everything else uses the `ckt` prefix. Known scripts +// always come from the fork-aware resolver above. +export function buildDevnetCCCClient(url: string, fallbacks: string[] = []) { + const resolved = resolveDevnetSystemScripts(); + if (resolved == null) { + throw new Error('can not getSystemScriptsFromListHashes in devnet'); + } + const scripts = toCCCKnownScripts(resolved.scripts); + const ClientCtor = resolved.forkedFrom === 'mainnet' ? ccc.ClientPublicMainnet : ccc.ClientPublicTestnet; + return new ClientCtor({ url, scripts, fallbacks }); +} diff --git a/src/scripts/util.ts b/src/scripts/util.ts index 8b1ede8..e9a74a3 100644 --- a/src/scripts/util.ts +++ b/src/scripts/util.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import { DeploymentRecipe, getNewestMigrationFile, readDeploymentMigrationFile } from '../deploy/migration'; import { MyScriptsRecord, ScriptInfo } from '../scripts/type'; +import { SystemCell } from '../util/list-hashes'; import path from 'path'; import { logger } from '../util/logger'; @@ -97,3 +98,72 @@ export function extractScriptNameFromPath(pathString: string): string { // On Windows, it handles backslashes; on Unix, it handles forward slashes return path.basename(pathString); } + +export function systemCellToScriptInfo({ + cell, + depType, + depGroup, + extraCellDeps, +}: { + cell: SystemCell; + depType: 'code' | 'depGroup'; + depGroup?: { + txHash: string; + index: number; + }; + extraCellDeps?: ScriptInfo['cellDeps']; +}): ScriptInfo { + // todo: we left the type in cellDepsInfo since it requires async fetching and + // chain running to get the full type script of the type-id deps. + // Also, in devnet there is no real need to auto upgrade the system scripts with type-id + if (depType === 'code') { + let cellDeps: ScriptInfo['cellDeps'] = [ + { + cellDep: { + outPoint: { + txHash: cell.tx_hash as `0x${string}`, + index: cell.index, + }, + depType, + }, + }, + ]; + if (extraCellDeps && extraCellDeps.length > 0) { + cellDeps = [...extraCellDeps, ...cellDeps]; + } + return { + codeHash: (cell.type_hash || cell.data_hash) as `0x${string}`, + hashType: cell.type_hash ? 'type' : 'data2', + cellDeps, + }; + } + + if (depType === 'depGroup') { + if (!depGroup) { + throw new Error('require depGroup info since the dep type is depGroup'); + } + + let cellDeps: ScriptInfo['cellDeps'] = [ + { + cellDep: { + outPoint: { + txHash: depGroup!.txHash as `0x${string}`, + index: depGroup!.index, + }, + depType, + }, + }, + ]; + if (extraCellDeps && extraCellDeps.length > 0) { + cellDeps = [...extraCellDeps, ...cellDeps]; + } + + return { + codeHash: (cell.type_hash || cell.data_hash) as `0x${string}`, + hashType: cell.type_hash ? 'type' : 'data2', + cellDeps, + }; + } + + throw new Error(`unknown DepType ${depType}`); +} diff --git a/src/sdk/ckb.ts b/src/sdk/ckb.ts index 328a185..336f46c 100644 --- a/src/sdk/ckb.ts +++ b/src/sdk/ckb.ts @@ -4,7 +4,7 @@ import { ccc, ClientPublicMainnet, ClientPublicTestnet, OutPointLike, Script } from '@ckb-ccc/core'; import { isValidNetworkString, normalizePrivKey, validateUdtAmount, validateUdtTypeArgs } from '../util/validator'; import { networks } from './network'; -import { buildCCCDevnetKnownScripts, getDevnetSystemScriptsFromListHashes } from '../scripts/private'; +import { buildDevnetCCCClient, getDevnetSystemScriptsFromListHashes } from '../scripts/private'; import { MAINNET_SYSTEM_SCRIPTS, TESTNET_SYSTEM_SCRIPTS } from '../scripts/public'; import { SystemScriptsRecord } from '../scripts/type'; import { Migration } from '../deploy/migration'; @@ -107,11 +107,7 @@ export class CKB { url: networks.testnet.proxy_rpc_url, fallbacks: [networks.testnet.rpc_url], }) // we keep the fallbacks in case the proxy rpc is not started - : new ccc.ClientPublicTestnet({ - url: networks.devnet.proxy_rpc_url, - scripts: buildCCCDevnetKnownScripts(), - fallbacks: [networks.devnet.rpc_url], - }); + : buildDevnetCCCClient(networks.devnet.proxy_rpc_url, [networks.devnet.rpc_url]); } else { this.client = network === 'mainnet' @@ -124,11 +120,7 @@ export class CKB { url: networks.testnet.rpc_url, fallbacks: [], }) // pass it to avoid using websocket and fallback RPCs - : new ccc.ClientPublicTestnet({ - url: networks.devnet.rpc_url, - scripts: buildCCCDevnetKnownScripts(), - fallbacks: [], // pass it to avoid using websocket and fallback RPCs - }); + : buildDevnetCCCClient(networks.devnet.rpc_url); } } diff --git a/src/tools/ckb-tx-dumper.ts b/src/tools/ckb-tx-dumper.ts index c97ffaf..b5ef780 100644 --- a/src/tools/ckb-tx-dumper.ts +++ b/src/tools/ckb-tx-dumper.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { ccc } from '@ckb-ccc/core'; import { cccA } from '@ckb-ccc/core/advanced'; +import { callJsonRpc } from '../util/json-rpc'; import { logger } from '../util/logger'; export interface DumpOption { @@ -57,7 +58,10 @@ interface MockTransaction { mock_info: { inputs: MockInput[]; cell_deps: MockCellDep[]; - header_deps: string[]; + // ckb-debugger expects full JSON-RPC header views here (each with its + // `hash` field), not bare header hashes — see ckb-mock-tx-types + // ReprMockInfo.header_deps: Vec. + header_deps: unknown[]; }; tx: { version: string; @@ -205,6 +209,18 @@ async function resolveInputs(client: ccc.Client, inputs: ccc.CellInput[]): Promi return resolved; } +async function resolveHeaderDeps(rpc: string, headerDeps: ccc.Hex[]): Promise { + const headers: unknown[] = []; + for (const hash of headerDeps) { + const header = await callJsonRpc(rpc, 'get_header', [hash]); + if (!header) { + throw new Error(`Header not found: ${hash}`); + } + headers.push(header); + } + return headers; +} + export async function dumpTransaction({ rpc, txJsonFilePath, outputFilePath }: DumpOption) { try { const isTestnet = /testnet/i.test(rpc); @@ -221,16 +237,17 @@ export async function dumpTransaction({ rpc, txJsonFilePath, outputFilePath }: D const txJson = JSON.parse(fs.readFileSync(txJsonFilePath, 'utf-8')); const tx = cccA.JsonRpcTransformers.transactionTo(txJson); - const [cell_deps, inputs] = await Promise.all([ + const [cell_deps, inputs, header_deps] = await Promise.all([ resolveCellDeps(client, tx.cellDeps), resolveInputs(client, tx.inputs), + resolveHeaderDeps(rpc, tx.headerDeps), ]); const mockTx: MockTransaction = { mock_info: { inputs, cell_deps, - header_deps: tx.headerDeps.map((h) => h.toString()), + header_deps, }, tx: { version: '0x' + tx.version.toString(16), diff --git a/src/util/json-rpc.ts b/src/util/json-rpc.ts new file mode 100644 index 0000000..da5ac64 --- /dev/null +++ b/src/util/json-rpc.ts @@ -0,0 +1,64 @@ +import http from 'http'; +import https from 'https'; + +// Minimal raw JSON-RPC caller. Unlike Request.send it never goes through the +// configured proxy, which makes it safe for talking to the local devnet node. +export async function callJsonRpc( + rpcUrl: string, + method: string, + params: unknown[], + timeoutMs = 30000, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise { + const body = JSON.stringify({ id: 1, jsonrpc: '2.0', method, params }); + const url = new URL(rpcUrl); + const transport = url.protocol === 'https:' ? https : http; + + return new Promise((resolve, reject) => { + const req = transport.request( + { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname + url.search, + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(body), + }, + timeout: timeoutMs, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(chunk)); + // The response stream can error or close before `end` (connection + // reset, truncated body). Without these listeners the promise would + // hang until the request timeout — or the process would crash on an + // unhandled stream error. + res.once('error', reject); + res.on('close', () => { + if (!res.complete) { + reject(new Error(`JSON-RPC ${method} to ${rpcUrl} returned a truncated response`)); + } + }); + res.on('end', () => { + try { + const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')); + if (parsed.error) { + reject(new Error(`JSON-RPC ${method} failed: ${JSON.stringify(parsed.error)}`)); + return; + } + resolve(parsed.result); + } catch (error) { + reject(new Error(`Invalid JSON-RPC response from ${rpcUrl}: ${(error as Error).message}`)); + } + }); + }, + ); + req.on('timeout', () => { + req.destroy(new Error(`JSON-RPC ${method} to ${rpcUrl} timed out`)); + }); + req.on('error', reject); + req.write(body); + req.end(); + }); +} diff --git a/tests/ckb-tx-dumper.test.ts b/tests/ckb-tx-dumper.test.ts new file mode 100644 index 0000000..166f7d0 --- /dev/null +++ b/tests/ckb-tx-dumper.test.ts @@ -0,0 +1,135 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const HEADER_A = '0x' + 'aa'.repeat(32); +const HEADER_B = '0x' + 'bb'.repeat(32); + +const mockGetCell = jest.fn(); +const mockCallJsonRpc = jest.fn(); + +jest.mock('@ckb-ccc/core', () => ({ + ccc: { + mol: { + struct: jest.fn(), + vector: jest.fn(), + }, + ClientPublicMainnet: jest.fn().mockImplementation(() => ({ getCell: mockGetCell })), + ClientPublicTestnet: jest.fn().mockImplementation(() => ({ getCell: mockGetCell })), + OutPoint: { from: (x: unknown) => x }, + }, +})); + +jest.mock('@ckb-ccc/core/advanced', () => ({ + cccA: { + JsonRpcTransformers: { + transactionTo: () => mockCccTx, + }, + }, +})); + +jest.mock('../src/util/json-rpc', () => ({ + callJsonRpc: (...args: unknown[]) => mockCallJsonRpc(...args), +})); + +jest.mock('../src/util/logger', () => ({ + logger: { + success: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + setJsonMode: jest.fn(), + }, +})); + +// a ccc-shaped Transaction with two header deps and one input +const mockCccTx = { + version: 0, + cellDeps: [], + headerDeps: [HEADER_A, HEADER_B], + inputs: [ + { + previousOutput: { txHash: '0x' + '01'.repeat(32), index: 0 }, + since: 0, + }, + ], + outputs: [], + outputsData: [], + witnesses: [], +}; + +function fakeHeader(hash: string) { + return { + compact_target: '0x1e015555', + dao: '0x' + '00'.repeat(32), + epoch: '0x0', + extra_hash: '0x' + '00'.repeat(32), + hash, + nonce: '0x0', + number: '0x0', + parent_hash: '0x' + '00'.repeat(32), + proposals_hash: '0x' + '00'.repeat(32), + timestamp: '0x1718c6b7ff8', + transactions_root: '0x' + '00'.repeat(32), + uncles_hash: '0x' + '00'.repeat(32), + version: '0x0', + }; +} + +import { dumpTransaction } from '../src/tools/ckb-tx-dumper'; + +describe('dumpTransaction header deps', () => { + let dir: string; + let txJsonFilePath: string; + let outputFilePath: string; + + beforeEach(() => { + jest.clearAllMocks(); + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-dumper-test-')); + txJsonFilePath = path.join(dir, 'tx.json'); + outputFilePath = path.join(dir, 'out', 'mock.json'); + fs.writeFileSync(txJsonFilePath, JSON.stringify({ minimal: true })); + + mockGetCell.mockResolvedValue({ + cellOutput: { + capacity: 1000n, + lock: { codeHash: '0x' + '09'.repeat(32), hashType: 'type', args: '0x' }, + type: null, + }, + outputData: '0x', + }); + mockCallJsonRpc.mockImplementation((_rpc: string, method: string, params: string[]) => { + if (method === 'get_header') return Promise.resolve(fakeHeader(params[0])); + return Promise.resolve(null); + }); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('embeds full header objects in mock_info.header_deps', async () => { + await dumpTransaction({ rpc: 'http://127.0.0.1:8114', txJsonFilePath, outputFilePath }); + + expect(mockCallJsonRpc).toHaveBeenCalledTimes(2); + expect(mockCallJsonRpc).toHaveBeenCalledWith('http://127.0.0.1:8114', 'get_header', [HEADER_A]); + expect(mockCallJsonRpc).toHaveBeenCalledWith('http://127.0.0.1:8114', 'get_header', [HEADER_B]); + + const mockTx = JSON.parse(fs.readFileSync(outputFilePath, 'utf8')); + expect(mockTx.mock_info.header_deps).toHaveLength(2); + expect(mockTx.mock_info.header_deps[0].hash).toBe(HEADER_A); + expect(mockTx.mock_info.header_deps[1].hash).toBe(HEADER_B); + expect(mockTx.mock_info.header_deps[0].number).toBe('0x0'); + // the tx itself still references plain hashes + expect(mockTx.tx.header_deps).toEqual([HEADER_A, HEADER_B]); + }); + + it('throws when a header cannot be resolved', async () => { + mockCallJsonRpc.mockResolvedValue(null); + + await expect(dumpTransaction({ rpc: 'http://127.0.0.1:8114', txJsonFilePath, outputFilePath })).rejects.toThrow( + /Header not found/, + ); + }); +}); diff --git a/tests/debug-tx-file.test.ts b/tests/debug-tx-file.test.ts new file mode 100644 index 0000000..d1622e9 --- /dev/null +++ b/tests/debug-tx-file.test.ts @@ -0,0 +1,123 @@ +import { buildTxFileOptionBy } from '../src/cmd/debug'; +import { Network } from '../src/type/base'; + +const mockExistsSync = jest.fn(); +const mockWriteFileSync = jest.fn(); +const mockMkdirSync = jest.fn(); + +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + existsSync: (...args: unknown[]) => mockExistsSync(...args), + writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args), + mkdirSync: (...args: unknown[]) => mockMkdirSync(...args), +})); + +const mockCallJsonRpc = jest.fn(); +jest.mock('../src/util/json-rpc', () => ({ + callJsonRpc: (...args: unknown[]) => mockCallJsonRpc(...args), +})); + +const mockDumpTransaction = jest.fn(); +jest.mock('../src/tools/ckb-tx-dumper', () => ({ + dumpTransaction: (...args: unknown[]) => mockDumpTransaction(...args), +})); + +jest.mock('../src/cfg/setting', () => ({ + readSettings: () => ({ + devnet: { + rpcUrl: 'http://127.0.0.1:8114', + debugFullTransactionsPath: '/tmp/offckb/devnet/full-transactions', + transactionsPath: '/tmp/offckb/devnet/transactions', + }, + testnet: { + rpcUrl: 'https://testnet.ckb.dev', + debugFullTransactionsPath: '/tmp/offckb/testnet/full-transactions', + transactionsPath: '/tmp/offckb/testnet/transactions', + }, + mainnet: { + rpcUrl: 'https://mainnet.ckb.dev', + debugFullTransactionsPath: '/tmp/offckb/mainnet/full-transactions', + transactionsPath: '/tmp/offckb/mainnet/transactions', + }, + }), +})); + +jest.mock('../src/util/logger', () => ({ + logger: { + success: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + setJsonMode: jest.fn(), + }, +})); + +const TX_HASH = '0x' + 'ab'.repeat(32); +const TX_JSON_PATH = `/tmp/offckb/devnet/transactions/${TX_HASH}.json`; +const OUTPUT_PATH = `/tmp/offckb/devnet/full-transactions/${TX_HASH}.json`; + +describe('buildTxFileOptionBy', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does nothing when the dumped mock tx already exists', async () => { + mockExistsSync.mockReturnValue(true); + + const opt = await buildTxFileOptionBy(TX_HASH, Network.devnet); + + expect(opt).toBe(`--tx-file "${OUTPUT_PATH}"`); + expect(mockCallJsonRpc).not.toHaveBeenCalled(); + expect(mockDumpTransaction).not.toHaveBeenCalled(); + }); + + it('dumps directly from the proxy-cached tx json when present', async () => { + mockExistsSync.mockImplementation((p: string) => p === TX_JSON_PATH); + + await buildTxFileOptionBy(TX_HASH, Network.devnet); + + expect(mockCallJsonRpc).not.toHaveBeenCalled(); + expect(mockDumpTransaction).toHaveBeenCalledWith({ + rpc: 'http://127.0.0.1:8114', + txJsonFilePath: TX_JSON_PATH, + outputFilePath: OUTPUT_PATH, + }); + }); + + it('falls back to get_transaction when the tx json is not cached locally', async () => { + mockExistsSync.mockReturnValue(false); + const rpcTx = { version: '0x0', cell_deps: [], inputs: [], outputs: [], outputs_data: [], witnesses: [] }; + mockCallJsonRpc.mockResolvedValue({ transaction: rpcTx, tx_status: { status: 'committed' } }); + + const opt = await buildTxFileOptionBy(TX_HASH, Network.devnet); + + expect(mockCallJsonRpc).toHaveBeenCalledWith('http://127.0.0.1:8114', 'get_transaction', [TX_HASH]); + expect(mockWriteFileSync).toHaveBeenCalledWith(TX_JSON_PATH, JSON.stringify(rpcTx, null, 2)); + expect(mockDumpTransaction).toHaveBeenCalledWith({ + rpc: 'http://127.0.0.1:8114', + txJsonFilePath: TX_JSON_PATH, + outputFilePath: OUTPUT_PATH, + }); + expect(opt).toBe(`--tx-file "${OUTPUT_PATH}"`); + }); + + it('throws an actionable error when the transaction does not exist on the node', async () => { + mockExistsSync.mockReturnValue(false); + mockCallJsonRpc.mockResolvedValue(null); + + await expect(buildTxFileOptionBy(TX_HASH, Network.devnet)).rejects.toThrow( + `Transaction ${TX_HASH} not found on http://127.0.0.1:8114`, + ); + expect(mockDumpTransaction).not.toHaveBeenCalled(); + }); + + it('wraps RPC failures with context', async () => { + mockExistsSync.mockReturnValue(false); + mockCallJsonRpc.mockRejectedValue(new Error('connect ECONNREFUSED')); + + await expect(buildTxFileOptionBy(TX_HASH, Network.devnet)).rejects.toThrow( + `Failed to fetch transaction ${TX_HASH} from http://127.0.0.1:8114: connect ECONNREFUSED`, + ); + }); +}); diff --git a/tests/devnet-fork.test.ts b/tests/devnet-fork.test.ts new file mode 100644 index 0000000..0ca738b --- /dev/null +++ b/tests/devnet-fork.test.ts @@ -0,0 +1,197 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + detectSourceFromCkbToml, + expectedGenesisHash, + markForkFirstRunComplete, + parseGenesisHashFromInitOutput, + parseGenesisHashFromListHashes, + patchDevSpecForFork, + readForkState, + writeForkState, + ForkState, +} from '../src/devnet/fork'; +import { identifyPublicChainByGenesisHash, MAINNET_GENESIS_HASH, TESTNET_GENESIS_HASH } from '../src/scripts/const'; + +describe('detectSourceFromCkbToml', () => { + it('detects mainnet from a bundled spec', () => { + const content = `[chain]\nspec = { bundled = "specs/mainnet.toml" }\n`; + expect(detectSourceFromCkbToml(content)).toBe('mainnet'); + }); + + it('detects testnet from a bundled spec', () => { + const content = `[chain]\nspec = { bundled = "specs/testnet.toml" }\n`; + expect(detectSourceFromCkbToml(content)).toBe('testnet'); + }); + + it('returns null for a file-based (dev/custom) spec', () => { + const content = `[chain]\nspec = { file = "specs/dev.toml" }\n`; + expect(detectSourceFromCkbToml(content)).toBeNull(); + }); + + it('returns null for invalid toml', () => { + expect(detectSourceFromCkbToml('not = [valid')).toBeNull(); + }); + + it('returns null when chain spec is missing', () => { + expect(detectSourceFromCkbToml('[logger]\nfilter = "info"\n')).toBeNull(); + }); +}); + +describe('parseGenesisHashFromInitOutput', () => { + it('parses the genesis hash from ckb init output', () => { + const output = [ + 'WARN: Mining feature is disabled because of the lack of the block assembler config options.', + 'Initialized CKB directory in /tmp/foo', + 'copy /tmp/testnet.toml to specs/dev.toml', + 'Create ckb.toml', + 'Genesis Hash: 0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606', + ].join('\n'); + expect(parseGenesisHashFromInitOutput(output)).toBe(TESTNET_GENESIS_HASH); + }); + + it('returns null when the output has no genesis hash', () => { + expect(parseGenesisHashFromInitOutput('Initialized CKB directory')).toBeNull(); + }); +}); + +describe('parseGenesisHashFromListHashes', () => { + it('parses the genesis hash from ckb list-hashes output', () => { + const output = [ + '# Generated by: ckb list-hashes -C /tmp/ckb-testnet', + '[bundled-testnet]', + 'spec_hash = "0xfdf112fc3d75f1aa1b66fd756a18171a9fd4aeec0f67ea8e1a2c99d1a28dbe21"', + 'genesis = "0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606"', + 'cellbase = "0xcf17b48021d1644f6740288eb74e823ea53d598c25b3876a9b957ab5ee3d00f2"', + ].join('\n'); + expect(parseGenesisHashFromListHashes(output)).toBe(TESTNET_GENESIS_HASH); + }); + + it('accepts uppercase hex and normalizes to lowercase', () => { + const output = '[offckb]\ngenesis = "0x10639E0895502B5688A6BE8CF69460D76541BFA4821629D86D62BA0AAE3F9606"\n'; + expect(parseGenesisHashFromListHashes(output)).toBe(TESTNET_GENESIS_HASH); + }); + + it('returns null when there is no genesis field', () => { + expect(parseGenesisHashFromListHashes('[offckb]\nspec_hash = "0x00"\n')).toBeNull(); + }); +}); + +describe('expectedGenesisHash / identifyPublicChainByGenesisHash', () => { + it('maps both public chains', () => { + expect(expectedGenesisHash('mainnet')).toBe(MAINNET_GENESIS_HASH); + expect(expectedGenesisHash('testnet')).toBe(TESTNET_GENESIS_HASH); + expect(identifyPublicChainByGenesisHash(MAINNET_GENESIS_HASH)).toBe('mainnet'); + expect(identifyPublicChainByGenesisHash(TESTNET_GENESIS_HASH)).toBe('testnet'); + expect(identifyPublicChainByGenesisHash('0x' + '00'.repeat(32))).toBeNull(); + expect(identifyPublicChainByGenesisHash(undefined)).toBeNull(); + }); +}); + +describe('patchDevSpecForFork', () => { + it('applies the common dev patch on a spec without a params section (testnet)', () => { + const spec = { + name: 'ckb_testnet', + genesis: { version: 0 }, + pow: { func: 'EaglesongBlake2b' }, + }; + const patched = patchDevSpecForFork(spec, 'testnet') as Record; + + expect(patched.pow.func).toBe('Dummy'); + expect(patched.params.cellbase_maturity).toBe(0); + expect(patched.params.permanent_difficulty_in_dummy).toBe(true); + expect('genesis_epoch_length' in patched.params).toBe(false); + // untouched sections stay as-is + expect(patched.name).toBe('ckb_testnet'); + expect(patched.genesis).toEqual({ version: 0 }); + }); + + it('removes a wrong genesis_epoch_length on testnet (ckb#5205 regression)', () => { + // ckb < v0.207.0 wrote genesis_epoch_length = 1743 into imported testnet + // specs, which changes the genesis hash and bricks the fork. + const spec = { + pow: { func: 'EaglesongBlake2b' }, + params: { genesis_epoch_length: 1743 }, + }; + const patched = patchDevSpecForFork(spec, 'testnet') as Record; + expect('genesis_epoch_length' in patched.params).toBe(false); + }); + + it('forces genesis_epoch_length = 1743 on mainnet', () => { + const spec = { + pow: { func: 'Eaglesong' }, + params: { genesis_epoch_length: 1743, initial_primary_epoch_reward: 191780821917808 }, + }; + const patched = patchDevSpecForFork(spec, 'mainnet') as Record; + expect(patched.params.genesis_epoch_length).toBe(1743); + expect(patched.params.cellbase_maturity).toBe(0); + expect(patched.params.permanent_difficulty_in_dummy).toBe(true); + // economic params of the source chain are preserved + expect(patched.params.initial_primary_epoch_reward).toBe(191780821917808); + }); + + it('restores genesis_epoch_length on mainnet even if missing', () => { + const patched = patchDevSpecForFork({ pow: {} }, 'mainnet') as Record; + expect(patched.params.genesis_epoch_length).toBe(1743); + }); + + it('keeps a custom spec genesis_epoch_length untouched', () => { + const spec = { pow: { func: 'Eaglesong' }, params: { genesis_epoch_length: 500 } }; + const patched = patchDevSpecForFork(spec, 'custom') as Record; + expect(patched.params.genesis_epoch_length).toBe(500); + expect(patched.pow.func).toBe('Dummy'); + }); + + it('does not mutate the input spec object', () => { + const spec = { pow: { func: 'Eaglesong' }, params: { genesis_epoch_length: 1743 } }; + patchDevSpecForFork(spec, 'testnet'); + expect(spec.pow.func).toBe('Eaglesong'); + expect(spec.params.genesis_epoch_length).toBe(1743); + }); +}); + +describe('fork state file', () => { + let dir: string; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fork-test-')); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const state: ForkState = { + source: 'testnet', + sourceDir: '/data/ckb-testnet', + ckbVersion: '0.207.0', + genesisHash: TESTNET_GENESIS_HASH, + forkedAt: '2026-07-17T00:00:00.000Z', + firstRunPending: true, + }; + + it('returns null when no state file exists', () => { + expect(readForkState(dir)).toBeNull(); + }); + + it('round-trips the state', () => { + writeForkState(dir, state); + expect(readForkState(dir)).toEqual(state); + }); + + it('returns null for a corrupt state file', () => { + fs.writeFileSync(path.join(dir, 'fork.json'), '{oops'); + expect(readForkState(dir)).toBeNull(); + }); + + it('clears firstRunPending while preserving the other fields', () => { + writeForkState(dir, state); + markForkFirstRunComplete(dir); + const updated = readForkState(dir); + expect(updated).toEqual({ ...state, firstRunPending: false }); + }); + + it('markForkFirstRunComplete is a no-op without a state file', () => { + expect(() => markForkFirstRunComplete(dir)).not.toThrow(); + expect(readForkState(dir)).toBeNull(); + }); +}); diff --git a/tests/json-rpc.test.ts b/tests/json-rpc.test.ts new file mode 100644 index 0000000..e158b4d --- /dev/null +++ b/tests/json-rpc.test.ts @@ -0,0 +1,55 @@ +import http from 'http'; +import { AddressInfo } from 'net'; +import { callJsonRpc } from '../src/util/json-rpc'; + +describe('callJsonRpc', () => { + let server: http.Server; + let rpcUrl: string; + + afterEach(async () => { + if (server?.listening) { + await new Promise((resolve) => server.close(resolve)); + } + }); + + async function startServer(handler: http.RequestListener): Promise { + server = http.createServer(handler); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + rpcUrl = `http://127.0.0.1:${port}`; + } + + it('resolves with the result on a normal response', async () => { + await startServer((_req, res) => { + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ id: 1, jsonrpc: '2.0', result: '0x42' })); + }); + await expect(callJsonRpc(rpcUrl, 'get_tip_block_number', [])).resolves.toBe('0x42'); + }); + + it('rejects on a JSON-RPC error payload', async () => { + await startServer((_req, res) => { + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ id: 1, jsonrpc: '2.0', error: { code: -32601, message: 'Method not found' } })); + }); + await expect(callJsonRpc(rpcUrl, 'nope', [])).rejects.toThrow('Method not found'); + }); + + it('rejects instead of hanging when the response is truncated mid-body', async () => { + await startServer((_req, res) => { + // Declare a full JSON body but only send half of it, then kill the socket. + const body = JSON.stringify({ id: 1, jsonrpc: '2.0', result: '0x42' }); + res.writeHead(200, { 'content-type': 'application/json', 'content-length': body.length }); + res.write(body.slice(0, 5)); + res.socket?.destroy(); + }); + await expect(callJsonRpc(rpcUrl, 'get_tip_block_number', [])).rejects.toThrow( + /truncated|aborted|socket|other side closed/i, + ); + }); + + it('rejects when the connection cannot be established', async () => { + // Port 1 is reserved and never listens. + await expect(callJsonRpc('http://127.0.0.1:1', 'get_tip_block_number', [], 3000)).rejects.toThrow(); + }); +}); diff --git a/tests/sdk/ckb.udt.test.ts b/tests/sdk/ckb.udt.test.ts index f36f24c..3c13247 100644 --- a/tests/sdk/ckb.udt.test.ts +++ b/tests/sdk/ckb.udt.test.ts @@ -11,7 +11,7 @@ jest.mock('../../src/sdk/network', () => ({ })); jest.mock('../../src/scripts/private', () => ({ - buildCCCDevnetKnownScripts: jest.fn(() => ({})), + buildDevnetCCCClient: jest.fn(() => mockClient), getDevnetSystemScriptsFromListHashes: jest.fn(() => ({ sudt: { script: { diff --git a/tests/system-scripts.test.ts b/tests/system-scripts.test.ts new file mode 100644 index 0000000..0d85240 --- /dev/null +++ b/tests/system-scripts.test.ts @@ -0,0 +1,175 @@ +import { resolveDevnetSystemScripts, toCCCKnownScripts, buildDevnetCCCClient } from '../src/scripts/private'; +import { MAINNET_SYSTEM_SCRIPTS, TESTNET_SYSTEM_SCRIPTS } from '../src/scripts/public'; +import { MAINNET_GENESIS_HASH, TESTNET_GENESIS_HASH } from '../src/scripts/const'; + +// Real `ckb list-hashes` output (trimmed) of a testnet fork: only the four +// genesis system cells and two dep groups, genesis = testnet genesis hash. +const TESTNET_FORK_LIST_HASHES = `# Generated by: ckb list-hashes +[ckb_testnet] +spec_hash = "0x1c7f66f909b7ab4349fe71d191cacb8d1d5f3b96a0368cc6389deeb7eef2601e" +genesis = "${TESTNET_GENESIS_HASH}" +cellbase = "0x8f8c79eb6671709633fe6a46de93c0fedc9c1b8a6527a18d3983879542635c9f" + +[[ckb_testnet.system_cells]] +path = "Bundled(specs/cells/secp256k1_blake160_sighash_all)" +tx_hash = "0x8f8c79eb6671709633fe6a46de93c0fedc9c1b8a6527a18d3983879542635c9f" +index = 1 +data_hash = "0x709f3fda12f561cfacf92273c57a98fede188a3f1a59b1f888d113f9cce08649" +type_hash = "0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8" + +[[ckb_testnet.system_cells]] +path = "Bundled(specs/cells/dao)" +tx_hash = "0x8f8c79eb6671709633fe6a46de93c0fedc9c1b8a6527a18d3983879542635c9f" +index = 2 +data_hash = "0x32064a14ce10d95d4b7343054cc19d73b25b16ae61a6c681011ca781a60c7923" +type_hash = "0x82d76d1b75fe2fd9a27dfbaa65a039221a380d76c926f378d3f81cf3e7e13f2e" + +[[ckb_testnet.system_cells]] +path = "Bundled(specs/cells/secp256k1_data)" +tx_hash = "0x8f8c79eb6671709633fe6a46de93c0fedc9c1b8a6527a18d3983879542635c9f" +index = 3 +data_hash = "0x9799bee251b975b82c45a02154ce28cec89c5853ecc14d12b7b8cccfc19e0af4" + +[[ckb_testnet.system_cells]] +path = "Bundled(specs/cells/secp256k1_blake160_multisig_all)" +tx_hash = "0x8f8c79eb6671709633fe6a46de93c0fedc9c1b8a6527a18d3983879542635c9f" +index = 4 +data_hash = "0x43400de165f0821abf63dcac299bbdf7fd73898675ee4ddb099b0a0d8db63bfb" +type_hash = "0x5c5069eb0857efc65e1bca0c07df34c31663b3622fd3876c876320fc9634e2a8" + +[[ckb_testnet.dep_groups]] +included_cells = ["Bundled(specs/cells/secp256k1_data)", "Bundled(specs/cells/secp256k1_blake160_sighash_all)"] +tx_hash = "0xf8de3bb47d055cdf460d93a2a6e1b05f7432f9777c8c474abf4eec1d4aee5d37" +index = 0 + +[[ckb_testnet.dep_groups]] +included_cells = ["Bundled(specs/cells/secp256k1_data)", "Bundled(specs/cells/secp256k1_blake160_multisig_all)"] +tx_hash = "0x4ace50a3d0e04d7cc8a3f4e24f5ab3c7b29b5c40e2be0c4ae3ddce2f96a95a37" +index = 0 +`; + +const MAINNET_FORK_LIST_HASHES = TESTNET_FORK_LIST_HASHES.replaceAll('ckb_testnet', 'ckb').replace( + `genesis = "${TESTNET_GENESIS_HASH}"`, + `genesis = "${MAINNET_GENESIS_HASH}"`, +); + +// A pure offckb devnet: genesis does not match any public chain. +const PURE_DEVNET_LIST_HASHES = TESTNET_FORK_LIST_HASHES.replace( + `genesis = "${TESTNET_GENESIS_HASH}"`, + `genesis = "0x${'11'.repeat(32)}"`, +); + +const mockGetDevnetListHashes = jest.fn(); +jest.mock('../src/util/list-hashes', () => ({ + getDevnetListHashes: () => mockGetDevnetListHashes(), +})); + +jest.mock('../src/cfg/setting', () => ({ + readSettings: () => ({ + bins: { defaultCKBVersion: '0.207.0' }, + devnet: { configPath: '/tmp/offckb-devnet-config' }, + }), +})); + +jest.mock('../src/util/logger', () => ({ + logger: { + success: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + setJsonMode: jest.fn(), + }, +})); + +describe('resolveDevnetSystemScripts', () => { + beforeEach(() => { + mockGetDevnetListHashes.mockReset(); + }); + + it('returns null when list-hashes is unavailable', () => { + mockGetDevnetListHashes.mockReturnValue(null); + expect(resolveDevnetSystemScripts()).toBeNull(); + }); + + it('resolves genesis scripts from list-hashes on a pure devnet, without supplementation', () => { + mockGetDevnetListHashes.mockReturnValue(PURE_DEVNET_LIST_HASHES); + const resolved = resolveDevnetSystemScripts(); + + expect(resolved).not.toBeNull(); + expect(resolved!.forkedFrom).toBeNull(); + const scripts = resolved!.scripts; + expect(scripts.secp256k1_blake160_sighash_all!.script.codeHash).toBe( + '0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8', + ); + // not in genesis of a plain 4-cell spec, and no static record applies + expect(scripts.omnilock).toBeUndefined(); + expect(scripts.xudt).toBeUndefined(); + expect(scripts.type_id).toBeDefined(); + }); + + it('identifies a testnet fork by genesis hash and supplements post-genesis scripts', () => { + mockGetDevnetListHashes.mockReturnValue(TESTNET_FORK_LIST_HASHES); + const resolved = resolveDevnetSystemScripts(); + + expect(resolved!.forkedFrom).toBe('testnet'); + expect(resolved!.genesisHash).toBe(TESTNET_GENESIS_HASH); + const scripts = resolved!.scripts; + + // genesis scripts still come from list-hashes (the chain's own spec): + // secp256k1 is referenced via its dep group, dao via its own cell + expect(scripts.secp256k1_blake160_sighash_all!.script.cellDeps[0].cellDep.outPoint.txHash).toBe( + '0xf8de3bb47d055cdf460d93a2a6e1b05f7432f9777c8c474abf4eec1d4aee5d37', + ); + expect(scripts.secp256k1_blake160_sighash_all!.script.cellDeps[0].cellDep.depType).toBe('depGroup'); + expect(scripts.dao!.script.codeHash).toBe('0x82d76d1b75fe2fd9a27dfbaa65a039221a380d76c926f378d3f81cf3e7e13f2e'); + + // post-genesis scripts are filled from the testnet static record + expect(scripts.omnilock!.script.codeHash).toBe(TESTNET_SYSTEM_SCRIPTS.omnilock!.script.codeHash); + expect(scripts.xudt!.script.codeHash).toBe(TESTNET_SYSTEM_SCRIPTS.xudt!.script.codeHash); + expect(scripts.sudt!.script.codeHash).toBe(TESTNET_SYSTEM_SCRIPTS.sudt!.script.codeHash); + expect(scripts.anyone_can_pay!.script.codeHash).toBe(TESTNET_SYSTEM_SCRIPTS.anyone_can_pay!.script.codeHash); + + // ccc known scripts must be buildable on a fork (used by transfer/deploy) + expect(() => toCCCKnownScripts(scripts)).not.toThrow(); + }); + + it('identifies a mainnet fork by genesis hash and uses the mainnet record', () => { + mockGetDevnetListHashes.mockReturnValue(MAINNET_FORK_LIST_HASHES); + const resolved = resolveDevnetSystemScripts(); + + expect(resolved!.forkedFrom).toBe('mainnet'); + const scripts = resolved!.scripts; + expect(scripts.omnilock!.script.codeHash).toBe(MAINNET_SYSTEM_SCRIPTS.omnilock!.script.codeHash); + expect(scripts.sudt!.script.codeHash).toBe(MAINNET_SYSTEM_SCRIPTS.sudt!.script.codeHash); + expect(scripts.xudt!.script.codeHash).toBe(MAINNET_SYSTEM_SCRIPTS.xudt!.script.codeHash); + }); + + it('does not mutate or share objects with the static records', () => { + mockGetDevnetListHashes.mockReturnValue(TESTNET_FORK_LIST_HASHES); + const resolved = resolveDevnetSystemScripts(); + const scripts = resolved!.scripts; + + expect(scripts.omnilock).not.toBe(TESTNET_SYSTEM_SCRIPTS.omnilock); + scripts.omnilock!.script.cellDeps.length = 0; + expect(TESTNET_SYSTEM_SCRIPTS.omnilock!.script.cellDeps.length).toBeGreaterThan(0); + }); +}); + +describe('buildDevnetCCCClient', () => { + beforeEach(() => { + mockGetDevnetListHashes.mockReset(); + }); + + it('uses the ckt prefix on a pure/testnet devnet', () => { + mockGetDevnetListHashes.mockReturnValue(TESTNET_FORK_LIST_HASHES); + const client = buildDevnetCCCClient('http://127.0.0.1:8114'); + expect(client.addressPrefix).toBe('ckt'); + }); + + it('uses the ckb prefix on a mainnet fork so ckb1 addresses parse', () => { + mockGetDevnetListHashes.mockReturnValue(MAINNET_FORK_LIST_HASHES); + const client = buildDevnetCCCClient('http://127.0.0.1:8114'); + expect(client.addressPrefix).toBe('ckb'); + }); +});