Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clean-pandas-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@offckb/cli": patch
---

Fix canary DevRel findings across daemon lifecycle, SUDT type args, fork isolation and migration, Indexer readiness, account safety, verified ckb-tui downloads, private-key input, and stable JSON command results.
53 changes: 42 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ ckb development network for your first try

Options:
-V, --version output the version number
--json Output one command result as JSON on stdout and logs as NDJSON on stderr
-h, --help display help for command

Commands:
Expand All @@ -84,6 +85,9 @@ Commands:
debugger Port of the raw CKB Standalone Debugger
status [options] Show ckb-tui status interface
config <action> [item] [value] do a configuration action
devnet config Edit devnet configuration
devnet info Show fork metadata and node/indexer readiness
devnet fork [options] Fork Mainnet/Testnet state into the local devnet
help [command] display help for command
```

Expand Down Expand Up @@ -141,17 +145,17 @@ offckb node stop

**Agent-Friendly JSON Output**

For programmatic consumption or agent integration, add `--json` to any command to emit structured JSON logs:
For programmatic consumption or agent integration, add `--json` before or after the command:

```sh
offckb node --json
offckb node --daemon --json
offckb --json balance ckt1...
offckb devnet info --json
```

Each log line is a single JSON object:
In JSON mode, stdout is reserved for one stable command result. Progress logs are newline-delimited JSON on stderr, and failures use `{ "ok": false, "code", "message" }` with a non-zero exit code. This lets scripts parse stdout without scraping log messages or stack traces:

```json
{ "level": "info", "message": "Launching CKB devnet Node...", "timestamp": "2026-07-07T07:10:00.000Z" }
{ "ok": true, "command": "balance", "network": "devnet", "address": "ckt1...", "ckb": "4200", "udt": [] }
```

**RPC & Proxy RPC**
Expand All @@ -173,7 +177,15 @@ Using a proxy RPC server for Testnet/Mainnet is especially helpful for debugging

**Watch Network with TUI**

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.
Once you start the CKB Node, launch the interactive CKB-TUI for one network:

```sh
offckb status --network devnet
offckb status --network testnet
offckb status --network mainnet
```

`status` performs a JSON-RPC health check through the proxy before opening the TUI and requires an interactive terminal.

### 2. Create a New Contract Project {#create-project}

Expand Down Expand Up @@ -379,21 +391,31 @@ Pay attention to the `devnet.configPath` and `devnet.dataPath`.
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
# Point at the directory used by the source node's `ckb -C`:
offckb devnet fork --from /path/to/ckb-data --dry-run
offckb devnet fork --from /path/to/ckb-data
offckb node
offckb node --daemon
offckb devnet info
```

- `--from` points at the directory the source node runs with (`-C`), which must contain `data/db`. Stop the source node first.
- Database fork mode requires `--from`; it points at the directory the source node runs with (`ckb -C`), which must contain `data/db`. Keeping the source explicit makes large database copies predictable in local scripts and CI.
- Stop the source node first. Use `--dry-run` to validate the source chain, CKB/DB compatibility, migration requirement, and target without replacing the current devnet.
- The source chain is auto-detected from the source `ckb.toml`; pass `--source mainnet|testnet` when it cannot be detected, and `--spec-file <path>` 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.
- The command copies the chain state (your original data is never modified), deliberately excludes peer store/log/tmp data, imports the matching chain spec, patches it for local mining, verifies the genesis hash, and writes a fork receipt.
- Fork networking is outbound-isolated: no bootnodes, persisted peers, peer discovery, or outbound peer slots. `offckb devnet info` displays the observed peer count so this property is visible.
- If `ckb migrate --check` says the database is old, the preflight stops before changing the devnet. Re-run with `--migrate`; only the copied database is migrated.
- The first `offckb node` run automatically boots with `--skip-spec-check --overwrite-spec`; later runs are normal. Daemon startup waits for healthy CKB RPC, miner spawn, and proxy health before reporting success.
- Forking replaces the current devnet; use `--force` to replace an existing devnet/fork, or `offckb clean` to reset back to a pure devnet.

`offckb devnet info` reports RPC readiness, node tip, Indexer tip/lag, peer count, network isolation, and fork metadata. Balance and signing commands warn while the Indexer is unavailable or behind instead of silently presenting incomplete state.

On a forked devnet, `offckb system-scripts`, transfers, deploys and `offckb debug --tx-hash <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.

`offckb transfer` fails closed on a Mainnet fork: non-built-in keys require `--allow-mainnet-replay-risk`, and inputs copied from Mainnet are rejected even with that override.

## Config Setting

### List All Settings
Expand Down Expand Up @@ -455,12 +477,21 @@ LOG_LEVEL=debug offckb node

## Accounts

OffCKB comes with 20 pre-funded accounts, each initialized with `42_000_000_00000000` capacity in the genesis block.
On a pure OffCKB devnet, OffCKB comes with 20 pre-funded accounts, each initialized with `42_000_000_00000000` capacity in the genesis block. A fork keeps the source chain genesis and therefore has no OffCKB genesis allocation; built-in dev accounts are funded by locally mined cellbase cells instead.

```sh
offckb accounts
offckb accounts --show-private-keys # trusted local terminals only
```

On a Mainnet fork, `accounts` re-encodes the same dev lock scripts with the `ckb` address prefix. Once the Indexer is caught up it also reports each account's spendable pure-CKB balance; until then the field is omitted with a warning. Private keys are hidden by default so JSON and agent logs do not collect them.

- All private keys are stored in the `account/keys` file.
- Detailed information for each account is recorded in `account/account.json`.
- When deploying contracts, the deployment cost are automatically deducted from these pre-funded accounts. This allows you to test deployments without faucets or manual funding.

For commands that accept a private key, prefer `--privkey-file <path>` or `OFFCKB_PRIVATE_KEY` over `--privkey`, which is visible in shell history and process listings.

:warning: **DO NOT SEND REAL ASSETS TO THESE ACCOUNTS. THE KEYS ARE PUBLIC, AND YOU MAY LOSE YOUR MONEY** :warning:

## About CCC
Expand Down
139 changes: 112 additions & 27 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { Command, Option } from 'commander';
import { Command, CommanderError, Option } from 'commander';
import { startNode, stopNode } from './cmd/node';
import { accounts } from './cmd/accounts';
import { clean } from './cmd/clean';
Expand All @@ -13,6 +13,7 @@ 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 { devnetInfo } from './cmd/devnet-info';
import { debugSingleScript, debugTransaction, parseSingleScriptOption } from './cmd/debug';
import { printSystemScripts } from './cmd/system-scripts';
import { transferAll } from './cmd/transfer-all';
Expand All @@ -21,7 +22,6 @@ import { CKBDebugger } from './tools/ckb-debugger';
import { logger } from './util/logger';
import { Network } from './type/base';
import { status } from './cmd/status';
import { validateNetworkOpt } from './util/validator';

const version = require('../package.json').version;
const description = require('../package.json').description;
Expand All @@ -31,10 +31,22 @@ setUTF8EncodingForWindows();

const program = new Command();
program.name('offckb').description(description).version(version).enablePositionalOptions();
let activeCommand = 'offckb';

function commandPath(command: Command): string {
const names: string[] = [];
let current: Command | null = command;
while (current?.parent) {
names.unshift(current.name());
current = current.parent;
}
return names.join('.') || 'offckb';
}

program.option('--json', 'Output logs in JSON format for agent/programmatic consumption');
program.hook('preAction', (thisCommand) => {
const opts = thisCommand.opts();
program.hook('preAction', (_thisCommand, actionCommand) => {
activeCommand = commandPath(actionCommand);
const opts = actionCommand.optsWithGlobals();
if (opts.json) {
logger.setJsonMode(true);
}
Expand Down Expand Up @@ -78,7 +90,8 @@ program
.option('--target <target>', 'Specify the script binaries file/folder path to deploy', './')
.option('-o, --output <output>', 'Specify the output folder path for the deployment record files', './deployment')
.option('-t, --type-id', 'Specify if use upgradable type id to deploy the script')
.option('--privkey <privkey>', 'Specify the private key to deploy scripts')
.option('--privkey <privkey>', 'Specify the private key to deploy scripts (visible in shell history)')
.option('--privkey-file <path>', 'Read the private key from a local file')
.option('-y, --yes', 'Skip confirmation prompt and deploy immediately')
.action((options: DeployOptions) => deploy(options));

Expand All @@ -92,8 +105,7 @@ program
.action(async (option) => {
// For debugging, tx-hash is required
if (!option.txHash) {
logger.error('Error: --tx-hash is required for debugging operations');
process.exit(1);
throw new Error('--tx-hash is required for debugging operations');
}

const txHash = option.txHash;
Expand Down Expand Up @@ -129,37 +141,46 @@ program
.description('Clean the devnet data, need to stop running the chain first')
.option('-d, --data', 'Only remove chain data, keep devnet config files')
.action((options: { data?: boolean }) => clean(options));
program.command('accounts').description('Print account list info').action(accounts);
program
.command('accounts')
.description('Print account list info')
.option('--show-private-keys', 'Include built-in dev private keys (hidden by default)')
.action(async (options) => {
await accounts(options);
});

program
.command('deposit [toAddress] [amountInCKB]')
.description('Deposit CKB tokens to address, only devnet and testnet')
.option('--network <network>', 'Specify the network to deposit to', 'devnet')
.option('-r, --proxy-rpc', 'Use Proxy RPC to connect to blockchain')
.action(async (toAddress: string, amountInCKB: string, options: DepositOptions) => {
return deposit(toAddress, amountInCKB, options);
await deposit(toAddress, amountInCKB, options);
});

program
.command('transfer [toAddress] [amount]')
.description('Transfer CKB or UDT tokens to address, only devnet and testnet')
.option('--network <network>', 'Specify the network to transfer to', 'devnet')
.option('--privkey <privkey>', 'Specify the private key to transfer')
.addOption(new Option('--udt-kind <kind>', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt'))
.option('--privkey <privkey>', 'Specify the private key to transfer (visible in shell history)')
.option('--privkey-file <path>', 'Read the private key from a local file')
.addOption(new Option('--udt-kind <kind>', 'Specify the UDT kind').choices(['sudt', 'xudt']))
.option('--udt-type-args <typeArgs>', 'Specify the UDT type script args to transfer UDT')
.option('--allow-mainnet-replay-risk', 'Allow a non-built-in key on a Mainnet fork (copied inputs remain blocked)')
.option('-r, --proxy-rpc', 'Use Proxy RPC to connect to blockchain')
.action(async (toAddress: string, amount: string, options: TransferOptions) => {
return transfer(toAddress, amount, options);
await transfer(toAddress, amount, options);
});

program
.command('transfer-all [toAddress]')
.description('Transfer All CKB tokens to address, only devnet and testnet')
.option('--network <network>', 'Specify the network to transfer to', 'devnet')
.option('--privkey <privkey>', 'Specify the private key to deploy scripts')
.option('--privkey <privkey>', 'Specify the private key (visible in shell history)')
.option('--privkey-file <path>', 'Read the private key from a local file')
.option('-r, --proxy-rpc', 'Use Proxy RPC to connect to blockchain')
.action(async (toAddress: string, options: TransferOptions) => {
return transferAll(toAddress, options);
await transferAll(toAddress, options);
});

program
Expand All @@ -170,7 +191,7 @@ program
.option('--udt-type-args <typeArgs>', 'Filter by UDT type script args')
.option('--no-udt', 'Skip UDT balance scan')
.action(async (toAddress: string, options: BalanceOption) => {
return balanceOf(toAddress, options);
await balanceOf(toAddress, options);
});

const udtCommand = program.command('udt').description('UDT token commands');
Expand All @@ -182,9 +203,10 @@ udtCommand
.addOption(new Option('--udt-kind <kind>', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt'))
.option('--type-args <typeArgs>', 'Specify the UDT type script args (xudt only; defaults to signer lock hash)')
.option('--to <toAddress>', 'Specify the receiver address (defaults to signer)')
.option('--privkey <privkey>', 'Specify the private key to issue UDT')
.option('--privkey <privkey>', 'Specify the private key to issue UDT (visible in shell history)')
.option('--privkey-file <path>', 'Read the private key from a local file')
.action(async (amount: string, options: UdtIssueOption) => {
return udtIssue(amount, options);
await udtIssue(amount, options);
});

udtCommand
Expand All @@ -193,9 +215,10 @@ udtCommand
.option('--network <network>', 'Specify the network', 'devnet')
.addOption(new Option('--udt-kind <kind>', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt'))
.requiredOption('--type-args <typeArgs>', 'Specify the UDT type script args')
.option('--privkey <privkey>', 'Specify the private key to destroy UDT')
.option('--privkey <privkey>', 'Specify the private key to destroy UDT (visible in shell history)')
.option('--privkey-file <path>', 'Read the private key from a local file')
.action(async (amount: string, options: UdtDestroyOption) => {
return udtDestroy(amount, options);
await udtDestroy(amount, options);
});

program
Expand All @@ -211,10 +234,13 @@ program
program
.command('status')
.description('Show ckb-tui status interface')
.option('--network <network>', 'Specify the network whose node status to monitor', 'devnet')
.addOption(
new Option('--network <network>', 'Specify the network whose node status to monitor')
.choices(['devnet', 'testnet', 'mainnet'])
.default('devnet'),
)
.action(async (option) => {
validateNetworkOpt(option.network);
return await status({ network: option.network });
await status({ network: option.network });
});

program
Expand All @@ -235,18 +261,77 @@ devnetCommand
)
.action(devnetConfig);

devnetCommand
.command('info')
.description('Show fork metadata and node/indexer readiness')
.action(async () => {
await devnetInfo();
});

devnetCommand
.command('fork')
.description('Fork an existing mainnet/testnet chain data directory into the local devnet')
.requiredOption('--from <dir>', 'Path to the source CKB node directory (the one passed to ckb -C)')
.option('--from <dir>', 'Path to the source CKB node directory used with `ckb -C`')
.option('--source <source>', 'Source chain: mainnet or testnet (auto-detected from the source ckb.toml when omitted)')
.option('--spec-file <path>', 'Use a local chain spec file instead of downloading it')
.option('--force', 'Replace the existing devnet (or a previous fork)')
.option('--migrate', 'Migrate only the copied database when the selected CKB version requires it')
.option('--dry-run', 'Run source/spec/database preflight without replacing the current devnet')
.action(devnetFork);

program.parse(process.argv);
function normalizeGlobalJsonFlag(argv: string[]): string[] {
const jsonRequested = argv.slice(2).includes('--json');
if (!jsonRequested) return argv;
return [argv[0], argv[1], '--json', ...argv.slice(2).filter((arg) => arg !== '--json')];
}

function installBrokenPipeHandlers() {
for (const stream of [process.stdout, process.stderr]) {
stream.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EPIPE') {
process.exit(0);
}
throw error;
});
}
}

function configureCommanderErrors(command: Command) {
command.exitOverride();
command.configureOutput({
writeErr: (text) => {
if (!logger.isJsonMode()) process.stderr.write(text);
},
});
command.commands.forEach(configureCommanderErrors);
}

export async function runCli(argv: string[] = process.argv): Promise<void> {
installBrokenPipeHandlers();
const normalizedArgv = normalizeGlobalJsonFlag(argv);
if (normalizedArgv.includes('--json')) logger.setJsonMode(true);

if (!normalizedArgv.slice(2).length) {
program.outputHelp();
return;
}

configureCommanderErrors(program);

try {
await program.parseAsync(normalizedArgv);
if (logger.isJsonMode() && !logger.hasResult() && (process.exitCode == null || process.exitCode === 0)) {
logger.result({ command: activeCommand, completed: true });
}
} catch (error) {
if (error instanceof CommanderError && error.exitCode === 0) return;
const message = error instanceof Error ? error.message : String(error);
const code = error instanceof CommanderError ? error.code : 'COMMAND_FAILED';
logger.failure(code, message);
process.exitCode = error instanceof CommanderError ? error.exitCode : 1;
}
}

// If no command is specified, display help
if (!process.argv.slice(2).length) {
program.outputHelp();
if (require.main === module) {
void runCli();
}
Loading
Loading