Skip to content

feat: fork mainnet/testnet chain data into the local devnet#453

Open
humble-little-bear wants to merge 2 commits into
developfrom
feat/devnet-fork
Open

feat: fork mainnet/testnet chain data into the local devnet#453
humble-little-bear wants to merge 2 commits into
developfrom
feat/devnet-fork

Conversation

@humble-little-bear

Copy link
Copy Markdown
Collaborator

What

Adds offckb devnet fork — fork an existing Mainnet/Testnet data directory into the local devnet, implementing the Devnet From Existing Data flow (docs.nervos.org#857, tracked in #451) natively in offckb:

offckb devnet fork --from /path/to/ckb-data [--source mainnet|testnet] [--spec-file <path>] [--force]
offckb node   # first run auto-applies --skip-spec-check --overwrite-spec
offckb clean  # reset back to a pure devnet

The result is a locally minable dev chain that carries the source chain's real state (deployed contracts, cells) — useful for reproducing/debugging mainnet transactions and integration-testing against real deployments.

How it works

Fork setup (src/devnet/fork.ts)

  • Validates the source dir (data/db present), refuses when a ckb process (source or running offckb devnet, incl. daemon) is alive — copying a live RocksDB corrupts data.
  • Detects the source chain from the source ckb.toml bundled spec; --source/--spec-file override (spec file also enables offline use). Specs are fetched per CKB version and cached.
  • Full-copies data/ (never hardlinks — RocksDB appends WAL/MANIFEST in place), runs ckb init --chain dev --import-spec --force, and verifies the genesis hash against the well-known constants, which turns the ckb#5205 genesis_epoch_length trap into a loud error.
  • Patches specs/dev.toml per the guide: pow = Dummy, cellbase_maturity = 0, permanent_difficulty_in_dummy = true; Mainnet keeps genesis_epoch_length = 1743, Testnet must not have the key (falls back to the default 1000).
  • Aligns ckb.toml/ckb-miner.toml with offckb's built-in devnet (miner account as block assembler, RPC 8114 + Indexer) so mining rewards flow to the miner account and deposit etc. work on the fork.
  • Writes fork.json; offckb node appends --skip-spec-check --overwrite-spec on the first run only and clears the flag once the node answers RPC (failed first boots keep the flag and retry). offckb clean removes the fork entirely.

Fork-aware system scripts (one pipeline, no fork special-casing)

  • Devnet system scripts still come from ckb list-hashes of the actual chain spec — on a fork that is the source chain's spec, so genesis scripts are correct with zero changes.
  • The list-hashes output's genesis hash identifies the chain: on a mainnet/testnet match, post-genesis deployments (sudt/xudt/omnilock/spore/…) — which no chain's list-hashes can ever list — are supplemented from the well-known static records (deep-cloned). This one resolver feeds system-scripts (now titled e.g. DEVNET (fork of TESTNET)), the SDK's getSystemScripts, and ccc known scripts, so transfer/deploy/fee estimation keep working on a fork.
  • The devnet ccc client follows the fork too: a mainnet fork uses the ckb address prefix (previously ckb1 addresses were rejected on devnet).

Debug: local-first

  • offckb debug --tx-hash now falls back to fetching the transaction from the node (get_transaction) when it's not in the local proxy cache — historical txs on a fork debug fully offline. Also fixes buildTxFileOptionBy checking the wrong path (tx json missing previously crashed with ENOENT).
  • Fixes the tx dumper writing bare hash strings into mock_info.header_deps; ckb-debugger requires full header objects (ReprMockInfo.header_deps: Vec<HeaderView>), so any tx with header deps (DAO withdrawals, timelocks — the typical mainnet debug case) produced an invalid mock file. Headers are now fetched and embedded.

Verification

  • 32 new unit tests (source-chain detection, dev.toml patch branches incl. the ckb#5205 regression, genesis-hash verification, fork state machine, genesis-hash-based supplementation + clone isolation, debug fallback, header embedding). Full suite: 12 suites / 117 passed.
  • End-to-end with real ckb v0.207.0: ckb init --chain testnet source → offckb devnet fork (auto-detect, genesis hash verified) → offckb node (first-run flags applied and auto-cleared after boot; second run plain) → chain mines on testnet genesis → system-scripts shows DEVNET (fork of TESTNET) with supplemented contracts → deposit works (miner rewards mature instantly) → debug --tx-hash on a cache-deleted tx (fallback fires, debugger runs) and on a tx with header deps (full header embedded, debugger runs) → refusal cases (existing devnet w/o --force, node running) → --force + --spec-fileoffckb clean restores a pure ckb_dev devnet.

Not in this PR (follow-ups)

reflink/CoW fast copy + disk-space precheck, devnet fork --status, debug --replay (node-native estimate_cycles replay), docs cross-linking with the official guide.

Add 'offckb devnet fork' implementing the Devnet From Existing Data
flow: copy an existing mainnet/testnet data directory into the local
devnet, import and patch the source chain spec for local Dummy mining,
verify the genesis hash, and boot the first run with
--skip-spec-check --overwrite-spec automatically.

- src/devnet/fork.ts: fork command (source validation, chain detection,
  spec fetch/cache or --spec-file, ckb init --import-spec, dev.toml and
  ckb.toml alignment with offckb's devnet, fork.json state)
- src/cmd/node.ts: first run of a fork adds the spec flags and clears
  them once the node answers RPC
- system scripts resolve from the chain's own list-hashes and, when the
  genesis hash identifies a mainnet/testnet fork, supplement post-genesis
  deployments (sudt/xudt/omnilock/spore/...) from the static records;
  the devnet ccc client uses the ckb prefix on a mainnet fork
- debug: fall back to get_transaction when the tx json is not in the
  local proxy cache; fix buildTxFileOptionBy checking the wrong path
- tx dumper: embed full header objects in mock_info.header_deps instead
  of bare hashes (ckb-debugger rejects the latter)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3639ffea-3135-45a7-9a3c-5e2b556238e2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This change adds offckb devnet fork, persists fork state, applies fork-specific startup and system-script behavior, selects chain-appropriate CCC clients, and improves debug transaction preparation by fetching missing transactions and resolving header dependencies through JSON-RPC.

Changes

Devnet fork workflow

Layer / File(s) Summary
Fork command and data initialization
src/cli.ts, src/cmd/devnet-fork.ts, src/devnet/fork.ts, tests/devnet-fork.test.ts, README.md, .changeset/devnet-fork.md
Registers offckb devnet fork, copies source chain data, resolves and patches specs, verifies genesis data, writes fork state, and documents the workflow.
Fork-aware node startup
src/cmd/node.ts
Applies first-run spec overrides for forked devnets and clears pending state after RPC readiness.
Fork-aware scripts and client construction
src/scripts/*, src/cmd/system-scripts.ts, src/sdk/ckb.ts, tests/system-scripts.test.ts, tests/sdk/ckb.udt.test.ts
Resolves scripts by genesis hash, supplements fork scripts from static records, and selects ckb or ckt address behavior for CCC clients.
RPC-backed transaction debugging
src/util/json-rpc.ts, src/cmd/debug.ts, src/tools/ckb-tx-dumper.ts, tests/*tx*.test.ts
Fetches missing transactions, caches them, and stores resolved full header objects in mock_info.header_deps.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant OffckbCLI
  participant ForkWorkflow
  participant CKBNode
  participant Devnet
  User->>OffckbCLI: offckb devnet fork --from ...
  OffckbCLI->>ForkWorkflow: forkDevnet(options)
  ForkWorkflow->>CKBNode: copy and initialize source data
  CKBNode-->>ForkWorkflow: genesis hash
  ForkWorkflow->>Devnet: write fork.json
  User->>OffckbCLI: offckb node
  OffckbCLI->>Devnet: start with fork overrides
  Devnet-->>OffckbCLI: get_tip_block_number
  OffckbCLI->>Devnet: clear firstRunPending
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 19.51% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding support to fork mainnet/testnet chain data into local devnet.
Description check ✅ Passed The description is directly aligned with the changeset and explains the new fork flow, behavior, and verification.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/devnet-fork

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/cmd/node.ts`:
- Around line 94-97: Update the first-run readiness flow around ckbProcess and
clearForkFirstRunWhenNodeUp so readiness is tied to the newly spawned fork, not
merely any node at rpcUrl. Abort the readiness polling when ckbProcess exits,
verify the spawned node reports the expected genesis, and only then clear
firstRunPending; preserve the pending flag when startup fails or the port
belongs to an unrelated node.

In `@src/devnet/fork.ts`:
- Around line 322-343: Extend the fork validation around runCkbInit and
genesisHash to read the copied source database’s genesis hash using the
available CKB database inspection command, such as ckb list-hashes. Compare that
value with the imported spec’s genesisHash and reject mismatches before
committing the fork, while preserving the existing source-based expected hash
validation.
- Around line 317-319: Move the copySourceData call into the existing
try/rollback scope so failures from fs.cpSync are handled by the same cleanup
logic. Ensure partial configPath data is removed when copying fails, while
preserving the current behavior for successful copies.
- Around line 252-257: Update runCkbInit to avoid shell interpolation by
replacing execSync with execFileSync or spawnSync and passing the CKB binary
path plus init arguments as a separate argv array. Do not use
encodeBinPathForTerminal for command arguments; preserve the existing dev chain,
import-spec, force flags, UTF-8 output, buffer limit, and debug command logging
without executing user-supplied paths through a shell.

In `@src/scripts/private.ts`:
- Around line 140-151: Update buildDevnetCCCClient to require an explicit unsafe
opt-in when resolved.forkedFrom is 'mainnet', and reject that mode otherwise.
For the opted-in mainnet fork, configure a fork-only replay-protection marker or
cell dependency in the constructed CCC client/known scripts so transactions
cannot validate on mainnet; preserve existing testnet behavior and
address-prefix selection.

In `@src/util/json-rpc.ts`:
- Around line 30-45: Update the HTTP response handling callback around the
IncomingMessage `res` stream to register a one-time `error` listener that
rejects the promise and a `close` listener that rejects when `!res.complete`.
Keep the existing `data`/`end` parsing behavior, and do not rely on an `aborted`
event or flag.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cd51abf8-7217-4a3c-966f-f5e095e32c2a

📥 Commits

Reviewing files that changed from the base of the PR and between 303f54e and 8430162.

📒 Files selected for processing (19)
  • .changeset/devnet-fork.md
  • README.md
  • src/cli.ts
  • src/cmd/debug.ts
  • src/cmd/devnet-fork.ts
  • src/cmd/node.ts
  • src/cmd/system-scripts.ts
  • src/devnet/fork.ts
  • src/scripts/const.ts
  • src/scripts/private.ts
  • src/scripts/util.ts
  • src/sdk/ckb.ts
  • src/tools/ckb-tx-dumper.ts
  • src/util/json-rpc.ts
  • tests/ckb-tx-dumper.test.ts
  • tests/debug-tx-file.test.ts
  • tests/devnet-fork.test.ts
  • tests/sdk/ckb.udt.test.ts
  • tests/system-scripts.test.ts

Comment thread src/cmd/node.ts Outdated
Comment thread src/devnet/fork.ts Outdated
Comment thread src/devnet/fork.ts Outdated
Comment thread src/devnet/fork.ts
Comment on lines +322 to +343
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.)`,
);
}
}

@coderabbitai coderabbitai Bot Jul 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Verify the copied database genesis, not only the imported spec.

genesisHash comes from ckb init, so --source mainnet with testnet data and a mainnet spec passes this check. The mismatch is only discovered when the fork starts. Read the source database’s genesis hash, for example through ckb list-hashes, and compare it with the imported spec before committing the fork.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execSync } from 'child_process';
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/devnet/fork.ts` around lines 322 - 343, Extend the fork validation around
runCkbInit and genesisHash to read the copied source database’s genesis hash
using the available CKB database inspection command, such as ckb list-hashes.
Compare that value with the imported spec’s genesisHash and reject mismatches
before committing the fork, while preserving the existing source-based expected
hash validation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially addressed in 1f9019f, with one correction: ckb list-hashes is spec-driven — it resolves the config dir's chain spec and never opens the database, so it cannot literally read the copied db's genesis.

What it does catch is the realistic mislabel case: we now cross-check the source dir's own configured genesis (ckb list-hashes -C <sourceDir>) against the imported spec's genesis and reject before committing the fork. Verified e2e: --source testnet + the real testnet spec over dev-chain data now fails at fork time with a clear error and rolls back.

The residual gap (a db not matching its own config dir) is caught at first boot — ckb refuses to start on a genesis mismatch, and firstRunPending stays set so the flags retry with a clear error.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@humble-little-bear, thanks for the correction. You’re right: ckb list-hashes validates the resolved chain spec, not the RocksDB contents, so my suggested database-genesis check via that command was inaccurate.

The source-configured-genesis versus imported-spec check in 1f9019f correctly catches the realistic mislabel/spec mismatch before the fork is retained. For the remaining database/config mismatch, CKB’s startup rejection plus retaining firstRunPending provides a safe, recoverable failure path.

🐇🔍


✏️ Learnings added
Learnt from: humble-little-bear
Repo: ckb-devrel/offckb PR: 453
File: src/devnet/fork.ts:349-370
Timestamp: 2026-07-17T15:11:18.222Z
Learning: In `src/devnet/fork.ts`, `ckb list-hashes -C <configDir>` is spec-driven: it resolves the chain spec configured in that directory and does not inspect the RocksDB database’s genesis. It can validate source configuration/spec consistency, but not prove that copied database data matches that configuration.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment thread src/scripts/private.ts
Comment thread src/util/json-rpc.ts
- fork: run ckb init via execFileSync argv (no shell interpolation)
- fork: roll back partial configPath when the data copy fails
- fork: cross-check the source dir's own genesis (list-hashes) against
  the imported spec so a mislabeled --source/--spec-file is rejected
  before committing the fork
- node: clear firstRunPending only when the spawned node answers with
  the fork's genesis; abort the poll when the process exits, and never
  trust an unrelated node occupying the port
- json-rpc: reject on response stream error / premature close instead
  of hanging until the request timeout
- docs: caution that fork transactions spending mainnet cells are
  replayable on mainnet (CKB has no chain id)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant