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');
+ });
+});