From 1b4157dafc67a6edd8cc79ab5d0b4a4f7d4e262e Mon Sep 17 00:00:00 2001 From: RetricSu Date: Mon, 20 Jul 2026 15:38:49 +0800 Subject: [PATCH 1/8] fix canary devrel reliability issues --- .changeset/clean-pandas-report.md | 5 + README.md | 54 ++++-- src/cli.ts | 136 ++++++++++--- src/cmd/accounts.ts | 75 +++++++- src/cmd/balance.ts | 8 +- src/cmd/clean.ts | 6 +- src/cmd/config.ts | 9 +- src/cmd/create.ts | 9 +- src/cmd/debug.ts | 3 +- src/cmd/deploy.ts | 13 +- src/cmd/deposit.ts | 6 + src/cmd/devnet-config.ts | 19 +- src/cmd/devnet-fork.ts | 12 +- src/cmd/devnet-info.ts | 49 +++++ src/cmd/node.ts | 225 +++++++++++++++------- src/cmd/status.ts | 54 +----- src/cmd/transfer-all.ts | 17 +- src/cmd/transfer.ts | 42 ++-- src/cmd/udt.ts | 63 ++++-- src/devnet/fork.ts | 287 +++++++++++++++++++++++----- src/devnet/readiness.ts | 107 +++++++++++ src/node/init-chain.ts | 30 ++- src/node/install.ts | 1 + src/sdk/ckb.ts | 12 +- src/tools/ckb-tui.ts | 62 +++--- src/util/fork-safety.ts | 26 +++ src/util/fs.ts | 18 +- src/util/logger.ts | 48 ++++- src/util/private-key.ts | 25 +++ src/util/validator.ts | 13 +- tests/accounts.test.ts | 48 +++++ tests/ckb-tui-checksum.test.ts | 51 +++++ tests/devnet-config-command.test.ts | 56 ++---- tests/devnet-fork.test.ts | 89 +++++++++ tests/fork-safety.test.ts | 40 ++++ tests/init-chain.test.ts | 61 ++++++ tests/logger.test.ts | 28 +++ tests/node-command.test.ts | 59 +++--- tests/node-supervisor.test.ts | 93 +++++++++ tests/private-key.test.ts | 37 ++++ tests/readiness-warning.test.ts | 42 ++++ tests/readiness.test.ts | 50 +++++ tests/sdk/ckb.udt.test.ts | 6 +- tests/status.test.ts | 58 ++++++ tests/udt.test.ts | 37 ++-- tests/validator.test.ts | 8 +- 46 files changed, 1753 insertions(+), 444 deletions(-) create mode 100644 .changeset/clean-pandas-report.md create mode 100644 src/cmd/devnet-info.ts create mode 100644 src/devnet/readiness.ts create mode 100644 src/util/fork-safety.ts create mode 100644 src/util/private-key.ts create mode 100644 tests/accounts.test.ts create mode 100644 tests/ckb-tui-checksum.test.ts create mode 100644 tests/fork-safety.test.ts create mode 100644 tests/init-chain.test.ts create mode 100644 tests/node-supervisor.test.ts create mode 100644 tests/private-key.test.ts create mode 100644 tests/readiness-warning.test.ts create mode 100644 tests/readiness.test.ts create mode 100644 tests/status.test.ts diff --git a/.changeset/clean-pandas-report.md b/.changeset/clean-pandas-report.md new file mode 100644 index 00000000..701c0deb --- /dev/null +++ b/.changeset/clean-pandas-report.md @@ -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. diff --git a/README.md b/README.md index 6b485068..b872116a 100644 --- a/README.md +++ b/README.md @@ -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: @@ -84,6 +85,9 @@ Commands: debugger Port of the raw CKB Standalone Debugger status [options] Show ckb-tui status interface config [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 ``` @@ -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** @@ -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} @@ -379,16 +391,27 @@ 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 +# Auto-detect a single common Neuron/CKB data directory: +offckb devnet fork --dry-run + +# Or choose one explicitly: +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. +- With no `--from`, offckb searches `CKB_HOME` and common Neuron Mainnet/Testnet directories. If it finds more than one, it lists them and asks you to choose. An explicit `--from` points at the directory the source node runs with (`-C`), which must contain `data/db`. +- 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 ` 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 ` work against the real source-chain state, e.g. debugging a failed mainnet transaction fully locally. > [!CAUTION] @@ -455,12 +478,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 ` 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 diff --git a/src/cli.ts b/src/cli.ts index 73a84ec7..5e2b46b8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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'; @@ -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'; @@ -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; @@ -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); } @@ -78,7 +90,8 @@ program .option('--target ', 'Specify the script binaries file/folder path to deploy', './') .option('-o, --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 ', 'Specify the private key to deploy scripts') + .option('--privkey ', 'Specify the private key to deploy scripts (visible in shell history)') + .option('--privkey-file ', 'Read the private key from a local file') .option('-y, --yes', 'Skip confirmation prompt and deploy immediately') .action((options: DeployOptions) => deploy(options)); @@ -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; @@ -129,7 +141,13 @@ 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]') @@ -137,29 +155,31 @@ program .option('--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 ', 'Specify the network to transfer to', 'devnet') - .option('--privkey ', 'Specify the private key to transfer') + .option('--privkey ', 'Specify the private key to transfer (visible in shell history)') + .option('--privkey-file ', 'Read the private key from a local file') .addOption(new Option('--udt-kind ', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt')) .option('--udt-type-args ', 'Specify the UDT type script args to transfer UDT') .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 ', 'Specify the network to transfer to', 'devnet') - .option('--privkey ', 'Specify the private key to deploy scripts') + .option('--privkey ', 'Specify the private key (visible in shell history)') + .option('--privkey-file ', '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 @@ -170,7 +190,7 @@ program .option('--udt-type-args ', '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'); @@ -182,9 +202,10 @@ udtCommand .addOption(new Option('--udt-kind ', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt')) .option('--type-args ', 'Specify the UDT type script args (xudt only; defaults to signer lock hash)') .option('--to ', 'Specify the receiver address (defaults to signer)') - .option('--privkey ', 'Specify the private key to issue UDT') + .option('--privkey ', 'Specify the private key to issue UDT (visible in shell history)') + .option('--privkey-file ', 'Read the private key from a local file') .action(async (amount: string, options: UdtIssueOption) => { - return udtIssue(amount, options); + await udtIssue(amount, options); }); udtCommand @@ -193,9 +214,10 @@ udtCommand .option('--network ', 'Specify the network', 'devnet') .addOption(new Option('--udt-kind ', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt')) .requiredOption('--type-args ', 'Specify the UDT type script args') - .option('--privkey ', 'Specify the private key to destroy UDT') + .option('--privkey ', 'Specify the private key to destroy UDT (visible in shell history)') + .option('--privkey-file ', 'Read the private key from a local file') .action(async (amount: string, options: UdtDestroyOption) => { - return udtDestroy(amount, options); + await udtDestroy(amount, options); }); program @@ -211,10 +233,13 @@ program program .command('status') .description('Show ckb-tui status interface') - .option('--network ', 'Specify the network whose node status to monitor', 'devnet') + .addOption( + new Option('--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 @@ -235,18 +260,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 ', 'Path to the source CKB node directory (the one passed to ckb -C)') + .option('--from ', 'Path to the source CKB node directory (auto-detects common Neuron paths when omitted)') .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)') + .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 { + 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(); } diff --git a/src/cmd/accounts.ts b/src/cmd/accounts.ts index 195e915f..1eafab22 100644 --- a/src/cmd/accounts.ts +++ b/src/cmd/accounts.ts @@ -1,26 +1,75 @@ import accountConfig from '../../account/account.json'; +import { ccc } from '@ckb-ccc/core'; +import { readSettings } from '../cfg/setting'; +import { readForkState } from '../devnet/fork'; +import { warnIfForkIndexerIsBehind } from '../devnet/readiness'; +import { Network } from '../type/base'; import { logger } from '../util/logger'; -export function accounts() { +export interface AccountsOptions { + showPrivateKeys?: boolean; +} + +export async function accounts(options: AccountsOptions = {}) { + const settings = readSettings(); + const fork = readForkState(settings.devnet.configPath); + const isMainnetFork = fork?.source === 'mainnet'; + const client = isMainnetFork + ? new ccc.ClientPublicMainnet({ url: settings.devnet.rpcUrl, fallbacks: [] }) + : new ccc.ClientPublicTestnet({ url: settings.devnet.rpcUrl, fallbacks: [] }); + const context = fork ? `DEVNET (fork of ${fork.source.toUpperCase()})` : 'DEVNET'; + const readiness = fork ? await warnIfForkIndexerIsBehind(Network.devnet) : undefined; + const canReadSpendableBalance = + readiness?.ready === true && readiness.indexerTip != null && readiness.indexerLag === BigInt(0); + logger.warn([ '#### All Accounts are for test and develop only ####'.toUpperCase(), "#### DON'T use these accounts on Mainnet ####".toUpperCase(), - '#### Otherwise You will loose your money ####'.toUpperCase(), + '#### Otherwise You will lose your money ####'.toUpperCase(), '', ]); - logger.info([ - 'Print account list, each account is funded with 42_000_000_00000000 capacity in the devnet genesis block.', - '', - ]); + if (fork) { + logger.info([ + `Print account list for ${context}. Addresses use the source-chain prefix.`, + 'Forked devnets do not include the standard offckb genesis allocation; funds come from fork-mined cellbase cells.', + 'Run `offckb devnet info` before trusting balances while the indexer catches up.', + '', + ]); + } else { + logger.info([ + 'Print account list, each account is funded with 42_000_000_00000000 capacity in the devnet genesis block.', + '', + ]); + } - const accountDetails = accountConfig.map((account, index) => { + const resolvedAccounts = await Promise.all( + accountConfig.map(async (account, index) => { + const script = ccc.Script.from(account.lockScript as ccc.ScriptLike); + const address = ccc.Address.fromScript(script, client).toString(); + const spendableCkb = canReadSpendableBalance + ? ccc.fixedPointToString(await client.getBalanceSingle(script)) + : undefined; + return { + index, + address, + ...(spendableCkb == null ? {} : { spendableCkb }), + ...(options.showPrivateKeys ? { privkey: account.privkey } : {}), + pubkey: account.pubkey, + lockArg: account.lockScript.args, + lockScript: account.lockScript, + }; + }), + ); + + const accountDetails = resolvedAccounts.map((account) => { return [ - `- "#": ${index}`, + `- "#": ${account.index}`, `address: ${account.address}`, - `privkey: ${account.privkey}`, + ...('spendableCkb' in account ? [`spendable_ckb: ${account.spendableCkb}`] : []), + ...(options.showPrivateKeys ? [`privkey: ${account.privkey}`] : []), `pubkey: ${account.pubkey}`, - `lock_arg: ${account.lockScript.args}`, + `lock_arg: ${account.lockArg}`, 'lockScript:', ` codeHash: ${account.lockScript.codeHash}`, ` hashType: ${account.lockScript.hashType}`, @@ -32,4 +81,10 @@ export function accounts() { accountDetails.forEach((details, _index) => { logger.info(details); }); + + if (!options.showPrivateKeys) { + logger.info('Private keys are hidden by default. Use --show-private-keys only in a trusted local terminal.'); + } + logger.result({ command: 'accounts', context, forked: Boolean(fork), accounts: resolvedAccounts }); + return resolvedAccounts; } diff --git a/src/cmd/balance.ts b/src/cmd/balance.ts index a036a095..e1a65b3b 100644 --- a/src/cmd/balance.ts +++ b/src/cmd/balance.ts @@ -2,6 +2,7 @@ import { CKB, UdtBalanceInfo } from '../sdk/ckb'; import { validateNetworkOpt } from '../util/validator'; import { NetworkOption, Network, UdtKind } from '../type/base'; import { logger } from '../util/logger'; +import { warnIfForkIndexerIsBehind } from '../devnet/readiness'; export interface BalanceOption extends NetworkOption { udtKind?: UdtKind; @@ -13,6 +14,8 @@ export async function balanceOf(address: string, opt: BalanceOption = { network: const network = opt.network; validateNetworkOpt(network); + await warnIfForkIndexerIsBehind(network); + const ckb = new CKB({ network }); const [balanceInCKB, udtBalances] = await Promise.all([ @@ -29,8 +32,9 @@ export async function balanceOf(address: string, opt: BalanceOption = { network: logger.info(` ${udt.kind} (args=${udt.args}): ${udt.balance}`); } } - - process.exit(0); + const result = { command: 'balance', network, address, ckb: balanceInCKB, udt: filtered }; + logger.result(result); + return result; } function filterUdtBalances(balances: UdtBalanceInfo[], opt: BalanceOption): UdtBalanceInfo[] { diff --git a/src/cmd/clean.ts b/src/cmd/clean.ts index 729ae54e..a7c5738c 100644 --- a/src/cmd/clean.ts +++ b/src/cmd/clean.ts @@ -20,8 +20,7 @@ export function clean(options?: CleanOptions) { fs.rmSync(chainDataPath, { recursive: true }); logger.info(`Chain data cleaned. Devnet config files preserved.`); } catch (error: unknown) { - logger.info(`Did you stop running the chain first?`); - logger.error((error as Error).message); + throw new Error(`Failed to clean chain data. Did you stop the chain first? ${(error as Error).message}`); } } else { logger.info(`Nothing to clean. Chain data directory ${chainDataPath} not found.`); @@ -34,8 +33,7 @@ export function clean(options?: CleanOptions) { fs.rmSync(allDevnetDataPath, { recursive: true }); logger.info(`Chain data cleaned.`); } catch (error: unknown) { - logger.info(`Did you stop running the chain first?`); - logger.error((error as Error).message); + throw new Error(`Failed to clean devnet data. Did you stop the chain first? ${(error as Error).message}`); } } else { logger.info(`Nothing to clean. Devnet data directory ${allDevnetDataPath} not found.`); diff --git a/src/cmd/config.ts b/src/cmd/config.ts index a5feae8c..b5f849e3 100644 --- a/src/cmd/config.ts +++ b/src/cmd/config.ts @@ -27,8 +27,7 @@ export async function Config(action: ConfigAction, item: ConfigItem, value?: str const settings = readSettings(); const proxy = settings.proxy; if (proxy == null) { - logger.info(`No Proxy.`); - process.exit(0); + return logger.info(`No Proxy.`); } return logger.info(`${Request.proxyConfigToUrl(proxy)}`); } @@ -55,7 +54,7 @@ export async function Config(action: ConfigAction, item: ConfigItem, value?: str settings.proxy = proxy; return writeSettings(settings); } catch (error: unknown) { - return logger.error(`invalid proxyURL, `, (error as Error).message); + throw new Error(`invalid proxyURL: ${(error as Error).message}`); } } @@ -67,12 +66,12 @@ export async function Config(action: ConfigAction, item: ConfigItem, value?: str settings.bins.defaultCKBVersion = version; return writeSettings(settings); } else { - return logger.error( + throw new Error( `invalid version value, ${value}. Check available versions on https://github.com/nervosnetwork/ckb/tags`, ); } } catch (error: unknown) { - return logger.error(`invalid version value, `, (error as Error).message); + throw new Error(`invalid version value: ${(error as Error).message}`); } } diff --git a/src/cmd/create.ts b/src/cmd/create.ts index 88dcf897..0aa2b322 100644 --- a/src/cmd/create.ts +++ b/src/cmd/create.ts @@ -59,8 +59,7 @@ export async function createScriptProject(name?: string, options: CreateScriptPr // Check if directory already exists if (fs.existsSync(fullProjectPath)) { - logger.error(`❌ Directory '${projectPath}' already exists!`); - process.exit(1); + throw new Error(`Directory '${projectPath}' already exists!`); } logger.info([ @@ -85,8 +84,7 @@ export async function createScriptProject(name?: string, options: CreateScriptPr const templateDir = possiblePaths.find((p) => fs.existsSync(p)) || possiblePaths[0]; if (!fs.existsSync(templateDir)) { - logger.error(`❌ Template directory not found: ${templateDir}`); - process.exit(1); + throw new Error(`Template directory not found: ${templateDir}`); } // Initialize template processor @@ -175,8 +173,7 @@ export async function createScriptProject(name?: string, options: CreateScriptPr CKBDebugger.createCkbDebuggerFallback(); } } catch (error: unknown) { - logger.error(`\n❌ Failed to create project: ${(error as Error).message}`); - process.exit(1); + throw new Error(`Failed to create project: ${(error as Error).message}`); } } diff --git a/src/cmd/debug.ts b/src/cmd/debug.ts index 59422dc2..430326b5 100644 --- a/src/cmd/debug.ts +++ b/src/cmd/debug.ts @@ -181,7 +181,6 @@ export async function buildContract(jsFile: string, outputFile: string, jsVmPath await CKBDebugger.runWithArgs(args); logger.success(`✅ Contract built successfully: ${outputFile}`); } catch (error) { - logger.error(`❌ Build failed: ${error}`); - process.exit(1); + throw new Error(`Build failed: ${error instanceof Error ? error.message : String(error)}`); } } diff --git a/src/cmd/deploy.ts b/src/cmd/deploy.ts index c51ba65e..1347a44f 100644 --- a/src/cmd/deploy.ts +++ b/src/cmd/deploy.ts @@ -7,11 +7,15 @@ import { deployBinaries, saveArtifacts } from '../deploy'; import { CKB } from '../sdk/ckb'; import { confirm } from '@inquirer/prompts'; import { logger } from '../util/logger'; +import { resolvePrivateKey } from '../util/private-key'; +import { warnIfForkIndexerIsBehind } from '../devnet/readiness'; +import { warnIfMainnetForkSigning } from '../util/fork-safety'; export interface DeployOptions extends NetworkOption { target?: string; output?: string; privkey?: string | null; + privkeyFile?: string | null; typeId?: boolean; yes?: boolean; } @@ -22,10 +26,11 @@ export async function deploy( const network = opt.network as Network; validateNetworkOpt(network); - const ckb = new CKB({ network }); - // we use deployerAccount to deploy contract by default - const privateKey = opt.privkey || deployerAccount.privkey; + const privateKey = resolvePrivateKey(opt, deployerAccount.privkey); + warnIfMainnetForkSigning(network, privateKey); + await warnIfForkIndexerIsBehind(network); + const ckb = new CKB({ network }); const enableTypeId = opt.typeId ?? false; const targetFolder = opt.target!; const output = opt.output!; @@ -52,7 +57,7 @@ export async function deploy( '', ` 📁 Deployment artifacts will be saved to: ${outputFolder}`, ` 🌐 Network: ${network}`, - ` 🔑 Using ${opt.privkey ? 'custom' : 'default'} private key`, + ` 🔑 Using ${opt.privkey || opt.privkeyFile || process.env.OFFCKB_PRIVATE_KEY ? 'custom' : 'default'} private key`, ` 🔄 Type ID: ${enableTypeId ? 'enabled (upgradable)' : 'disabled (immutable)'}`, ]); diff --git a/src/cmd/deposit.ts b/src/cmd/deposit.ts index 3fafe5cc..ffa9d288 100644 --- a/src/cmd/deposit.ts +++ b/src/cmd/deposit.ts @@ -6,6 +6,8 @@ import { validateNetworkOpt } from '../util/validator'; import { Request } from '../util/request'; import { RequestInit } from 'node-fetch'; import { logger } from '../util/logger'; +import { warnIfForkIndexerIsBehind } from '../devnet/readiness'; +import { warnIfMainnetForkSigning } from '../util/fork-safety'; export interface DepositOptions extends NetworkOption {} @@ -25,12 +27,16 @@ export async function deposit( // deposit from devnet miner const privateKey = ckbDevnetMinerAccount.privkey; + warnIfMainnetForkSigning(network, privateKey); + await warnIfForkIndexerIsBehind(network); const txHash = await ckb.transfer({ toAddress, privateKey, amountInCKB, }); logger.info('tx hash: ', txHash); + logger.result({ command: 'deposit', network, amount: amountInCKB, toAddress, txHash }); + return txHash; } async function depositFromTestnetFaucet(ckbAddress: string, ckb: CKB) { diff --git a/src/cmd/devnet-config.ts b/src/cmd/devnet-config.ts index 8ccfbfe2..c1cb6aa6 100644 --- a/src/cmd/devnet-config.ts +++ b/src/cmd/devnet-config.ts @@ -54,12 +54,10 @@ export async function devnetConfig(options: DevnetConfigOptions = {}) { } if (!process.stdin.isTTY || !process.stdout.isTTY) { - logger.error('Interactive devnet config editor requires a TTY terminal.'); - logger.info('Use non-interactive mode instead, e.g.:'); - logger.info(' offckb devnet config --set ckb.logger.filter=info'); - logger.info(' offckb devnet config --set miner.client.poll_interval=1500'); - process.exitCode = 1; - return; + throw new Error( + 'Interactive devnet config editor requires a TTY terminal. Use non-interactive mode, e.g. ' + + '`offckb devnet config --set ckb.logger.filter=info`.', + ); } const isSaved = await runDevnetConfigTui(editor, configPath); @@ -72,13 +70,10 @@ export async function devnetConfig(options: DevnetConfigOptions = {}) { logger.info('No changes saved.'); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logger.error(message); - + let message = error instanceof Error ? error.message : String(error); if (error instanceof InitializationError) { - logger.info('Tip: run `offckb node` once to initialize devnet config files first.'); + message += ' Tip: run `offckb node` once to initialize devnet config files first.'; } - - process.exitCode = 1; + throw new Error(message); } } diff --git a/src/cmd/devnet-fork.ts b/src/cmd/devnet-fork.ts index 11fa7b28..715b1d19 100644 --- a/src/cmd/devnet-fork.ts +++ b/src/cmd/devnet-fork.ts @@ -1,16 +1,8 @@ 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); + throw new Error(`Invalid --source value: ${options.source}. Expected mainnet or testnet.`); } + await forkDevnet(options); } diff --git a/src/cmd/devnet-info.ts b/src/cmd/devnet-info.ts new file mode 100644 index 00000000..4fc33ccf --- /dev/null +++ b/src/cmd/devnet-info.ts @@ -0,0 +1,49 @@ +import { readSettings } from '../cfg/setting'; +import { readForkState } from '../devnet/fork'; +import { checkNodeReadiness } from '../devnet/readiness'; +import { logger } from '../util/logger'; + +export async function devnetInfo() { + const settings = readSettings(); + const fork = readForkState(settings.devnet.configPath); + const readiness = await checkNodeReadiness(settings.devnet.rpcUrl); + const indexerReady = readiness.indexerTip != null && readiness.indexerLag === BigInt(0); + const networkIsolated = fork && readiness.peers != null ? readiness.peers === 0 : undefined; + const result = { + command: 'devnet.info', + kind: fork ? `fork-of-${fork.source}` : 'pure-devnet', + configPath: settings.devnet.configPath, + rpcUrl: settings.devnet.rpcUrl, + proxyUrl: `http://127.0.0.1:${settings.devnet.rpcProxyPort}`, + ready: readiness.ready, + nodeTip: readiness.nodeTip?.toString(), + indexerTip: readiness.indexerTip?.toString(), + indexerLag: readiness.indexerLag?.toString(), + indexerReady, + peers: readiness.peers, + networkIsolated, + error: readiness.error, + fork, + }; + + logger.info(`Devnet: ${result.kind}`); + logger.info(`RPC: ${result.rpcUrl}`); + logger.info(`Proxy RPC: ${result.proxyUrl}`); + logger.info(`Node ready: ${result.ready ? 'yes' : 'no'}`); + if (readiness.nodeTip != null) logger.info(`Node tip: ${readiness.nodeTip}`); + if (readiness.indexerTip != null) logger.info(`Indexer tip: ${readiness.indexerTip}`); + if (readiness.indexerLag != null) logger.info(`Indexer lag: ${readiness.indexerLag}`); + logger.info(`Indexer ready: ${indexerReady ? 'yes' : 'no'}`); + if (readiness.peers != null) logger.info(`Peers: ${readiness.peers}`); + if (fork) + logger.info(`Public network isolated: ${networkIsolated == null ? 'unknown' : networkIsolated ? 'yes' : 'NO'}`); + if (fork && readiness.peers != null && readiness.peers > 0) { + logger.warn('A forked devnet has connected peers. Stop it and inspect ckb.toml before signing or mining.'); + } + if (fork?.source === 'mainnet') { + logger.warn('MAINNET FORK REPLAY RISK: only sign with built-in dev keys and fork-mined cells.'); + } + if (readiness.error) logger.warn(readiness.error); + logger.result(result); + return result; +} diff --git a/src/cmd/node.ts b/src/cmd/node.ts index 914e48e8..4cf2f7eb 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -4,12 +4,12 @@ import * as path from 'path'; import { initChainIfNeeded } from '../node/init-chain'; 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'; +import { checkNodeReadiness, waitForNodeReady } from '../devnet/readiness'; export interface NodeProp { version?: string; @@ -28,6 +28,14 @@ const DAEMON_LOG_DIR = 'logs'; const DAEMON_LOG_FILE = 'daemon.log'; const DAEMON_PID_FILE = 'daemon.pid'; const DAEMON_CHILD_ENV = 'OFFCKB_DAEMON_CHILD'; +const NODE_READY_TIMEOUT_MS = 90_000; +const FORK_NODE_READY_TIMEOUT_MS = 10 * 60_000; + +function cleanChildOutput(data: unknown): string { + // CKB colors its output even when it is redirected. Strip ANSI control + // sequences so JSON logs stay machine-readable. + return String(data).replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, ''); +} export function startNode({ version, network = Network.devnet, binaryPath, daemon }: NodeProp) { if (binaryPath && network !== Network.devnet) { @@ -59,14 +67,14 @@ export async function nodeDevnet({ version, binaryPath, daemon }: NodeProp) { let ckbBinPath = ''; if (binaryPath) { - ckbBinPath = encodeBinPathForTerminal(binaryPath); + ckbBinPath = binaryPath; logger.info(`Using custom CKB binary path: ${ckbBinPath}`); } else { await installCKBBinary(ckbVersion); - ckbBinPath = encodeBinPathForTerminal(getCKBBinaryPath(ckbVersion)); + ckbBinPath = getCKBBinaryPath(ckbVersion); } await initChainIfNeeded(); - const devnetConfigPath = encodeBinPathForTerminal(settings.devnet.configPath); + const devnetConfigPath = settings.devnet.configPath; // 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. @@ -76,56 +84,97 @@ export async function nodeDevnet({ version, binaryPath, daemon }: NodeProp) { 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 { - // Run first command - const ckbProcess = exec(ckbCmd); - // Log first command's output - ckbProcess.stdout?.on('data', (data) => { - logger.info(['CKB:', data.toString()]); - }); + const runArgs = ['run', '-C', devnetConfigPath]; + if (firstRunFlags) runArgs.push('--skip-spec-check', '--overwrite-spec'); + const ckbProcess = spawn(ckbBinPath, runArgs, { stdio: ['ignore', 'pipe', 'pipe'] }); + ckbProcess.stdout?.on('data', (data) => logger.info(['CKB:', cleanChildOutput(data)])); + ckbProcess.stderr?.on('data', (data) => logger.error(['CKB error:', cleanChildOutput(data)])); + + let ckbExited = false; + ckbProcess.once('exit', () => { + ckbExited = true; + }); + ckbProcess.once('error', () => { + ckbExited = true; + }); - ckbProcess.stderr?.on('data', (data) => { - logger.error(['CKB error:', data.toString()]); - }); + const timeoutMs = forkState ? FORK_NODE_READY_TIMEOUT_MS : NODE_READY_TIMEOUT_MS; + const readiness = await waitForNodeReady(settings.devnet.rpcUrl, timeoutMs, () => !ckbExited); + if (!readiness.ready) { + if (!ckbExited) ckbProcess.kill('SIGTERM'); + throw new Error(`CKB devnet failed to become ready: ${readiness.error ?? 'CKB process exited'}`); + } + if (ckbExited) { + throw new Error('CKB devnet exited immediately after its readiness check.'); + } - 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, - ); - } + if (forkState?.firstRunPending) { + await clearForkFirstRunWhenNodeUp( + ckbProcess, + settings.devnet.rpcUrl, + settings.devnet.configPath, + forkState.genesisHash, + ); + } - // Start the second command after 3 seconds - setTimeout(async () => { - try { - // Run second command - const minerProcess = exec(minerCmd); - minerProcess.stdout?.on('data', (data) => { - logger.info(['CKB-Miner:', data.toString()]); - }); - minerProcess.stderr?.on('data', (data) => { - logger.error(['CKB-Miner error:', data.toString()]); - }); - - // by default we start the proxy server - const ckbRpc = settings.devnet.rpcUrl; - const port = settings.devnet.rpcProxyPort; - const proxy = createRPCProxy(Network.devnet, ckbRpc, port); - proxy.start(); - } catch (error) { - logger.error('Error running CKB-Miner:', error); - } - }, 3000); + let minerProcess: ChildProcess; + try { + minerProcess = spawn(ckbBinPath, ['miner', '-C', devnetConfigPath], { stdio: ['ignore', 'pipe', 'pipe'] }); + } catch (error) { + ckbProcess.kill('SIGTERM'); + throw new Error(`CKB miner failed to start: ${(error as Error).message}`); + } + minerProcess.stdout?.on('data', (data) => logger.info(['CKB-Miner:', cleanChildOutput(data)])); + minerProcess.stderr?.on('data', (data) => logger.error(['CKB-Miner error:', cleanChildOutput(data)])); + try { + await waitForChildSpawn(minerProcess, 'CKB miner'); } catch (error) { - logger.error('Error:', error); + ckbProcess.kill('SIGTERM'); + throw error; } + + const proxy = createRPCProxy(Network.devnet, settings.devnet.rpcUrl, settings.devnet.rpcProxyPort); + proxy.start(); + logger.success(`CKB devnet is ready at ${settings.devnet.rpcUrl}.`); + logger.result({ + command: 'node', + network: Network.devnet, + daemon: false, + rpcUrl: settings.devnet.rpcUrl, + proxyUrl: `http://127.0.0.1:${settings.devnet.rpcProxyPort}`, + }); + + // Treat CKB, miner and proxy as one service. A dead CKB must not leave a + // healthy-looking proxy and a miner that retries forever. + let serviceStopping = false; + const stopService = (component: 'CKB node' | 'CKB miner', code: number | null, signal: NodeJS.Signals | null) => { + if (serviceStopping) return; + serviceStopping = true; + if (component !== 'CKB node' && !ckbProcess.killed) ckbProcess.kill('SIGTERM'); + if (component !== 'CKB miner' && !minerProcess.killed) minerProcess.kill('SIGTERM'); + proxy.stop(); + if (process.env[DAEMON_CHILD_ENV] === '1') cleanupPidFile(resolveDaemonPaths().pidFile); + logger.error(`${component} exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'none'}).`); + process.exitCode = typeof code === 'number' && code > 0 ? code : 1; + }; + ckbProcess.once('exit', (code, signal) => stopService('CKB node', code, signal)); + minerProcess.once('exit', (code, signal) => stopService('CKB miner', code, signal)); +} + +function waitForChildSpawn(child: ChildProcess, label: string): Promise { + return new Promise((resolve, reject) => { + const onSpawn = () => { + child.removeListener('error', onError); + resolve(); + }; + const onError = (error: Error) => { + child.removeListener('spawn', onSpawn); + reject(new Error(`${label} failed to start: ${error.message}`)); + }; + child.once('spawn', onSpawn); + child.once('error', onError); + }); } function resolveDaemonPaths() { @@ -352,15 +401,24 @@ function terminateProcess(pid: number, signal: 'SIGTERM' | 'SIGKILL'): Promise arg !== '--daemon'); @@ -396,15 +454,13 @@ function startDaemon() { env: childEnv, }); } catch (error) { - logger.error('Failed to spawn daemon process:', error); closeFileDescriptors(out, err); - return; + throw new Error(`Failed to spawn daemon process: ${(error as Error).message}`); } if (!child.pid) { - logger.error('Failed to spawn daemon process: no PID returned.'); closeFileDescriptors(out, err); - return; + throw new Error('Failed to spawn daemon process: no PID returned.'); } child.unref(); @@ -424,10 +480,39 @@ function startDaemon() { // File descriptors are now owned by the spawned child; close our copies. closeFileDescriptors(out, err); - logger.success(`CKB devnet daemon started with PID ${child.pid}.`); + const forkState = readForkState(settings.devnet.configPath); + const timeoutMs = forkState ? FORK_NODE_READY_TIMEOUT_MS : NODE_READY_TIMEOUT_MS; + // The proxy only starts after the child has a healthy CKB RPC and has + // successfully spawned the miner, so this is the daemon's service-level + // readiness check rather than a port/process check. + const proxyUrl = `http://127.0.0.1:${settings.devnet.rpcProxyPort}`; + const readiness = await waitForNodeReady(proxyUrl, timeoutMs, () => isProcessAlive(child.pid!)); + if (!readiness.ready) { + try { + await terminateProcess(child.pid, 'SIGTERM'); + } catch { + // The failed child may already have exited. + } + cleanupPidFile(pidFile); + throw new Error( + `CKB devnet daemon failed to become ready. See ${logFile}. ${readiness.error ?? 'Daemon process exited.'}`, + ); + } + + logger.success(`CKB devnet daemon started with PID ${child.pid} and passed its RPC/proxy health check.`); logger.info(`Logs: ${logFile}`); logger.info(`PID file: ${pidFile}`); logger.info('Stop the daemon with: offckb node stop'); + logger.result({ + command: 'node', + network: Network.devnet, + daemon: true, + pid: child.pid, + rpcUrl: settings.devnet.rpcUrl, + proxyUrl, + logFile, + pidFile, + }); } function closeFileDescriptors(...fds: (number | undefined)[]) { @@ -447,29 +532,29 @@ export async function stopNode() { const metadata = readPidFile(pidFile); if (!metadata) { logger.warn(`No daemon PID file found at ${pidFile}. Is the devnet daemon running?`); + logger.result({ command: 'node.stop', stopped: false, reason: 'not-running' }); return; } const pid = metadata.pid; if (!Number.isInteger(pid) || pid <= 0) { - logger.error(`Invalid PID in ${pidFile}: ${pid}`); cleanupPidFile(pidFile); - return; + throw new Error(`Invalid PID in ${pidFile}: ${pid}`); } if (!isProcessAlive(pid)) { logger.warn(`Daemon process ${pid} is not running.`); cleanupPidFile(pidFile); + logger.result({ command: 'node.stop', stopped: false, reason: 'stale-pid', pid }); return; } const identityOk = await verifyDaemonIdentity(pid, metadata); if (!identityOk) { - logger.error( + throw new Error( `Process ${pid} does not appear to be the offckb daemon. Refusing to send signals to avoid killing an unrelated process. ` + `If you are sure this is the daemon, stop it manually and remove ${pidFile}.`, ); - return; } logger.info(`Stopping CKB devnet daemon (PID ${pid})...`); @@ -480,16 +565,13 @@ export async function stopNode() { if (err.code === 'ESRCH') { logger.warn(`Daemon process ${pid} is not running.`); cleanupPidFile(pidFile); + logger.result({ command: 'node.stop', stopped: false, reason: 'already-exited', pid }); return; } if (err.code === 'EPERM') { - logger.error(`Permission denied when sending SIGTERM to daemon process ${pid}.`); - } else { - logger.error(`Failed to send SIGTERM to daemon process ${pid}:`, error); + throw new Error(`Permission denied when sending SIGTERM to daemon process ${pid}.`); } - // Still try to clean up the PID file so the user can recover. - cleanupPidFile(pidFile); - return; + throw new Error(`Failed to send SIGTERM to daemon process ${pid}: ${err.message}`); } const exited = await waitForProcessExit(pid, 5000); @@ -498,12 +580,13 @@ export async function stopNode() { try { await terminateProcess(pid, 'SIGKILL'); } catch (error) { - logger.error(`Failed to send SIGKILL to daemon process ${pid}:`, error); + throw new Error(`Failed to send SIGKILL to daemon process ${pid}: ${(error as Error).message}`); } } cleanupPidFile(pidFile); logger.success('CKB devnet daemon stopped.'); + logger.result({ command: 'node.stop', stopped: true, pid }); } export async function nodeTestnet() { diff --git a/src/cmd/status.ts b/src/cmd/status.ts index 881effea..d247e492 100644 --- a/src/cmd/status.ts +++ b/src/cmd/status.ts @@ -1,8 +1,7 @@ import { readSettings } from '../cfg/setting'; import { CKBTui } from '../tools/ckb-tui'; import { Network } from '../type/base'; -import { logger } from '../util/logger'; -import * as net from 'net'; +import { checkNodeReadiness } from '../devnet/readiness'; export interface StatusOptions { network: Network; @@ -20,61 +19,24 @@ export async function status({ network }: StatusOptions) { // ckb-tui is an interactive terminal UI. Running it without a TTY // (pipe, redirect, CI) would hang or produce garbage output. if (!process.stdout.isTTY || !process.stdin.isTTY) { - logger.error( - 'The status command requires an interactive terminal (TTY). ' + - 'It cannot be used in pipes, redirects, or non-interactive environments like CI.', + throw new Error( + 'The status command requires an interactive terminal (TTY). It cannot be used in pipes, redirects, or CI.', ); - process.exit(1); } const settings = readSettings(); const networkKey = NETWORK_SETTINGS_KEY[network]; const port = settings[networkKey].rpcProxyPort; const url = `http://127.0.0.1:${port}`; - const isListening = await isRPCPortListening(port); - if (!isListening) { - logger.error( - `RPC port ${port} is not listening. Please make sure the ${network} node is running and Proxy RPC is enabled.`, + const readiness = await checkNodeReadiness(url); + if (!readiness.ready) { + throw new Error( + `RPC proxy ${url} is not connected to a healthy ${network} node: ${readiness.error ?? 'health check failed'}`, ); - return; } const result = CKBTui.run(['-r', url]); // Propagate ckb-tui exit code so scripts can detect TUI failure if (result.status !== 0) { - process.exitCode = result.status ?? 1; + throw new Error(`ckb-tui exited with code ${result.status ?? 'unknown'}`); } } - -async function isRPCPortListening(port: number): Promise { - if (!Number.isInteger(port) || port < 1 || port > 65535) { - return false; - } - const client = new net.Socket(); - return new Promise((resolve) => { - let settled = false; - const TIMEOUT_MS = 2000; - const timeout = setTimeout(() => { - if (!settled) { - settled = true; - client.destroy(); - resolve(false); - } - }, TIMEOUT_MS); - client.once('error', () => { - if (!settled) { - settled = true; - clearTimeout(timeout); - resolve(false); - } - }); - client.once('connect', () => { - if (!settled) { - settled = true; - clearTimeout(timeout); - client.end(); - resolve(true); - } - }); - client.connect(port, '127.0.0.1'); - }); -} diff --git a/src/cmd/transfer-all.ts b/src/cmd/transfer-all.ts index ee37c5be..c6aefb81 100644 --- a/src/cmd/transfer-all.ts +++ b/src/cmd/transfer-all.ts @@ -3,20 +3,22 @@ import { NetworkOption, Network } from '../type/base'; import { buildTestnetTxLink } from '../util/link'; import { validateNetworkOpt } from '../util/validator'; import { logger } from '../util/logger'; +import { resolvePrivateKey } from '../util/private-key'; +import { warnIfForkIndexerIsBehind } from '../devnet/readiness'; +import { warnIfMainnetForkSigning } from '../util/fork-safety'; export interface TransferAllOptions extends NetworkOption { privkey?: string | null; + privkeyFile?: string | null; } export async function transferAll(toAddress: string, opt: TransferAllOptions = { network: Network.devnet }) { const network = opt.network; validateNetworkOpt(network); - if (opt.privkey == null) { - throw new Error('--privkey is required!'); - } - - const privateKey = opt.privkey; + const privateKey = resolvePrivateKey(opt); + warnIfMainnetForkSigning(network, privateKey); + await warnIfForkIndexerIsBehind(network); const ckb = new CKB({ network }); const txHash = await ckb.transferAll({ @@ -25,8 +27,11 @@ export async function transferAll(toAddress: string, opt: TransferAllOptions = { }); if (network === 'testnet') { logger.info(`Successfully transfer, check ${buildTestnetTxLink(txHash)} for details.`); - return; + logger.result({ command: 'transfer-all', network, toAddress, txHash }); + return txHash; } logger.info('Successfully transfer, txHash:', txHash); + logger.result({ command: 'transfer-all', network, toAddress, txHash }); + return txHash; } diff --git a/src/cmd/transfer.ts b/src/cmd/transfer.ts index 1518eb3e..768173bc 100644 --- a/src/cmd/transfer.ts +++ b/src/cmd/transfer.ts @@ -1,10 +1,15 @@ import { CKB } from '../sdk/ckb'; import { NetworkOption, Network, UdtKind } from '../type/base'; import { logTxSuccess } from '../util/link'; -import { validateNetworkOpt, validateUdtKind, validateUdtTypeArgs } from '../util/validator'; +import { validateNetworkOpt, validateUdtAmount, validateUdtKind, validateUdtTypeArgs } from '../util/validator'; +import { resolvePrivateKey } from '../util/private-key'; +import { logger } from '../util/logger'; +import { warnIfForkIndexerIsBehind } from '../devnet/readiness'; +import { warnIfMainnetForkSigning } from '../util/fork-safety'; export interface TransferOptions extends NetworkOption { privkey?: string | null; + privkeyFile?: string | null; udtKind?: UdtKind; udtTypeArgs?: string; } @@ -13,28 +18,41 @@ export async function transfer(toAddress: string, amount: string, opt: TransferO const network = opt.network; validateNetworkOpt(network); - if (opt.privkey == null) { - throw new Error('--privkey is required!'); + let udtKind: UdtKind | undefined; + let udtTypeArgs: string | undefined; + if (opt.udtTypeArgs) { + validateUdtAmount(amount); + udtKind = opt.udtKind ?? 'sudt'; + validateUdtKind(udtKind); + udtTypeArgs = validateUdtTypeArgs(udtKind, opt.udtTypeArgs); } - const privateKey = opt.privkey; + const privateKey = resolvePrivateKey(opt); + warnIfMainnetForkSigning(network, privateKey); + await warnIfForkIndexerIsBehind(network); const ckb = new CKB({ network }); - if (opt.udtTypeArgs) { - const kind = opt.udtKind ?? 'sudt'; - validateUdtKind(kind); - const udtTypeArgs = validateUdtTypeArgs(kind, opt.udtTypeArgs); - const udtType = await ckb.buildUdtTypeScript(kind, udtTypeArgs); + if (udtKind && udtTypeArgs) { + const udtType = await ckb.buildUdtTypeScript(udtKind, udtTypeArgs); const txHash = await ckb.udtTransfer({ toAddress, amount, privateKey, udtType, - kind, + kind: udtKind, }); logTxSuccess(network, txHash, 'transfer UDT'); - return; + logger.result({ + command: 'udt.transfer', + network, + kind: udtKind, + amount, + typeArgs: udtTypeArgs, + toAddress, + txHash, + }); + return txHash; } const txHash = await ckb.transfer({ @@ -43,4 +61,6 @@ export async function transfer(toAddress: string, amount: string, opt: TransferO privateKey, }); logTxSuccess(network, txHash, 'transfer'); + logger.result({ command: 'transfer', network, amount, toAddress, txHash }); + return txHash; } diff --git a/src/cmd/udt.ts b/src/cmd/udt.ts index 4d7ea238..517ea5f3 100644 --- a/src/cmd/udt.ts +++ b/src/cmd/udt.ts @@ -1,64 +1,87 @@ import { CKB } from '../sdk/ckb'; import { NetworkOption, Network, UdtKind } from '../type/base'; import { logTxSuccess } from '../util/link'; -import { validateNetworkOpt, validateUdtKind, validateUdtTypeArgs } from '../util/validator'; +import { validateNetworkOpt, validateUdtAmount, validateUdtKind, validateUdtTypeArgs } from '../util/validator'; +import { resolvePrivateKey } from '../util/private-key'; +import { logger } from '../util/logger'; +import { warnIfForkIndexerIsBehind } from '../devnet/readiness'; +import { warnIfMainnetForkSigning } from '../util/fork-safety'; export interface UdtIssueOption extends NetworkOption { udtKind: UdtKind; typeArgs?: string; to?: string; - privkey: string; + privkey?: string; + privkeyFile?: string; } export interface UdtDestroyOption extends NetworkOption { udtKind: UdtKind; typeArgs: string; - privkey: string; + privkey?: string; + privkeyFile?: string; } -export async function udtIssue( - amount: string, - opt: UdtIssueOption = { network: Network.devnet, udtKind: 'sudt', privkey: '' }, -) { +export async function udtIssue(amount: string, opt: UdtIssueOption = { network: Network.devnet, udtKind: 'sudt' }) { const network = opt.network; validateNetworkOpt(network); validateUdtKind(opt.udtKind); + validateUdtAmount(amount); + const typeArgs = opt.typeArgs ? validateUdtTypeArgs(opt.udtKind, opt.typeArgs) : undefined; - if (!opt.privkey) { - throw new Error('--privkey is required!'); - } + const privateKey = resolvePrivateKey(opt); + warnIfMainnetForkSigning(network, privateKey); + await warnIfForkIndexerIsBehind(network); const ckb = new CKB({ network }); - const txHash = await ckb.udtIssue({ - privateKey: opt.privkey, + const result = await ckb.udtIssue({ + privateKey, kind: opt.udtKind, amount, - typeArgs: opt.typeArgs ? validateUdtTypeArgs(opt.udtKind, opt.typeArgs) : undefined, + typeArgs, toAddress: opt.to, }); - logTxSuccess(network, txHash, 'issued UDT'); + logTxSuccess(network, result.txHash, 'issued UDT'); + logger.info(`UDT kind: ${opt.udtKind}`); + logger.info(`UDT type args: ${result.typeArgs}`); + logger.info(`Receiver: ${result.receiver}`); + logger.info(`Next: offckb balance ${result.receiver} --udt-kind ${opt.udtKind} --udt-type-args ${result.typeArgs}`); + logger.result({ + command: 'udt.issue', + network, + kind: opt.udtKind, + amount, + receiver: result.receiver, + typeArgs: result.typeArgs, + txHash: result.txHash, + }); + return result; } export async function udtDestroy( amount: string, - opt: UdtDestroyOption = { network: Network.devnet, udtKind: 'sudt', typeArgs: '', privkey: '' }, + opt: UdtDestroyOption = { network: Network.devnet, udtKind: 'sudt', typeArgs: '' }, ) { const network = opt.network; validateNetworkOpt(network); validateUdtKind(opt.udtKind); + validateUdtAmount(amount); + const typeArgs = validateUdtTypeArgs(opt.udtKind, opt.typeArgs); - if (!opt.privkey) { - throw new Error('--privkey is required!'); - } + const privateKey = resolvePrivateKey(opt); + warnIfMainnetForkSigning(network, privateKey); + await warnIfForkIndexerIsBehind(network); const ckb = new CKB({ network }); const txHash = await ckb.udtDestroy({ - privateKey: opt.privkey, + privateKey, kind: opt.udtKind, amount, - typeArgs: validateUdtTypeArgs(opt.udtKind, opt.typeArgs), + typeArgs, }); logTxSuccess(network, txHash, 'destroyed UDT'); + logger.result({ command: 'udt.destroy', network, kind: opt.udtKind, amount, typeArgs, txHash }); + return txHash; } diff --git a/src/devnet/fork.ts b/src/devnet/fork.ts index 26e88360..f9fec28e 100644 --- a/src/devnet/fork.ts +++ b/src/devnet/fork.ts @@ -1,5 +1,6 @@ -import { execFileSync, execSync } from 'child_process'; +import { execFileSync, execSync, spawnSync } from 'child_process'; import fs from 'fs'; +import os from 'os'; import path from 'path'; import toml, { JsonMap } from '@iarna/toml'; import { cachePath, getCKBBinaryPath, packageRootPath, readSettings } from '../cfg/setting'; @@ -18,13 +19,17 @@ export interface ForkState { genesisHash: string; forkedAt: string; firstRunPending: boolean; + databaseMigrated?: boolean; + networkIsolated?: boolean; } export interface ForkOptions { - from: string; + from?: string; source?: 'mainnet' | 'testnet'; specFile?: string; force?: boolean; + migrate?: boolean; + dryRun?: boolean; } export const FORK_STATE_FILE = 'fork.json'; @@ -137,6 +142,53 @@ function validateSourceDir(sourceDir: string): void { } } +export function commonCkbSourceDirectories( + homeDir = os.homedir(), + platform: NodeJS.Platform = process.platform, +): string[] { + const neuronRoots = + platform === 'darwin' + ? [path.join(homeDir, 'Library', 'Application Support', 'Neuron', 'chains')] + : platform === 'win32' + ? [path.join(homeDir, 'AppData', 'Roaming', 'Neuron', 'chains')] + : [ + path.join(homeDir, '.local', 'share', 'Neuron', 'chains'), + path.join(homeDir, '.config', 'Neuron', 'chains'), + ]; + + return [ + ...(process.env.CKB_HOME ? [path.resolve(process.env.CKB_HOME)] : []), + ...neuronRoots.flatMap((root) => [path.join(root, 'mainnet'), path.join(root, 'testnet')]), + path.join(homeDir, '.ckb'), + ]; +} + +export function discoverCkbSourceDirectories(candidates = commonCkbSourceDirectories()): string[] { + return [...new Set(candidates.map((candidate) => path.resolve(candidate)))].filter((candidate) => + isFolderExists(path.join(candidate, 'data', 'db')), + ); +} + +export function resolveForkSourceDirectory(from?: string, candidates?: string[]): string { + if (from) return path.resolve(from); + + const discovered = discoverCkbSourceDirectories(candidates); + if (discovered.length === 1) { + logger.info(`Auto-detected CKB source directory: ${discovered[0]}`); + return discovered[0]; + } + if (discovered.length > 1) { + throw new Error( + `Found multiple CKB source directories:\n${discovered.map((dir) => ` - ${dir}`).join('\n')}\n` + + 'Choose one with --from .', + ); + } + throw new Error( + 'Could not auto-detect a CKB data directory. Start Neuron sync first, set CKB_HOME, ' + + 'or pass the directory used by `ckb -C` with --from .', + ); +} + // 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 { @@ -244,7 +296,7 @@ async function resolveSpecFile( } } -function copySourceData(sourceDir: string, configPath: string): void { +export 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} ..`); @@ -252,7 +304,29 @@ function copySourceData(sourceDir: string, configPath: string): void { 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 }); + const excludedTopLevelEntries = new Set(['network', 'logs', 'tmp']); + fs.cpSync(sourceData, targetData, { + recursive: true, + filter: (sourcePath) => { + const relative = path.relative(sourceData, sourcePath); + if (!relative) return true; + const topLevelEntry = relative.split(path.sep)[0]; + return !excludedTopLevelEntries.has(topLevelEntry); + }, + }); + logger.info('Excluded source network peers and transient logs/tmp data from the fork.'); +} + +export function isolateForkCkbConfig(config: Record): Record { + const network = { ...((config.network as Record) ?? {}) }; + network.bootnodes = []; + network.max_outbound_peers = 0; + network.whitelist_only = true; + network.discovery_local_address = false; + + const loggerConfig = { ...((config.logger as Record) ?? {}) }; + loggerConfig.filter = 'warn'; + return { ...config, network, logger: loggerConfig }; } function runCkbInit(ckbBinPath: string, configPath: string, specFile: string): string { @@ -261,7 +335,49 @@ function runCkbInit(ckbBinPath: string, configPath: string, specFile: string): s // 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 }); + return execFileSync(ckbBinPath, args, { + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +export function migrationNeededFromExitCode(status: number | null): boolean { + if (status === 0) return true; + if (status === 64) return false; + throw new Error(`ckb migrate --check failed with exit code ${status ?? 'unknown'}`); +} + +function isDatabaseMigrationNeeded(ckbBinPath: string, configPath: string): boolean { + const result = spawnSync(ckbBinPath, ['migrate', '--check', '-C', configPath], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.error) { + throw new Error(`Could not check the CKB database version: ${result.error.message}`); + } + try { + return migrationNeededFromExitCode(result.status); + } catch (error) { + const details = [result.stdout, result.stderr].filter(Boolean).join('\n').trim(); + throw new Error(`${(error as Error).message}${details ? `: ${details}` : ''}`); + } +} + +function migrateDatabaseCopy(ckbBinPath: string, configPath: string): void { + logger.info('Migrating the copied database (the source directory remains untouched) ..'); + const result = spawnSync(ckbBinPath, ['migrate', '--force', '-C', configPath], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.error || result.status !== 0) { + const details = [result.stdout, result.stderr].filter(Boolean).join('\n').trim(); + throw new Error( + `Failed to migrate the copied database: ${result.error?.message ?? `exit code ${result.status}`}` + + `${details ? `\n${details}` : ''}`, + ); + } + logger.success('Copied database migration completed.'); } // Best-effort read of the source directory's own chain identity. `ckb @@ -281,6 +397,44 @@ function readSourceGenesisHash(ckbBinPath: string, sourceDir: string): string | } } +function validateForkSpec( + ckbBinPath: string, + sourceDir: string, + specFile: string, + declaredSource: ForkSource | null, +): { source: ForkSource; genesisHash: string } { + const tempConfigPath = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fork-preflight-')); + try { + const initOutput = runCkbInit(ckbBinPath, tempConfigPath, specFile); + const genesisHash = parseGenesisHashFromInitOutput(initOutput); + if (!genesisHash) { + throw new Error(`Could not parse the genesis hash from ckb init output:\n${initOutput}`); + } + + const source = declaredSource ?? 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.', + ); + } + } + + 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.`, + ); + } + return { source, genesisHash }; + } finally { + fs.rmSync(tempConfigPath, { recursive: true, force: true }); + } +} + // 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 @@ -289,7 +443,12 @@ 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')); + const ckbConfigPath = path.join(configPath, 'ckb.toml'); + const parsedCkbConfig = toml.parse( + fs.readFileSync(path.join(devnetSourcePath, 'ckb.toml'), 'utf8'), + ) as unknown as Record; + const ckbConfig = isolateForkCkbConfig(parsedCkbConfig); + fs.writeFileSync(ckbConfigPath, toml.stringify(ckbConfig as unknown as JsonMap)); 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'); @@ -304,21 +463,9 @@ export async function forkDevnet(options: ForkOptions): Promise { const configPath = settings.devnet.configPath; const ckbVersion = settings.bins.defaultCKBVersion; - const sourceDir = path.resolve(options.from); + const sourceDir = resolveForkSourceDirectory(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; @@ -337,6 +484,53 @@ export async function forkDevnet(options: ForkOptions): Promise { await installCKBBinary(ckbVersion); const ckbBinPath = getCKBBinaryPath(ckbVersion); + const databaseMigrationNeeded = isDatabaseMigrationNeeded(ckbBinPath, sourceDir); + const sourcePeerStoreDetected = fs.existsSync(path.join(sourceDir, 'data', 'network', 'peer_store')); + const specFile = await resolveSpecFile(options, source ?? 'mainnet', ckbVersion); + const specPreflight = validateForkSpec(ckbBinPath, sourceDir, specFile, source); + source = specPreflight.source; + + logger.info( + `Fork preflight: source=${source}, genesis=${specPreflight.genesisHash}, CKB=${ckbVersion}, migration=${databaseMigrationNeeded ? 'required' : 'not required'}.`, + ); + if (sourcePeerStoreDetected) { + logger.info('A persisted source peer store was detected and will be excluded from the fork.'); + } + if (options.dryRun) { + logger.success('Fork preflight passed. No devnet files were changed.'); + logger.result({ + command: 'devnet.fork.preflight', + sourceDir, + source, + genesisHash: specPreflight.genesisHash, + ckbVersion, + databaseMigrationNeeded, + sourcePeerStoreDetected, + sourcePeerStoreWillBeExcluded: true, + targetDir: configPath, + }); + return; + } + + assertOffckbDevnetStopped(); + + if (databaseMigrationNeeded && !options.migrate) { + throw new Error( + `The source database requires migration for CKB v${ckbVersion}. ` + + 'Re-run with --migrate to migrate only the copied devnet, leaving the source untouched.', + ); + } + + 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 }); + } try { // Inside the try so a failed copy (disk full, permissions, I/O) gets the @@ -344,42 +538,14 @@ export async function forkDevnet(options: ForkOptions): Promise { // 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) { + if (genesisHash !== specPreflight.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.`, + `Chain spec changed after preflight: expected genesis ${specPreflight.genesisHash}, got ${genesisHash}.`, ); } @@ -392,6 +558,10 @@ export async function forkDevnet(options: ForkOptions): Promise { alignConfigsWithOffckb(configPath); + if (databaseMigrationNeeded) { + migrateDatabaseCopy(ckbBinPath, configPath); + } + const state: ForkState = { source, sourceDir, @@ -399,12 +569,29 @@ export async function forkDevnet(options: ForkOptions): Promise { genesisHash, forkedAt: new Date().toISOString(), firstRunPending: true, + databaseMigrated: databaseMigrationNeeded, + networkIsolated: true, }; writeForkState(configPath, state); logger.success(`Devnet forked from ${sourceDir} (${source}, genesis ${genesisHash}).`); - logger.info('Start it with: offckb node'); + logger.info('Start it with: offckb node --daemon'); logger.info('The first run applies --skip-spec-check --overwrite-spec automatically.'); + logger.info('Then inspect node, Indexer, and network isolation with: offckb devnet info'); + logger.info('List source-prefix dev addresses with: offckb accounts'); + if (source === 'mainnet') { + logger.warn('MAINNET FORK REPLAY RISK: only sign with built-in dev keys and fork-mined cells.'); + } + logger.result({ + command: 'devnet.fork', + sourceDir, + source, + genesisHash, + ckbVersion, + databaseMigrated: databaseMigrationNeeded, + networkIsolated: true, + next: ['offckb node --daemon', 'offckb devnet info', 'offckb accounts'], + }); } catch (error) { // Leave no half-forked devnet behind. fs.rmSync(configPath, { recursive: true, force: true }); diff --git a/src/devnet/readiness.ts b/src/devnet/readiness.ts new file mode 100644 index 00000000..c3e91034 --- /dev/null +++ b/src/devnet/readiness.ts @@ -0,0 +1,107 @@ +import { readSettings } from '../cfg/setting'; +import { Network } from '../type/base'; +import { logger } from '../util/logger'; +import { callJsonRpc } from '../util/json-rpc'; +import { readForkState } from './fork'; + +export interface NodeReadiness { + ready: boolean; + rpcUrl: string; + version?: string; + nodeTip?: bigint; + indexerTip?: bigint; + indexerLag?: bigint; + peers?: number; + error?: string; +} + +function parseHexNumber(value: unknown): bigint | undefined { + if (typeof value !== 'string' || !/^0x[0-9a-f]+$/i.test(value)) return undefined; + return BigInt(value); +} + +export async function checkNodeReadiness(rpcUrl: string, timeoutMs = 3000): Promise { + try { + const [nodeInfo, tipValue] = await Promise.all([ + callJsonRpc(rpcUrl, 'local_node_info', [], timeoutMs), + callJsonRpc(rpcUrl, 'get_tip_block_number', [], timeoutMs), + ]); + const nodeTip = parseHexNumber(tipValue); + if (nodeTip == null) { + throw new Error(`Invalid node tip returned by ${rpcUrl}`); + } + + let indexerTip: bigint | undefined; + try { + const value = await callJsonRpc(rpcUrl, 'get_indexer_tip', [], timeoutMs); + indexerTip = parseHexNumber(value?.block_number); + } catch { + // Indexer readiness is reported separately and does not make the node RPC unhealthy. + } + + let peers: number | undefined; + try { + const value = await callJsonRpc(rpcUrl, 'get_peers', [], timeoutMs); + peers = Array.isArray(value) ? value.length : undefined; + } catch { + // The Net RPC module can be disabled on custom nodes. + } + + const indexerLag = indexerTip == null ? undefined : indexerTip >= nodeTip ? BigInt(0) : nodeTip - indexerTip; + return { + ready: true, + rpcUrl, + version: typeof nodeInfo?.version === 'string' ? nodeInfo.version : undefined, + nodeTip, + indexerTip, + indexerLag, + peers, + }; + } catch (error) { + return { + ready: false, + rpcUrl, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export async function waitForNodeReady( + rpcUrl: string, + timeoutMs: number, + isProcessAlive: () => boolean = () => true, +): Promise { + const start = Date.now(); + let last = await checkNodeReadiness(rpcUrl); + while (!last.ready && isProcessAlive() && Date.now() - start < timeoutMs) { + await new Promise((resolve) => setTimeout(resolve, 500)); + last = await checkNodeReadiness(rpcUrl); + } + return last; +} + +export async function checkConfiguredDevnetReadiness(): Promise { + return checkNodeReadiness(readSettings().devnet.rpcUrl); +} + +export async function warnIfForkIndexerIsBehind(network: Network): Promise { + if (network !== Network.devnet) return undefined; + + const settings = readSettings(); + if (!readForkState(settings.devnet.configPath)) return undefined; + + const readiness = await checkNodeReadiness(settings.devnet.rpcUrl); + if (!readiness.ready) { + logger.warn(`The forked devnet is not RPC-ready: ${readiness.error ?? 'health check failed'}`); + } else if (readiness.indexerTip == null) { + logger.warn( + 'The CKB indexer is not ready yet; cell and balance lookups may be incomplete. Check `offckb devnet info`.', + ); + } else if (readiness.indexerLag && readiness.indexerLag > BigInt(0)) { + logger.warn( + `The CKB indexer is ${readiness.indexerLag} blocks behind the node; cell and balance lookups may be incomplete. ` + + 'Check `offckb devnet info`.', + ); + } + return readiness; +} diff --git a/src/node/init-chain.ts b/src/node/init-chain.ts index a98d721c..29a3a660 100644 --- a/src/node/init-chain.ts +++ b/src/node/init-chain.ts @@ -8,19 +8,29 @@ export async function initChainIfNeeded() { const settings = readSettings(); const devnetSourcePath = path.resolve(packageRootPath, './ckb/devnet'); const devnetConfigPath = settings.devnet.configPath; - if (!isFolderExists(devnetConfigPath)) { - const devnetConfigPath = settings.devnet.configPath; - await copyFilesWithExclusion(devnetSourcePath, devnetConfigPath, ['data']); + const requiredConfigFiles = ['ckb.toml', 'ckb-miner.toml', path.join('specs', 'dev.toml')]; + const isInitialized = + isFolderExists(devnetConfigPath) && + requiredConfigFiles.every((relativePath) => fs.existsSync(path.join(devnetConfigPath, relativePath))); + const minerConfigPath = path.join(devnetConfigPath, 'ckb-miner.toml'); + const minerConfigWasMissing = !fs.existsSync(minerConfigPath); + + // Daemon mode creates data/logs before the child starts. A directory-only + // check therefore mistakes a fresh install for an initialized chain. Check + // the files CKB actually needs instead, and repair an incomplete directory. + if (!isInitialized) { + await copyFilesWithExclusion(devnetSourcePath, devnetConfigPath, ['data'], false); logger.debug(`init devnet config folder: ${devnetConfigPath}`); // copy and edit ckb-miner.toml const minerToml = path.join(devnetSourcePath, 'ckb-miner.toml'); - const newMinerToml = path.join(devnetConfigPath, 'ckb-miner.toml'); - // Read the content of the ckb-miner.toml file - const data = fs.readFileSync(minerToml, 'utf8'); - // Replace the URL - const modifiedData = data.replace('http://ckb:8114/', settings.devnet.rpcUrl); - // Write the modified content back to the file - fs.writeFileSync(newMinerToml, modifiedData, 'utf8'); + if (minerConfigWasMissing) { + // Read the content of the ckb-miner.toml file + const data = fs.readFileSync(minerToml, 'utf8'); + // Replace the URL + const modifiedData = data.replace('http://ckb:8114/', settings.devnet.rpcUrl); + // Write the modified content back to the file + fs.writeFileSync(minerConfigPath, modifiedData, 'utf8'); + } } } diff --git a/src/node/install.ts b/src/node/install.ts index e681d9fb..5dcd10a9 100644 --- a/src/node/install.ts +++ b/src/node/install.ts @@ -65,6 +65,7 @@ export async function downloadCKBBinaryAndUnzip(version: string) { logger.info(`CKB ${version} installed successfully.`); } catch (error: unknown) { logger.error('Error installing dependency binary:', (error as Error).message); + throw error; } } diff --git a/src/sdk/ckb.ts b/src/sdk/ckb.ts index 336f46c0..f498e8b3 100644 --- a/src/sdk/ckb.ts +++ b/src/sdk/ckb.ts @@ -60,6 +60,12 @@ export interface UdtIssueOption { toAddress?: string; } +export interface UdtIssueResult { + txHash: HexString; + typeArgs: HexString; + receiver: string; +} + export interface UdtDestroyOption { privateKey: HexString; kind: UdtKind; @@ -393,7 +399,7 @@ export class CKB { return txHash; } - async udtIssue({ privateKey, kind, amount, typeArgs, toAddress }: UdtIssueOption): Promise { + async udtIssue({ privateKey, kind, amount, typeArgs, toAddress }: UdtIssueOption): Promise { const signer = this.buildSigner(privateKey); const signerAddress = await signer.getAddressObjSecp256k1(); const to = toAddress ? await ccc.Address.fromString(toAddress, this.client) : signerAddress; @@ -405,7 +411,7 @@ export class CKB { logger.warn('SUDT type args are derived from the issuer lock hash; --type-args is ignored'); } const issuerLockHash = signerAddress.script.hash(); - resolvedTypeArgs = ('0x' + issuerLockHash.slice(2, 42)) as HexString; + resolvedTypeArgs = issuerLockHash as HexString; } else { if (typeArgs) { resolvedTypeArgs = validateUdtTypeArgs(kind, typeArgs); @@ -435,7 +441,7 @@ export class CKB { await tx.completeInputsByCapacity(signer); await tx.completeFeeBy(signer, this.feeRate); const txHash = await signer.sendTransaction(tx); - return txHash; + return { txHash, typeArgs: resolvedTypeArgs, receiver: to.toString() }; } async udtDestroy( diff --git a/src/tools/ckb-tui.ts b/src/tools/ckb-tui.ts index 8b3ba785..3d46ab9d 100644 --- a/src/tools/ckb-tui.ts +++ b/src/tools/ckb-tui.ts @@ -14,6 +14,17 @@ const EXTRACT_TIMEOUT_MS = 60_000; // Strict semver regex: v.. (no leading zeros on digits) const STRICT_VERSION_REGEX = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +// Independently pinned digests for the default release. Keeping these in +// offckb makes the default installation verifiable even though ckb-tui v0.1.3 +// did not upload a checksums-sha256.txt asset. +const KNOWN_SHA256: Record> = { + 'v0.1.3': { + 'ckb-tui-with-node-linux-amd64.tar.gz': '33455cefe2c016149fa8fa3abde7960b348d4606afef9279d787ac8a8b59956f', + 'ckb-tui-with-node-macos-aarch64.tar.gz': 'de18107ec179ced03608da956013e38ae82e6c1fae588f12c17d138ee6ee072c', + 'ckb-tui-with-node-windows-amd64.zip': '749d8e09fd5d23fc8af12892b7d197add5aae004f7438678023e4a973f3fd58b', + }, +}; + export class CKBTui { private static binaryPath: string | null = null; @@ -152,7 +163,7 @@ export class CKBTui { throw new Error(`curl exited with code ${curlResult.status}`); } - // 2. Verify checksum (best-effort: warns if checksum file is unavailable) + // 2. Verify checksum. Installation fails closed if no trusted digest exists. this.verifyChecksum(version, assetName, archivePath); // 3. Extract to temp directory @@ -199,14 +210,14 @@ export class CKBTui { } } - /** - * Best-effort SHA-256 checksum verification. - * Downloads the checksum file published alongside the release asset and - * verifies the downloaded archive. Logs a warning (but does not fail) if - * the checksum file is unavailable — this maintains compatibility while - * the upstream project adopts checksum publishing. - */ + /** Verify against a pinned digest or an upstream checksum manifest. */ private static verifyChecksum(version: string, assetName: string, archivePath: string): void { + const pinnedHash = KNOWN_SHA256[version]?.[assetName]; + if (pinnedHash) { + this.assertChecksum(archivePath, assetName, pinnedHash); + return; + } + const checksumUrl = `https://github.com/Officeyutong/ckb-tui/releases/download/${version}/checksums-sha256.txt`; const checksumPath = path.join(path.dirname(archivePath), 'checksums-sha256.txt'); @@ -217,11 +228,10 @@ export class CKBTui { }); if (fetchResult.status !== 0) { - logger.warn( - `SHA-256 checksum file not available for version ${version}. ` + - 'Skipping integrity verification. Consider asking the upstream maintainer to publish checksum files.', + throw new Error( + `No trusted SHA-256 checksum is available for ckb-tui ${version} (${assetName}). ` + + 'Refusing to install an unverified binary.', ); - return; } try { @@ -229,21 +239,9 @@ export class CKBTui { const expectedHash = this.parseChecksumFile(checksumContent, assetName); if (!expectedHash) { - logger.warn(`Checksum entry for "${assetName}" not found in checksums file. Skipping verification.`); - return; + throw new Error(`Checksum entry for "${assetName}" was not found; refusing to install an unverified binary.`); } - - const actualHash = crypto.createHash('sha256').update(fs.readFileSync(archivePath)).digest('hex'); - - if (actualHash !== expectedHash) { - throw new Error( - `SHA-256 checksum mismatch for ${assetName}.\n` + - `Expected: ${expectedHash}\nActual: ${actualHash}\n` + - 'The downloaded file may be corrupted or tampered with.', - ); - } - - logger.info('SHA-256 checksum verified successfully.'); + this.assertChecksum(archivePath, assetName, expectedHash); } finally { try { fs.unlinkSync(checksumPath); @@ -253,6 +251,18 @@ export class CKBTui { } } + private static assertChecksum(archivePath: string, assetName: string, expectedHash: string): void { + const actualHash = crypto.createHash('sha256').update(fs.readFileSync(archivePath)).digest('hex'); + if (actualHash !== expectedHash) { + throw new Error( + `SHA-256 checksum mismatch for ${assetName}.\n` + + `Expected: ${expectedHash}\nActual: ${actualHash}\n` + + 'The downloaded file may be corrupted or tampered with.', + ); + } + logger.info('SHA-256 checksum verified successfully.'); + } + /** * Parse a standard SHA-256 checksum file (format: " " per line) * and return the hex hash for the given asset name, or null if not found. diff --git a/src/util/fork-safety.ts b/src/util/fork-safety.ts new file mode 100644 index 00000000..1deb1b2e --- /dev/null +++ b/src/util/fork-safety.ts @@ -0,0 +1,26 @@ +import accountConfig from '../../account/account.json'; +import { ckbDevnetMinerAccount } from '../cfg/account'; +import { readSettings } from '../cfg/setting'; +import { readForkState } from '../devnet/fork'; +import { Network } from '../type/base'; +import { logger } from './logger'; + +const BUILT_IN_DEV_KEYS = new Set( + [...accountConfig.map((account) => account.privkey), ckbDevnetMinerAccount.privkey].map((key) => key.toLowerCase()), +); + +export function warnIfMainnetForkSigning(network: Network, privateKey: string): void { + if (network !== Network.devnet) return; + + const settings = readSettings(); + if (readForkState(settings.devnet.configPath)?.source !== 'mainnet') return; + + logger.warn([ + 'MAINNET FORK REPLAY RISK: CKB transactions have no chain id.', + 'A transaction spending cells copied from Mainnet can also be valid on Mainnet.', + 'Use only built-in dev keys and fork-mined cells unless you explicitly accept that risk.', + ]); + if (!BUILT_IN_DEV_KEYS.has(privateKey.trim().toLowerCase())) { + logger.warn('A non-built-in private key is being used on a Mainnet fork. Verify every input before signing.'); + } +} diff --git a/src/util/fs.ts b/src/util/fs.ts index b68d96e6..84544966 100644 --- a/src/util/fs.ts +++ b/src/util/fs.ts @@ -48,20 +48,26 @@ export function copyFileSync(source: string, target: string) { fs.writeFileSync(targetFile, fs.readFileSync(source)); } -export async function copyFilesWithExclusion(sourceDir: string, destinationDir: string, excludedFolders: string[]) { +export async function copyFilesWithExclusion( + sourceDir: string, + destinationDir: string, + excludedFolders: string[], + overwrite = true, +) { try { // Ensure the destination directory exists await fs.promises.mkdir(destinationDir, { recursive: true }); // Start copying recursively from the source directory - await copyRecursive(sourceDir, destinationDir, excludedFolders); + await copyRecursive(sourceDir, destinationDir, excludedFolders, overwrite); } catch (error) { logger.error('An error occurred during copying files:', error); + throw error; } } // Function to recursively copy files and directories -export async function copyRecursive(source: string, destination: string, excludedFolders: string[]) { +export async function copyRecursive(source: string, destination: string, excludedFolders: string[], overwrite = true) { // Get a list of all files and directories in the source directory const files = await fs.promises.readdir(source); @@ -80,11 +86,13 @@ export async function copyRecursive(source: string, destination: string, exclude } else { // Ensure destination directory exists before copying await fs.promises.mkdir(destPath, { recursive: true }); - await copyRecursive(sourcePath, destPath, excludedFolders); + await copyRecursive(sourcePath, destPath, excludedFolders, overwrite); } } else { // Otherwise, copy the file - await fs.promises.copyFile(sourcePath, destPath); + if (overwrite || !fs.existsSync(destPath)) { + await fs.promises.copyFile(sourcePath, destPath); + } } } } diff --git a/src/util/logger.ts b/src/util/logger.ts index 05ec4b36..0a76941d 100644 --- a/src/util/logger.ts +++ b/src/util/logger.ts @@ -21,11 +21,17 @@ interface LoggerOptions { transports?: winston.transport[]; } +export interface CommandResult { + command: string; + [key: string]: unknown; +} + class UnifiedLogger { private logger: winston.Logger; private enableColors: boolean; private showLevel: boolean; private jsonMode: boolean; + private resultEmitted = false; constructor(options: LoggerOptions = {}) { this.enableColors = options.enableColors !== false; @@ -38,8 +44,8 @@ class UnifiedLogger { levels: { error: 0, warn: 1, - info: 2, - success: 3, + success: 2, + info: 3, debug: 4, }, format: winston.format.combine( @@ -63,6 +69,44 @@ class UnifiedLogger { */ setJsonMode(enabled: boolean) { this.jsonMode = enabled; + // In machine mode stdout is reserved for command result records. Progress + // and diagnostics remain NDJSON, but go to stderr so callers can parse one + // stable stdout object without filtering human-oriented logs. + for (const transport of this.logger.transports) { + if (transport instanceof winston.transports.Console) { + const consoleTransport = transport as unknown as { stderrLevels: Record }; + consoleTransport.stderrLevels = Object.fromEntries( + (enabled ? Object.keys(levelColors) : ['error', 'warn']).map((level) => [level, true]), + ); + } + } + } + + isJsonMode(): boolean { + return this.jsonMode; + } + + /** Emit one stable, command-level result record for programmatic callers. */ + result(result: CommandResult) { + if (this.jsonMode && !this.resultEmitted) { + this.resultEmitted = true; + process.stdout.write(`${JSON.stringify({ ok: true, ...result })}\n`); + } + } + + hasResult(): boolean { + return this.resultEmitted; + } + + /** Emit a stable error record without exposing internal stack traces. */ + failure(code: string, message: string, details?: unknown) { + if (this.jsonMode) { + process.stderr.write( + `${JSON.stringify({ ok: false, code, message, ...(details == null ? {} : { details }) })}\n`, + ); + return; + } + this.error(message); } /** diff --git a/src/util/private-key.ts b/src/util/private-key.ts new file mode 100644 index 00000000..e7ba4129 --- /dev/null +++ b/src/util/private-key.ts @@ -0,0 +1,25 @@ +import fs from 'fs'; +import path from 'path'; + +export interface PrivateKeyInput { + privkey?: string | null; + privkeyFile?: string | null; +} + +export function resolvePrivateKey(input: PrivateKeyInput, defaultKey?: string): string { + if (input.privkey && input.privkeyFile) { + throw new Error('Use only one of --privkey or --privkey-file.'); + } + if (input.privkey) return input.privkey; + if (input.privkeyFile) { + const filePath = path.resolve(input.privkeyFile); + try { + return fs.readFileSync(filePath, 'utf8').trim(); + } catch (error) { + throw new Error(`Could not read private key file ${filePath}: ${(error as Error).message}`); + } + } + if (process.env.OFFCKB_PRIVATE_KEY) return process.env.OFFCKB_PRIVATE_KEY; + if (defaultKey) return defaultKey; + throw new Error('--privkey, --privkey-file, or OFFCKB_PRIVATE_KEY is required!'); +} diff --git a/src/util/validator.ts b/src/util/validator.ts index fa0021e8..5f7b4620 100644 --- a/src/util/validator.ts +++ b/src/util/validator.ts @@ -1,7 +1,6 @@ import path from 'path'; import fs from 'fs'; import { Network, HexString, UdtKind } from '../type/base'; -import { logger } from './logger'; export function validateTypescriptWorkspace() { const cwd = process.cwd(); @@ -55,10 +54,9 @@ export function validateNetworkOpt(network: string) { } if (network === Network.mainnet) { - logger.info( + throw new Error( 'Mainnet not support yet. Please use CKB-CLI to operate on mainnet for better security. Check https://github.com/nervosnetwork/ckb-cli', ); - process.exit(1); } } @@ -131,9 +129,12 @@ const U128_MAX = (BigInt(1) << BigInt(128)) - BigInt(1); export function validateUdtAmount(amount: string): bigint { if (!/^\d+$/.test(amount)) { - throw new Error(`invalid UDT amount "${amount}", must be a non-negative decimal integer`); + throw new Error(`invalid UDT amount "${amount}", must be a positive decimal integer`); } const value = BigInt(amount); + if (value === BigInt(0)) { + throw new Error('invalid UDT amount "0", must be greater than zero'); + } if (value > U128_MAX) { throw new Error(`UDT amount exceeds 128-bit max: ${amount}`); } @@ -152,8 +153,8 @@ export function validateHexString(value: string, name: string): HexString { export function validateUdtTypeArgs(kind: UdtKind, typeArgs: string): HexString { const hex = validateHexString(typeArgs, 'type args'); const byteLength = (hex.length - 2) / 2; - if (kind === 'sudt' && byteLength !== 20) { - throw new Error(`invalid SUDT type args length: expected 20 bytes, got ${byteLength}`); + if (kind === 'sudt' && byteLength !== 32) { + throw new Error(`invalid SUDT type args length: expected 32 bytes, got ${byteLength}`); } if (kind === 'xudt' && byteLength !== 32) { throw new Error(`invalid xUDT type args length: expected 32 bytes, got ${byteLength}`); diff --git a/tests/accounts.test.ts b/tests/accounts.test.ts new file mode 100644 index 00000000..94956fc6 --- /dev/null +++ b/tests/accounts.test.ts @@ -0,0 +1,48 @@ +let mockFork: { source: 'mainnet' | 'testnet' } | null = null; + +jest.mock('../src/cfg/setting', () => ({ + readSettings: () => ({ devnet: { configPath: '/tmp/offckb-devnet', rpcUrl: 'http://127.0.0.1:8114' } }), +})); +jest.mock('../src/devnet/fork', () => ({ readForkState: () => mockFork })); +jest.mock('../src/devnet/readiness', () => ({ warnIfForkIndexerIsBehind: jest.fn().mockResolvedValue(undefined) })); +jest.mock('../src/util/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + result: jest.fn(), + }, +})); + +import { accounts } from '../src/cmd/accounts'; +import { logger } from '../src/util/logger'; + +describe('accounts command', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFork = null; + }); + + it('uses ckt addresses and the genesis funding statement on a pure devnet', async () => { + const result = await accounts(); + expect(result[0].address).toMatch(/^ckt1/); + expect(result[0].privkey).toBeUndefined(); + expect(logger.info).toHaveBeenCalledWith( + expect.arrayContaining([expect.stringContaining('funded with 42_000_000_00000000')]), + ); + }); + + it('re-encodes built-in dev accounts with ckb on a Mainnet fork', async () => { + mockFork = { source: 'mainnet' }; + const result = await accounts(); + expect(result[0].address).toMatch(/^ckb1/); + expect(logger.info).toHaveBeenCalledWith( + expect.arrayContaining([expect.stringContaining('do not include the standard offckb genesis allocation')]), + ); + expect(logger.result).toHaveBeenCalledWith(expect.objectContaining({ context: 'DEVNET (fork of MAINNET)' })); + }); + + it('reveals dev private keys only after an explicit option', async () => { + const result = await accounts({ showPrivateKeys: true }); + expect(result[0].privkey).toMatch(/^0x[0-9a-f]{64}$/); + }); +}); diff --git a/tests/ckb-tui-checksum.test.ts b/tests/ckb-tui-checksum.test.ts new file mode 100644 index 00000000..5199688f --- /dev/null +++ b/tests/ckb-tui-checksum.test.ts @@ -0,0 +1,51 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const mockSpawnSync = jest.fn(); + +jest.mock('child_process', () => ({ + ...jest.requireActual('child_process'), + spawnSync: (...args: unknown[]) => mockSpawnSync(...args), +})); +jest.mock('../src/util/logger', () => ({ + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})); + +import { CKBTui } from '../src/tools/ckb-tui'; + +describe('ckb-tui checksum policy', () => { + let root: string; + let archive: string; + + beforeEach(() => { + jest.clearAllMocks(); + root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-tui-checksum-')); + archive = path.join(root, 'asset.tar.gz'); + fs.writeFileSync(archive, 'not the pinned release'); + }); + + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + + it('enforces the pinned digest for the default release without a network fallback', () => { + expect(() => + (CKBTui as unknown as { verifyChecksum: (...args: string[]) => void }).verifyChecksum( + 'v0.1.3', + 'ckb-tui-with-node-macos-aarch64.tar.gz', + archive, + ), + ).toThrow('checksum mismatch'); + expect(mockSpawnSync).not.toHaveBeenCalled(); + }); + + it('fails closed when an unpinned release has no checksum manifest', () => { + mockSpawnSync.mockReturnValue({ status: 22 }); + expect(() => + (CKBTui as unknown as { verifyChecksum: (...args: string[]) => void }).verifyChecksum( + 'v9.9.9', + 'ckb-tui-with-node-macos-aarch64.tar.gz', + archive, + ), + ).toThrow('Refusing to install an unverified binary'); + }); +}); diff --git a/tests/devnet-config-command.test.ts b/tests/devnet-config-command.test.ts index 507b7646..86ad3c2b 100644 --- a/tests/devnet-config-command.test.ts +++ b/tests/devnet-config-command.test.ts @@ -71,17 +71,13 @@ describe('devnet config command fallback behavior', () => { expect(process.exitCode).toBeUndefined(); }); - it('prints actionable fallback guidance when TTY is unavailable', async () => { + it('throws actionable fallback guidance when TTY is unavailable', async () => { Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); - await devnetConfig(); + await expect(devnetConfig()).rejects.toThrow('offckb devnet config --set ckb.logger.filter=info'); expect(runDevnetConfigTui).not.toHaveBeenCalled(); - expect(logger.error).toHaveBeenCalledWith('Interactive devnet config editor requires a TTY terminal.'); - expect(logger.info).toHaveBeenCalledWith('Use non-interactive mode instead, e.g.:'); - expect(logger.info).toHaveBeenCalledWith(' offckb devnet config --set ckb.logger.filter=info'); - expect(process.exitCode).toBe(1); }); }); @@ -100,13 +96,7 @@ describe('error handling with init hint', () => { }); it('should NOT show init hint for parse errors (--set invalid)', async () => { - await devnetConfig({ set: ['invalid'] }); - - expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Invalid --set item')); - expect(logger.info).not.toHaveBeenCalledWith( - 'Tip: run `offckb node` once to initialize devnet config files first.', - ); - expect(process.exitCode).toBe(1); + await expect(devnetConfig({ set: ['invalid'] })).rejects.toThrow('Invalid --set item'); }); it('should NOT show init hint for unknown field errors', async () => { @@ -114,13 +104,7 @@ describe('error handling with init hint', () => { throw new Error("Unknown field 'unknown.field'."); }); - await devnetConfig({ set: ['unknown.field=value'] }); - - expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Unknown field')); - expect(logger.info).not.toHaveBeenCalledWith( - 'Tip: run `offckb node` once to initialize devnet config files first.', - ); - expect(process.exitCode).toBe(1); + await expect(devnetConfig({ set: ['unknown.field=value'] })).rejects.toThrow('Unknown field'); }); it('should NOT show init hint for validation errors', async () => { @@ -128,13 +112,9 @@ describe('error handling with init hint', () => { throw new Error('Value must be a positive integer.'); }); - await devnetConfig({ set: ['miner.client.poll_interval=0'] }); - - expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Value must be a positive integer')); - expect(logger.info).not.toHaveBeenCalledWith( - 'Tip: run `offckb node` once to initialize devnet config files first.', + await expect(devnetConfig({ set: ['miner.client.poll_interval=0'] })).rejects.toThrow( + 'Value must be a positive integer', ); - expect(process.exitCode).toBe(1); }); it('should show init hint for missing config path (InitializationError)', async () => { @@ -143,11 +123,9 @@ describe('error handling with init hint', () => { throw new InitializationError('Devnet config path does not exist: /missing/path'); }); - await devnetConfig({ set: ['ckb.logger.filter=info'] }); - - expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Devnet config path does not exist')); - expect(logger.info).toHaveBeenCalledWith('Tip: run `offckb node` once to initialize devnet config files first.'); - expect(process.exitCode).toBe(1); + await expect(devnetConfig({ set: ['ckb.logger.filter=info'] })).rejects.toThrow( + 'Devnet config path does not exist: /missing/path Tip: run `offckb node`', + ); }); it('should show init hint for missing ckb.toml (InitializationError)', async () => { @@ -156,11 +134,9 @@ describe('error handling with init hint', () => { throw new InitializationError('Missing file: /path/ckb.toml'); }); - await devnetConfig({ set: ['ckb.logger.filter=info'] }); - - expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Missing file')); - expect(logger.info).toHaveBeenCalledWith('Tip: run `offckb node` once to initialize devnet config files first.'); - expect(process.exitCode).toBe(1); + await expect(devnetConfig({ set: ['ckb.logger.filter=info'] })).rejects.toThrow( + 'Missing file: /path/ckb.toml Tip: run `offckb node`', + ); }); it('should show init hint for missing miner.toml (InitializationError)', async () => { @@ -169,10 +145,8 @@ describe('error handling with init hint', () => { throw new InitializationError('Missing file: /path/ckb-miner.toml'); }); - await devnetConfig({ set: ['ckb.logger.filter=info'] }); - - expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Missing file')); - expect(logger.info).toHaveBeenCalledWith('Tip: run `offckb node` once to initialize devnet config files first.'); - expect(process.exitCode).toBe(1); + await expect(devnetConfig({ set: ['ckb.logger.filter=info'] })).rejects.toThrow( + 'Missing file: /path/ckb-miner.toml Tip: run `offckb node`', + ); }); }); diff --git a/tests/devnet-fork.test.ts b/tests/devnet-fork.test.ts index 0ca738bf..d7b87bd7 100644 --- a/tests/devnet-fork.test.ts +++ b/tests/devnet-fork.test.ts @@ -11,6 +11,12 @@ import { readForkState, writeForkState, ForkState, + copySourceData, + isolateForkCkbConfig, + migrationNeededFromExitCode, + commonCkbSourceDirectories, + discoverCkbSourceDirectories, + resolveForkSourceDirectory, } from '../src/devnet/fork'; import { identifyPublicChainByGenesisHash, MAINNET_GENESIS_HASH, TESTNET_GENESIS_HASH } from '../src/scripts/const'; @@ -151,6 +157,89 @@ describe('patchDevSpecForFork', () => { }); }); +describe('fork data isolation and migration preflight', () => { + let root: string; + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fork-copy-test-')); + }); + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + + it('copies chain state without persisted peers or transient data', () => { + const source = path.join(root, 'source'); + const target = path.join(root, 'target'); + for (const entry of ['db', 'indexer', 'network/peer_store', 'logs', 'tmp']) { + fs.mkdirSync(path.join(source, 'data', entry), { recursive: true }); + fs.writeFileSync(path.join(source, 'data', entry, 'fixture'), entry); + } + + copySourceData(source, target); + + expect(fs.existsSync(path.join(target, 'data', 'db', 'fixture'))).toBe(true); + expect(fs.existsSync(path.join(target, 'data', 'indexer', 'fixture'))).toBe(true); + expect(fs.existsSync(path.join(target, 'data', 'network'))).toBe(false); + expect(fs.existsSync(path.join(target, 'data', 'logs'))).toBe(false); + expect(fs.existsSync(path.join(target, 'data', 'tmp'))).toBe(false); + }); + + it('forces forked nodes into an outbound-isolated network config', () => { + const config = isolateForkCkbConfig({ + network: { bootnodes: ['mainnet-peer'], max_outbound_peers: 8, discovery_local_address: true }, + logger: { filter: 'warn,ckb-script=debug' }, + }) as Record; + + expect(config.network).toEqual( + expect.objectContaining({ + bootnodes: [], + max_outbound_peers: 0, + whitelist_only: true, + discovery_local_address: false, + }), + ); + expect(config.logger.filter).toBe('warn'); + }); + + it('understands ckb migrate --check exit codes', () => { + expect(migrationNeededFromExitCode(0)).toBe(true); + expect(migrationNeededFromExitCode(64)).toBe(false); + expect(() => migrationNeededFromExitCode(1)).toThrow('migrate --check failed'); + expect(() => migrationNeededFromExitCode(null)).toThrow('migrate --check failed'); + }); +}); + +describe('fork source discovery', () => { + let root: string; + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fork-discovery-test-')); + }); + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + + it('includes the standard Neuron source paths for each platform', () => { + expect(commonCkbSourceDirectories('/home/alice', 'darwin')).toContain( + path.join('/home/alice', 'Library', 'Application Support', 'Neuron', 'chains', 'mainnet'), + ); + expect(commonCkbSourceDirectories('/home/alice', 'linux')).toContain( + path.join('/home/alice', '.local', 'share', 'Neuron', 'chains', 'testnet'), + ); + }); + + it('selects the only directory that contains data/db', () => { + const source = path.join(root, 'mainnet'); + fs.mkdirSync(path.join(source, 'data', 'db'), { recursive: true }); + expect(discoverCkbSourceDirectories([source, path.join(root, 'missing')])).toEqual([source]); + expect(resolveForkSourceDirectory(undefined, [source])).toBe(source); + }); + + it('requires an explicit choice when multiple sources are found', () => { + const sources = [path.join(root, 'mainnet'), path.join(root, 'testnet')]; + sources.forEach((source) => fs.mkdirSync(path.join(source, 'data', 'db'), { recursive: true })); + expect(() => resolveForkSourceDirectory(undefined, sources)).toThrow('multiple CKB source directories'); + }); + + it('keeps an explicit path even when discovery finds nothing', () => { + expect(resolveForkSourceDirectory('./custom', [])).toBe(path.resolve('./custom')); + }); +}); + describe('fork state file', () => { let dir: string; beforeEach(() => { diff --git a/tests/fork-safety.test.ts b/tests/fork-safety.test.ts new file mode 100644 index 00000000..8a1f4ce3 --- /dev/null +++ b/tests/fork-safety.test.ts @@ -0,0 +1,40 @@ +let mockFork: { source: 'mainnet' | 'testnet' } | null = null; + +jest.mock('../src/cfg/setting', () => ({ + readSettings: () => ({ devnet: { configPath: '/tmp/offckb-devnet' } }), +})); +jest.mock('../src/devnet/fork', () => ({ readForkState: () => mockFork })); +jest.mock('../src/util/logger', () => ({ logger: { warn: jest.fn() } })); + +import accountConfig from '../account/account.json'; +import { warnIfMainnetForkSigning } from '../src/util/fork-safety'; +import { logger } from '../src/util/logger'; +import { Network } from '../src/type/base'; + +describe('Mainnet fork signing warning', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFork = null; + }); + + it('shows the replay warning for a built-in dev key', () => { + mockFork = { source: 'mainnet' }; + warnIfMainnetForkSigning(Network.devnet, accountConfig[0].privkey); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith(expect.arrayContaining([expect.stringContaining('REPLAY RISK')])); + }); + + it('adds a high-signal warning for an external key', () => { + mockFork = { source: 'mainnet' }; + warnIfMainnetForkSigning(Network.devnet, '0x' + '11'.repeat(32)); + expect(logger.warn).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenLastCalledWith(expect.stringContaining('non-built-in private key')); + }); + + it('does not warn on pure devnet or public Testnet commands', () => { + warnIfMainnetForkSigning(Network.devnet, accountConfig[0].privkey); + mockFork = { source: 'mainnet' }; + warnIfMainnetForkSigning(Network.testnet, accountConfig[0].privkey); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/init-chain.test.ts b/tests/init-chain.test.ts new file mode 100644 index 00000000..f9205c75 --- /dev/null +++ b/tests/init-chain.test.ts @@ -0,0 +1,61 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +let mockConfigPath = ''; + +jest.mock('../src/cfg/setting', () => ({ + packageRootPath: path.resolve(__dirname, '..'), + readSettings: () => ({ + devnet: { configPath: mockConfigPath, rpcUrl: 'http://127.0.0.1:8114' }, + }), +})); + +jest.mock('../src/util/logger', () => ({ + logger: { debug: jest.fn(), error: jest.fn() }, +})); + +import { initChainIfNeeded } from '../src/node/init-chain'; + +describe('initChainIfNeeded', () => { + let root: string; + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-init-chain-')); + mockConfigPath = path.join(root, 'devnet'); + }); + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + + it('repairs a fresh daemon directory that contains only data/logs', async () => { + fs.mkdirSync(path.join(mockConfigPath, 'data', 'logs'), { recursive: true }); + + await initChainIfNeeded(); + + expect(fs.existsSync(path.join(mockConfigPath, 'ckb.toml'))).toBe(true); + expect(fs.existsSync(path.join(mockConfigPath, 'ckb-miner.toml'))).toBe(true); + expect(fs.existsSync(path.join(mockConfigPath, 'specs', 'dev.toml'))).toBe(true); + expect(fs.readFileSync(path.join(mockConfigPath, 'ckb-miner.toml'), 'utf8')).toContain('http://127.0.0.1:8114'); + }); + + it('does not overwrite a complete custom config', async () => { + fs.mkdirSync(path.join(mockConfigPath, 'specs'), { recursive: true }); + fs.writeFileSync(path.join(mockConfigPath, 'ckb.toml'), 'custom-ckb'); + fs.writeFileSync(path.join(mockConfigPath, 'ckb-miner.toml'), 'custom-miner'); + fs.writeFileSync(path.join(mockConfigPath, 'specs', 'dev.toml'), 'custom-spec'); + + await initChainIfNeeded(); + + expect(fs.readFileSync(path.join(mockConfigPath, 'ckb.toml'), 'utf8')).toBe('custom-ckb'); + expect(fs.readFileSync(path.join(mockConfigPath, 'ckb-miner.toml'), 'utf8')).toBe('custom-miner'); + }); + + it('repairs missing files without overwriting partial custom configuration', async () => { + fs.mkdirSync(mockConfigPath, { recursive: true }); + fs.writeFileSync(path.join(mockConfigPath, 'ckb.toml'), 'custom-ckb'); + + await initChainIfNeeded(); + + expect(fs.readFileSync(path.join(mockConfigPath, 'ckb.toml'), 'utf8')).toBe('custom-ckb'); + expect(fs.existsSync(path.join(mockConfigPath, 'ckb-miner.toml'))).toBe(true); + expect(fs.existsSync(path.join(mockConfigPath, 'specs', 'dev.toml'))).toBe(true); + }); +}); diff --git a/tests/logger.test.ts b/tests/logger.test.ts index a8e55280..71e151eb 100644 --- a/tests/logger.test.ts +++ b/tests/logger.test.ts @@ -53,4 +53,32 @@ describe('UnifiedLogger JSON mode', () => { const parsed = JSON.parse(logs[0]); expect(parsed.message).toBe('line one\nline two'); }); + + it('does not filter success messages at the default info level', () => { + const { transport, logs } = createCapturingTransport(); + const log = UnifiedLogger.create({ transports: [transport], showLevel: false }); + log.success('completed'); + expect(logs).toEqual(['completed']); + }); + + it('emits stable command result and failure records in JSON mode', () => { + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + const log = UnifiedLogger.create({ transports: [], jsonMode: true }); + + log.result({ command: 'balance', ckb: '42' }); + log.failure('INVALID_ARGUMENT', 'bad amount'); + + expect(JSON.parse(String(stdout.mock.calls[0][0]))).toEqual({ ok: true, command: 'balance', ckb: '42' }); + expect(JSON.parse(String(stderr.mock.calls[0][0]))).toEqual({ + ok: false, + code: 'INVALID_ARGUMENT', + message: 'bad amount', + }); + expect(log.hasResult()).toBe(true); + log.result({ command: 'duplicate' }); + expect(stdout).toHaveBeenCalledTimes(1); + stdout.mockRestore(); + stderr.mockRestore(); + }); }); diff --git a/tests/node-command.test.ts b/tests/node-command.test.ts index b5c99aad..d55f883a 100644 --- a/tests/node-command.test.ts +++ b/tests/node-command.test.ts @@ -38,6 +38,11 @@ jest.mock('../src/tools/rpc-proxy', () => ({ })), })); +jest.mock('../src/devnet/readiness', () => ({ + checkNodeReadiness: jest.fn().mockResolvedValue({ ready: false, rpcUrl: 'http://127.0.0.1:8114' }), + waitForNodeReady: jest.fn().mockResolvedValue({ ready: true, rpcUrl: 'http://127.0.0.1:8114', nodeTip: 0n }), +})); + jest.mock('../src/cfg/setting', () => ({ readSettings: () => ({ devnet: { @@ -64,6 +69,7 @@ jest.mock('../src/util/logger', () => ({ warn: jest.fn(), error: jest.fn(), debug: jest.fn(), + result: jest.fn(), setJsonMode: jest.fn(), }, })); @@ -120,8 +126,8 @@ describe('node command daemon mode', () => { killSpy.mockRestore(); }); - it('spawns a detached child process without the --daemon flag', () => { - startNode({ network: Network.devnet, daemon: true }); + it('spawns a detached child process without the --daemon flag', async () => { + await startNode({ network: Network.devnet, daemon: true }); expect(mockMkdirSync).toHaveBeenCalledWith(logDir, { recursive: true }); const resolvedScriptPath = path.resolve('/path/to/offckb'); @@ -140,11 +146,13 @@ describe('node command daemon mode', () => { expect(writtenMetadata.scriptPath).toBe(resolvedScriptPath); expect(writtenMetadata.startedAt).toBeDefined(); - expect(logger.success).toHaveBeenCalledWith('CKB devnet daemon started with PID 12345.'); + expect(logger.success).toHaveBeenCalledWith( + 'CKB devnet daemon started with PID 12345 and passed its RPC/proxy health check.', + ); }); - it('warns and ignores daemon flag for non-devnet networks', () => { - startNode({ network: Network.testnet, daemon: true }); + it('warns and ignores daemon flag for non-devnet networks', async () => { + await startNode({ network: Network.testnet, daemon: true }); expect(logger.warn).toHaveBeenCalledWith( 'Daemon mode is only supported for devnet. The daemon flag will be ignored.', @@ -152,18 +160,17 @@ describe('node command daemon mode', () => { expect(mockSpawn).not.toHaveBeenCalled(); }); - it('refuses to start when a daemon is already running', () => { + it('refuses to start when a daemon is already running', async () => { mockReadFileSync.mockReturnValue( JSON.stringify({ pid: 9999, scriptPath: '/path/to/offckb', startedAt: new Date().toISOString() }), ); - startNode({ network: Network.devnet, daemon: true }); + await expect(startNode({ network: Network.devnet, daemon: true })).rejects.toThrow('already running'); - expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('already running')); expect(mockSpawn).not.toHaveBeenCalled(); }); - it('cleans up a stale PID file and starts a new daemon', () => { + it('cleans up a stale PID file and starts a new daemon', async () => { mockReadFileSync.mockReturnValue( JSON.stringify({ pid: 9999, scriptPath: '/path/to/offckb', startedAt: new Date().toISOString() }), ); @@ -176,41 +183,36 @@ describe('node command daemon mode', () => { return true; }); - startNode({ network: Network.devnet, daemon: true }); + await startNode({ network: Network.devnet, daemon: true }); expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); expect(mockSpawn).toHaveBeenCalled(); }); - it('errors and cleans up when spawn fails synchronously', () => { + it('errors and cleans up when spawn fails synchronously', async () => { mockSpawn.mockImplementation(() => { throw new Error('spawn error'); }); - startNode({ network: Network.devnet, daemon: true }); - - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining('Failed to spawn daemon process'), - expect.any(Error), + await expect(startNode({ network: Network.devnet, daemon: true })).rejects.toThrow( + 'Failed to spawn daemon process', ); expect(mockCloseSync).toHaveBeenCalledWith(3); expect(mockWriteFileSync).not.toHaveBeenCalled(); }); - it('errors when the spawned child has no PID', () => { + it('errors when the spawned child has no PID', async () => { mockSpawn.mockReturnValue({ pid: undefined, unref: jest.fn(), on: jest.fn(), }); - startNode({ network: Network.devnet, daemon: true }); - - expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('no PID returned')); + await expect(startNode({ network: Network.devnet, daemon: true })).rejects.toThrow('no PID returned'); expect(mockWriteFileSync).not.toHaveBeenCalled(); }); - it('handles backward-compatible plain PID files', () => { + it('handles backward-compatible plain PID files', async () => { mockReadFileSync.mockReturnValue('9999'); killSpy.mockImplementation((pid: number, signal?: NodeJS.Signals | number) => { if (signal === 0) { @@ -221,7 +223,7 @@ describe('node command daemon mode', () => { return true; }); - startNode({ network: Network.devnet, daemon: true }); + await startNode({ network: Network.devnet, daemon: true }); expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); expect(mockSpawn).toHaveBeenCalled(); @@ -289,8 +291,7 @@ describe('node command stop', () => { it('errors when the PID file contains an invalid PID', async () => { mockReadFileSync.mockReturnValue('not-a-number'); - await stopNode(); - expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Invalid PID')); + await expect(stopNode()).rejects.toThrow('Invalid PID'); expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); }); @@ -334,14 +335,13 @@ describe('node command stop', () => { return undefined as unknown as ReturnType; }); - await stopNode(); + await expect(stopNode()).rejects.toThrow('does not appear to be the offckb daemon'); - expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('does not appear to be the offckb daemon')); expect(killSpy).not.toHaveBeenCalledWith(expect.any(Number), 'SIGTERM'); expect(mockUnlinkSync).not.toHaveBeenCalled(); }); - it('cleans up the PID file when SIGTERM fails with an unknown error', async () => { + it('preserves the PID file when SIGTERM fails with a permission error', async () => { killSpy.mockImplementation((pid: number, signal?: NodeJS.Signals | number) => { if (signal === 0) { return true; @@ -351,10 +351,9 @@ describe('node command stop', () => { throw err; }); - await stopNode(); + await expect(stopNode()).rejects.toThrow('Permission denied'); - expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Permission denied')); - expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); + expect(mockUnlinkSync).not.toHaveBeenCalledWith(pidFile); }); it('cleans up the PID file when the process disappears between alive-check and SIGTERM', async () => { diff --git a/tests/node-supervisor.test.ts b/tests/node-supervisor.test.ts new file mode 100644 index 00000000..a72e280f --- /dev/null +++ b/tests/node-supervisor.test.ts @@ -0,0 +1,93 @@ +import { EventEmitter } from 'events'; + +const mockSpawn = jest.fn(); +const mockProxyStart = jest.fn(); +const mockProxyStop = jest.fn(); + +jest.mock('child_process', () => ({ + ...jest.requireActual('child_process'), + spawn: (...args: unknown[]) => mockSpawn(...args), +})); +jest.mock('../src/node/install', () => ({ installCKBBinary: jest.fn().mockResolvedValue(undefined) })); +jest.mock('../src/node/init-chain', () => ({ initChainIfNeeded: jest.fn().mockResolvedValue(undefined) })); +jest.mock('../src/cfg/setting', () => ({ + readSettings: () => ({ + bins: { defaultCKBVersion: '0.207.0' }, + devnet: { + configPath: '/tmp/offckb-devnet', + dataPath: '/tmp/offckb-devnet/data', + rpcUrl: 'http://127.0.0.1:8114', + rpcProxyPort: 28114, + }, + }), + getCKBBinaryPath: () => '/tmp/ckb', +})); +jest.mock('../src/devnet/fork', () => ({ + readForkState: jest.fn().mockReturnValue(null), + markForkFirstRunComplete: jest.fn(), +})); +jest.mock('../src/devnet/readiness', () => ({ + checkNodeReadiness: jest.fn(), + waitForNodeReady: jest.fn().mockResolvedValue({ ready: true, rpcUrl: 'http://127.0.0.1:8114' }), +})); +jest.mock('../src/tools/rpc-proxy', () => ({ + createRPCProxy: () => ({ start: mockProxyStart, stop: mockProxyStop }), +})); +jest.mock('../src/util/logger', () => ({ + logger: { + success: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + result: jest.fn(), + }, +})); + +import { nodeDevnet } from '../src/cmd/node'; + +class FakeChild extends EventEmitter { + stdout = new EventEmitter(); + stderr = new EventEmitter(); + killed = false; + kill = jest.fn((_signal?: NodeJS.Signals) => { + this.killed = true; + return true; + }); +} + +describe('foreground devnet supervisor', () => { + const originalExitCode = process.exitCode; + let ckb: FakeChild; + let miner: FakeChild; + + beforeEach(() => { + jest.clearAllMocks(); + process.exitCode = undefined; + ckb = new FakeChild(); + miner = new FakeChild(); + mockSpawn.mockReturnValueOnce(ckb).mockImplementationOnce(() => { + process.nextTick(() => miner.emit('spawn')); + return miner; + }); + }); + + afterAll(() => { + process.exitCode = originalExitCode; + }); + + it('stops miner and proxy when CKB exits', async () => { + await nodeDevnet({}); + ckb.emit('exit', 2, null); + expect(miner.kill).toHaveBeenCalledWith('SIGTERM'); + expect(mockProxyStop).toHaveBeenCalled(); + expect(process.exitCode).toBe(2); + }); + + it('stops CKB and proxy when the miner exits', async () => { + await nodeDevnet({}); + miner.emit('exit', 1, null); + expect(ckb.kill).toHaveBeenCalledWith('SIGTERM'); + expect(mockProxyStop).toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/tests/private-key.test.ts b/tests/private-key.test.ts new file mode 100644 index 00000000..3e0cef93 --- /dev/null +++ b/tests/private-key.test.ts @@ -0,0 +1,37 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { resolvePrivateKey } from '../src/util/private-key'; + +describe('resolvePrivateKey', () => { + const originalEnv = process.env.OFFCKB_PRIVATE_KEY; + afterEach(() => { + if (originalEnv == null) delete process.env.OFFCKB_PRIVATE_KEY; + else process.env.OFFCKB_PRIVATE_KEY = originalEnv; + }); + + it('reads a key from a file without putting it in argv', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-key-')); + const keyFile = path.join(root, 'key'); + fs.writeFileSync(keyFile, '0x1234\n'); + expect(resolvePrivateKey({ privkeyFile: keyFile })).toBe('0x1234'); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('supports OFFCKB_PRIVATE_KEY and rejects ambiguous sources', () => { + process.env.OFFCKB_PRIVATE_KEY = '0xabcd'; + expect(resolvePrivateKey({})).toBe('0xabcd'); + expect(() => resolvePrivateKey({ privkey: '0x1', privkeyFile: 'key' })).toThrow('only one'); + }); + + it('fails with an actionable message when no source is available', () => { + delete process.env.OFFCKB_PRIVATE_KEY; + expect(() => resolvePrivateKey({})).toThrow('OFFCKB_PRIVATE_KEY'); + }); + + it('uses a caller-provided dev key only when no explicit source is set', () => { + delete process.env.OFFCKB_PRIVATE_KEY; + expect(resolvePrivateKey({}, '0xdefault')).toBe('0xdefault'); + expect(resolvePrivateKey({ privkey: '0xexplicit' }, '0xdefault')).toBe('0xexplicit'); + }); +}); diff --git a/tests/readiness-warning.test.ts b/tests/readiness-warning.test.ts new file mode 100644 index 00000000..f96fced3 --- /dev/null +++ b/tests/readiness-warning.test.ts @@ -0,0 +1,42 @@ +const mockCallJsonRpc = jest.fn(); +let mockFork: { source: 'mainnet' | 'testnet' } | null = { source: 'mainnet' }; + +jest.mock('../src/util/json-rpc', () => ({ + callJsonRpc: (...args: unknown[]) => mockCallJsonRpc(...args), +})); +jest.mock('../src/cfg/setting', () => ({ + readSettings: () => ({ devnet: { configPath: '/tmp/offckb-devnet', rpcUrl: 'http://127.0.0.1:8114' } }), +})); +jest.mock('../src/devnet/fork', () => ({ readForkState: () => mockFork })); +jest.mock('../src/util/logger', () => ({ logger: { warn: jest.fn() } })); + +import { warnIfForkIndexerIsBehind } from '../src/devnet/readiness'; +import { logger } from '../src/util/logger'; +import { Network } from '../src/type/base'; + +describe('fork Indexer warning', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFork = { source: 'mainnet' }; + mockCallJsonRpc.mockImplementation(async (_url: string, method: string) => { + if (method === 'local_node_info') return { version: '0.207.0' }; + if (method === 'get_tip_block_number') return '0x64'; + if (method === 'get_indexer_tip') return { block_number: '0x5a' }; + if (method === 'get_peers') return []; + throw new Error(method); + }); + }); + + it('warns with the exact lag before cell-dependent operations', async () => { + await warnIfForkIndexerIsBehind(Network.devnet); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('10 blocks behind')); + }); + + it('does nothing outside a forked devnet', async () => { + mockFork = null; + await warnIfForkIndexerIsBehind(Network.devnet); + await warnIfForkIndexerIsBehind(Network.testnet); + expect(mockCallJsonRpc).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/readiness.test.ts b/tests/readiness.test.ts new file mode 100644 index 00000000..361eed55 --- /dev/null +++ b/tests/readiness.test.ts @@ -0,0 +1,50 @@ +const mockCallJsonRpc = jest.fn(); + +jest.mock('../src/util/json-rpc', () => ({ + callJsonRpc: (...args: unknown[]) => mockCallJsonRpc(...args), +})); + +import { checkNodeReadiness } from '../src/devnet/readiness'; + +describe('checkNodeReadiness', () => { + beforeEach(() => mockCallJsonRpc.mockReset()); + + it('reports node, indexer lag and peer count', async () => { + mockCallJsonRpc.mockImplementation(async (_url: string, method: string) => { + if (method === 'local_node_info') return { version: '0.207.0' }; + if (method === 'get_tip_block_number') return '0x64'; + if (method === 'get_indexer_tip') return { block_number: '0x5a' }; + if (method === 'get_peers') return [{}, {}]; + throw new Error(method); + }); + + await expect(checkNodeReadiness('http://127.0.0.1:8114')).resolves.toEqual({ + ready: true, + rpcUrl: 'http://127.0.0.1:8114', + version: '0.207.0', + nodeTip: 100n, + indexerTip: 90n, + indexerLag: 10n, + peers: 2, + }); + }); + + it('does not confuse an open proxy with a healthy upstream node', async () => { + mockCallJsonRpc.mockRejectedValue(new Error('Proxy error')); + const result = await checkNodeReadiness('http://127.0.0.1:28114'); + expect(result.ready).toBe(false); + expect(result.error).toContain('Proxy error'); + }); + + it('keeps node readiness when the optional Indexer and Net modules are unavailable', async () => { + mockCallJsonRpc.mockImplementation(async (_url: string, method: string) => { + if (method === 'local_node_info') return { version: '0.207.0' }; + if (method === 'get_tip_block_number') return '0x1'; + throw new Error('module disabled'); + }); + const result = await checkNodeReadiness('http://127.0.0.1:8114'); + expect(result.ready).toBe(true); + expect(result.indexerTip).toBeUndefined(); + expect(result.peers).toBeUndefined(); + }); +}); diff --git a/tests/sdk/ckb.udt.test.ts b/tests/sdk/ckb.udt.test.ts index 3c132470..33c60b47 100644 --- a/tests/sdk/ckb.udt.test.ts +++ b/tests/sdk/ckb.udt.test.ts @@ -116,9 +116,9 @@ describe('CKB SDK UDT helpers', () => { it('should build SUDT type script from system scripts', async () => { const ckb = createCKB(); - const type = await ckb.buildUdtTypeScript('sudt', '0x' + '12'.repeat(20)); + const type = await ckb.buildUdtTypeScript('sudt', '0x' + '12'.repeat(32)); expect(type.codeHash).toBe('0x' + 'c3'.repeat(32)); - expect(type.args).toBe('0x' + '12'.repeat(20)); + expect(type.args).toBe('0x' + '12'.repeat(32)); }); }); @@ -173,7 +173,7 @@ describe('CKB SDK UDT helpers', () => { privateKey: '0x' + '11'.repeat(32), kind: 'sudt', amount: '100', - typeArgs: '0x' + '12'.repeat(20), + typeArgs: '0x' + '12'.repeat(32), }); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('--type-args is ignored')); diff --git a/tests/status.test.ts b/tests/status.test.ts new file mode 100644 index 00000000..5a532ab4 --- /dev/null +++ b/tests/status.test.ts @@ -0,0 +1,58 @@ +const mockRun = jest.fn(); +const mockCheckNodeReadiness = jest.fn(); + +jest.mock('../src/tools/ckb-tui', () => ({ CKBTui: { run: (...args: unknown[]) => mockRun(...args) } })); +jest.mock('../src/devnet/readiness', () => ({ + checkNodeReadiness: (...args: unknown[]) => mockCheckNodeReadiness(...args), +})); +jest.mock('../src/cfg/setting', () => ({ + readSettings: () => ({ + devnet: { rpcProxyPort: 28114 }, + testnet: { rpcProxyPort: 38114 }, + mainnet: { rpcProxyPort: 48114 }, + }), +})); + +import { status } from '../src/cmd/status'; +import { Network } from '../src/type/base'; + +describe('status command', () => { + const originalStdoutTTY = process.stdout.isTTY; + const originalStdinTTY = process.stdin.isTTY; + + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true }); + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true }); + }); + + afterAll(() => { + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: originalStdoutTTY }); + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: originalStdinTTY }); + }); + + it('launches ckb-tui only after a real JSON-RPC health check', async () => { + mockCheckNodeReadiness.mockResolvedValue({ ready: true }); + mockRun.mockReturnValue({ status: 0 }); + await status({ network: Network.devnet }); + expect(mockCheckNodeReadiness).toHaveBeenCalledWith('http://127.0.0.1:28114'); + expect(mockRun).toHaveBeenCalledWith(['-r', 'http://127.0.0.1:28114']); + }); + + it('rejects a listening proxy whose upstream node is dead', async () => { + mockCheckNodeReadiness.mockResolvedValue({ ready: false, error: 'upstream refused' }); + await expect(status({ network: Network.devnet })).rejects.toThrow('upstream refused'); + expect(mockRun).not.toHaveBeenCalled(); + }); + + it('rejects non-interactive use', async () => { + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: false }); + await expect(status({ network: Network.devnet })).rejects.toThrow('interactive terminal'); + }); + + it('turns a non-zero ckb-tui exit into a command failure', async () => { + mockCheckNodeReadiness.mockResolvedValue({ ready: true }); + mockRun.mockReturnValue({ status: 7 }); + await expect(status({ network: Network.devnet })).rejects.toThrow('ckb-tui exited with code 7'); + }); +}); diff --git a/tests/udt.test.ts b/tests/udt.test.ts index 00050122..f1b851c0 100644 --- a/tests/udt.test.ts +++ b/tests/udt.test.ts @@ -5,7 +5,7 @@ import { udtIssue, udtDestroy } from '../src/cmd/udt'; import { CKB } from '../src/sdk/ckb'; import { logger } from '../src/util/logger'; -const mockTypeArgs = '0x' + 'ab'.repeat(20); +const mockTypeArgs = '0x' + 'ab'.repeat(32); const mockUdtType = { codeHash: '0x1234', hashType: 'type', args: mockTypeArgs }; jest.mock('../src/sdk/ckb', () => { @@ -24,7 +24,11 @@ jest.mock('../src/sdk/ckb', () => { }, ]), udtTransfer: jest.fn().mockResolvedValue('0xtxhash'), - udtIssue: jest.fn().mockResolvedValue('0xissuehash'), + udtIssue: jest.fn().mockResolvedValue({ + txHash: '0xissuehash', + typeArgs: mockTypeArgs, + receiver: 'ckt1receiver', + }), udtDestroy: jest.fn().mockResolvedValue('0xdestroyhash'), })), }; @@ -37,12 +41,17 @@ jest.mock('../src/util/logger', () => ({ warn: jest.fn(), success: jest.fn(), debug: jest.fn(), + result: jest.fn(), }, })); -function mockProcessExit() { - return jest.spyOn(process, 'exit').mockImplementation(() => undefined as never); -} +jest.mock('../src/devnet/readiness', () => ({ + warnIfForkIndexerIsBehind: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../src/util/fork-safety', () => ({ + warnIfMainnetForkSigning: jest.fn(), +})); describe('balance command', () => { beforeEach(() => { @@ -50,7 +59,6 @@ describe('balance command', () => { }); it('should print CKB and detected UDT balances by default', async () => { - const exitSpy = mockProcessExit(); await balanceOf('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', { network: Network.devnet, }); @@ -61,12 +69,10 @@ describe('balance command', () => { expect(logger.info).toHaveBeenCalledWith('CKB: 1234.5678'); expect(logger.info).toHaveBeenCalledWith('UDT:'); expect(logger.info).toHaveBeenCalledWith(` sudt (args=${mockTypeArgs}): 1000`); - expect(exitSpy).toHaveBeenCalledWith(0); - exitSpy.mockRestore(); + expect(logger.result).toHaveBeenCalledWith(expect.objectContaining({ command: 'balance', ckb: '1234.5678' })); }); it('should filter UDT balances by kind and type args', async () => { - const exitSpy = mockProcessExit(); await balanceOf('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', { network: Network.devnet, udtKind: 'sudt', @@ -76,12 +82,9 @@ describe('balance command', () => { const ckbInstance = (CKB as jest.Mock).mock.results[0].value; expect(ckbInstance.detectUdtBalances).toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith(` sudt (args=${mockTypeArgs}): 1000`); - expect(exitSpy).toHaveBeenCalledWith(0); - exitSpy.mockRestore(); }); it('should skip UDT scan with --no-udt', async () => { - const exitSpy = mockProcessExit(); await balanceOf('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', { network: Network.devnet, udt: false, @@ -90,8 +93,6 @@ describe('balance command', () => { const ckbInstance = (CKB as jest.Mock).mock.results[0].value; expect(ckbInstance.balance).toHaveBeenCalled(); expect(ckbInstance.detectUdtBalances).not.toHaveBeenCalled(); - expect(exitSpy).toHaveBeenCalledWith(0); - exitSpy.mockRestore(); }); }); @@ -130,9 +131,9 @@ describe('transfer command', () => { await expect( transfer('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', '100', { network: Network.devnet, - udtTypeArgs: '0xabcd', + udtTypeArgs: mockTypeArgs, }), - ).rejects.toThrow('--privkey is required!'); + ).rejects.toThrow('--privkey-file'); }); }); @@ -149,7 +150,7 @@ describe('udt command', () => { udtKind: 'sudt', privkey: '', }), - ).rejects.toThrow('--privkey is required!'); + ).rejects.toThrow('--privkey-file'); }); it('should issue UDT with privkey', async () => { @@ -174,7 +175,7 @@ describe('udt command', () => { typeArgs: mockTypeArgs, privkey: '', }), - ).rejects.toThrow('--privkey is required!'); + ).rejects.toThrow('--privkey-file'); }); it('should destroy UDT with privkey', async () => { diff --git a/tests/validator.test.ts b/tests/validator.test.ts index f216139f..9006bf27 100644 --- a/tests/validator.test.ts +++ b/tests/validator.test.ts @@ -78,8 +78,7 @@ describe('UDT validation helpers', () => { }); describe('validateUdtAmount', () => { - it('should accept non-negative decimal integers', () => { - expect(validateUdtAmount('0')).toBe(0n); + it('should accept positive decimal integers', () => { expect(validateUdtAmount('1')).toBe(1n); expect(validateUdtAmount('123456789012345678901234567890')).toBe(123456789012345678901234567890n); }); @@ -91,6 +90,7 @@ describe('UDT validation helpers', () => { expect(() => validateUdtAmount('1e10')).toThrow('invalid UDT amount'); expect(() => validateUdtAmount('')).toThrow('invalid UDT amount'); expect(() => validateUdtAmount('abc')).toThrow('invalid UDT amount'); + expect(() => validateUdtAmount('0')).toThrow('must be greater than zero'); }); it('should reject amounts exceeding u128 max', () => { @@ -102,7 +102,7 @@ describe('UDT validation helpers', () => { describe('validateUdtTypeArgs', () => { it('should accept valid SUDT type args', () => { - const args = '0x' + '12'.repeat(20); + const args = '0x' + '12'.repeat(32); expect(validateUdtTypeArgs('sudt', args)).toBe(args); }); @@ -118,7 +118,7 @@ describe('UDT validation helpers', () => { it('should reject wrong lengths', () => { expect(() => validateUdtTypeArgs('sudt', '0x' + '12'.repeat(19))).toThrow('invalid SUDT type args length'); - expect(() => validateUdtTypeArgs('sudt', '0x' + '12'.repeat(32))).toThrow('invalid SUDT type args length'); + expect(() => validateUdtTypeArgs('sudt', '0x' + '12'.repeat(20))).toThrow('invalid SUDT type args length'); expect(() => validateUdtTypeArgs('xudt', '0x' + '12'.repeat(31))).toThrow('invalid xUDT type args length'); }); }); From 71f10f138d476c0b800fb6427958d3997e5f58db Mon Sep 17 00:00:00 2001 From: RetricSu Date: Mon, 20 Jul 2026 15:43:39 +0800 Subject: [PATCH 2/8] fix fork copy isolation on windows --- src/devnet/fork.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/devnet/fork.ts b/src/devnet/fork.ts index f9fec28e..e0e453ff 100644 --- a/src/devnet/fork.ts +++ b/src/devnet/fork.ts @@ -305,15 +305,14 @@ export function copySourceData(sourceDir: string, configPath: string): void { // Full copy on purpose: never hardlink — RocksDB appends to WAL/MANIFEST in // place, and linked files would corrupt the source chain. const excludedTopLevelEntries = new Set(['network', 'logs', 'tmp']); - fs.cpSync(sourceData, targetData, { - recursive: true, - filter: (sourcePath) => { - const relative = path.relative(sourceData, sourcePath); - if (!relative) return true; - const topLevelEntry = relative.split(path.sep)[0]; - return !excludedTopLevelEntries.has(topLevelEntry); - }, - }); + fs.mkdirSync(targetData, { recursive: true }); + // Enumerate top-level entries instead of relying on fs.cp's filter paths, + // which may use Windows extended-length prefixes and bypass relative-path + // comparisons. + for (const entry of fs.readdirSync(sourceData)) { + if (excludedTopLevelEntries.has(entry)) continue; + fs.cpSync(path.join(sourceData, entry), path.join(targetData, entry), { recursive: true }); + } logger.info('Excluded source network peers and transient logs/tmp data from the fork.'); } From cb83cf2f547f6915bef0594f4c1757ea2731128d Mon Sep 17 00:00:00 2001 From: RetricSu Date: Mon, 20 Jul 2026 16:49:00 +0800 Subject: [PATCH 3/8] remove implicit fork source discovery --- README.md | 7 ++---- src/cli.ts | 2 +- src/devnet/fork.ts | 52 +++------------------------------------ tests/devnet-fork.test.ts | 38 +++------------------------- 4 files changed, 11 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index b872116a..6fcc8935 100644 --- a/README.md +++ b/README.md @@ -391,17 +391,14 @@ 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 -# Auto-detect a single common Neuron/CKB data directory: -offckb devnet fork --dry-run - -# Or choose one explicitly: +# 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 --daemon offckb devnet info ``` -- With no `--from`, offckb searches `CKB_HOME` and common Neuron Mainnet/Testnet directories. If it finds more than one, it lists them and asks you to choose. An explicit `--from` points at the directory the source node runs with (`-C`), which must contain `data/db`. +- 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 ` to use a local chain spec (e.g. offline). - 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. diff --git a/src/cli.ts b/src/cli.ts index 5e2b46b8..d23e46ad 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -270,7 +270,7 @@ devnetCommand devnetCommand .command('fork') .description('Fork an existing mainnet/testnet chain data directory into the local devnet') - .option('--from ', 'Path to the source CKB node directory (auto-detects common Neuron paths when omitted)') + .option('--from ', 'Path to the source CKB node directory used with `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)') diff --git a/src/devnet/fork.ts b/src/devnet/fork.ts index e0e453ff..9aa6f84c 100644 --- a/src/devnet/fork.ts +++ b/src/devnet/fork.ts @@ -142,53 +142,6 @@ function validateSourceDir(sourceDir: string): void { } } -export function commonCkbSourceDirectories( - homeDir = os.homedir(), - platform: NodeJS.Platform = process.platform, -): string[] { - const neuronRoots = - platform === 'darwin' - ? [path.join(homeDir, 'Library', 'Application Support', 'Neuron', 'chains')] - : platform === 'win32' - ? [path.join(homeDir, 'AppData', 'Roaming', 'Neuron', 'chains')] - : [ - path.join(homeDir, '.local', 'share', 'Neuron', 'chains'), - path.join(homeDir, '.config', 'Neuron', 'chains'), - ]; - - return [ - ...(process.env.CKB_HOME ? [path.resolve(process.env.CKB_HOME)] : []), - ...neuronRoots.flatMap((root) => [path.join(root, 'mainnet'), path.join(root, 'testnet')]), - path.join(homeDir, '.ckb'), - ]; -} - -export function discoverCkbSourceDirectories(candidates = commonCkbSourceDirectories()): string[] { - return [...new Set(candidates.map((candidate) => path.resolve(candidate)))].filter((candidate) => - isFolderExists(path.join(candidate, 'data', 'db')), - ); -} - -export function resolveForkSourceDirectory(from?: string, candidates?: string[]): string { - if (from) return path.resolve(from); - - const discovered = discoverCkbSourceDirectories(candidates); - if (discovered.length === 1) { - logger.info(`Auto-detected CKB source directory: ${discovered[0]}`); - return discovered[0]; - } - if (discovered.length > 1) { - throw new Error( - `Found multiple CKB source directories:\n${discovered.map((dir) => ` - ${dir}`).join('\n')}\n` + - 'Choose one with --from .', - ); - } - throw new Error( - 'Could not auto-detect a CKB data directory. Start Neuron sync first, set CKB_HOME, ' + - 'or pass the directory used by `ckb -C` with --from .', - ); -} - // 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 { @@ -458,11 +411,14 @@ function alignConfigsWithOffckb(configPath: string): void { } export async function forkDevnet(options: ForkOptions): Promise { + if (!options.from) { + throw new Error('Database fork requires a source CKB directory: --from .'); + } const settings = readSettings(); const configPath = settings.devnet.configPath; const ckbVersion = settings.bins.defaultCKBVersion; - const sourceDir = resolveForkSourceDirectory(options.from); + const sourceDir = path.resolve(options.from); validateSourceDir(sourceDir); assertSourceNodeStopped(sourceDir); diff --git a/tests/devnet-fork.test.ts b/tests/devnet-fork.test.ts index d7b87bd7..5d67c80b 100644 --- a/tests/devnet-fork.test.ts +++ b/tests/devnet-fork.test.ts @@ -14,9 +14,7 @@ import { copySourceData, isolateForkCkbConfig, migrationNeededFromExitCode, - commonCkbSourceDirectories, - discoverCkbSourceDirectories, - resolveForkSourceDirectory, + forkDevnet, } from '../src/devnet/fork'; import { identifyPublicChainByGenesisHash, MAINNET_GENESIS_HASH, TESTNET_GENESIS_HASH } from '../src/scripts/const'; @@ -206,37 +204,9 @@ describe('fork data isolation and migration preflight', () => { }); }); -describe('fork source discovery', () => { - let root: string; - beforeEach(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fork-discovery-test-')); - }); - afterEach(() => fs.rmSync(root, { recursive: true, force: true })); - - it('includes the standard Neuron source paths for each platform', () => { - expect(commonCkbSourceDirectories('/home/alice', 'darwin')).toContain( - path.join('/home/alice', 'Library', 'Application Support', 'Neuron', 'chains', 'mainnet'), - ); - expect(commonCkbSourceDirectories('/home/alice', 'linux')).toContain( - path.join('/home/alice', '.local', 'share', 'Neuron', 'chains', 'testnet'), - ); - }); - - it('selects the only directory that contains data/db', () => { - const source = path.join(root, 'mainnet'); - fs.mkdirSync(path.join(source, 'data', 'db'), { recursive: true }); - expect(discoverCkbSourceDirectories([source, path.join(root, 'missing')])).toEqual([source]); - expect(resolveForkSourceDirectory(undefined, [source])).toBe(source); - }); - - it('requires an explicit choice when multiple sources are found', () => { - const sources = [path.join(root, 'mainnet'), path.join(root, 'testnet')]; - sources.forEach((source) => fs.mkdirSync(path.join(source, 'data', 'db'), { recursive: true })); - expect(() => resolveForkSourceDirectory(undefined, sources)).toThrow('multiple CKB source directories'); - }); - - it('keeps an explicit path even when discovery finds nothing', () => { - expect(resolveForkSourceDirectory('./custom', [])).toBe(path.resolve('./custom')); +describe('fork input mode', () => { + it('requires an explicit source directory for a database fork', async () => { + await expect(forkDevnet({})).rejects.toThrow('Database fork requires a source CKB directory'); }); }); From 0a87ccf60989965b730e25df903dae647e48d781 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Tue, 21 Jul 2026 07:59:27 +0800 Subject: [PATCH 4/8] address PR review feedback --- README.md | 2 ++ src/cli.ts | 3 +- src/cmd/deposit.ts | 5 +++- src/cmd/devnet-info.ts | 6 +++- src/cmd/node.ts | 9 +++++- src/cmd/transfer.ts | 12 ++++++-- src/devnet/fork.ts | 9 ++++-- src/sdk/ckb.ts | 40 +++++++++++++++++++++++-- src/tools/ckb-tui.ts | 54 +++------------------------------- src/util/fork-safety.ts | 46 +++++++++++++++++++++++++++-- src/util/logger.ts | 1 + tests/ckb-tui-checksum.test.ts | 5 ++-- tests/deposit.test.ts | 46 +++++++++++++++++++++++++++++ tests/devnet-fork.test.ts | 6 ++-- tests/devnet-info.test.ts | 52 ++++++++++++++++++++++++++++++++ tests/fork-safety.test.ts | 23 +++++++++++++-- tests/logger.test.ts | 10 +++++++ tests/node-supervisor.test.ts | 39 ++++++++++++++++++++++-- tests/sdk/ckb.udt.test.ts | 45 ++++++++++++++++++++++++++++ tests/udt.test.ts | 45 ++++++++++++++++++++++++++++ 20 files changed, 385 insertions(+), 73 deletions(-) create mode 100644 tests/deposit.test.ts create mode 100644 tests/devnet-info.test.ts diff --git a/README.md b/README.md index 6fcc8935..b2e285f1 100644 --- a/README.md +++ b/README.md @@ -414,6 +414,8 @@ On a forked devnet, `offckb system-scripts`, transfers, deploys and `offckb debu > [!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 diff --git a/src/cli.ts b/src/cli.ts index d23e46ad..a174a65b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -164,8 +164,9 @@ program .option('--network ', 'Specify the network to transfer to', 'devnet') .option('--privkey ', 'Specify the private key to transfer (visible in shell history)') .option('--privkey-file ', 'Read the private key from a local file') - .addOption(new Option('--udt-kind ', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt')) + .addOption(new Option('--udt-kind ', 'Specify the UDT kind').choices(['sudt', 'xudt'])) .option('--udt-type-args ', '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) => { await transfer(toAddress, amount, options); diff --git a/src/cmd/deposit.ts b/src/cmd/deposit.ts index ffa9d288..bdc3e4b6 100644 --- a/src/cmd/deposit.ts +++ b/src/cmd/deposit.ts @@ -22,7 +22,9 @@ export async function deposit( const ckb = new CKB({ network }); if (network === 'testnet') { - return await depositFromTestnetFaucet(toAddress, ckb); + const txHash = await depositFromTestnetFaucet(toAddress, ckb); + logger.result({ command: 'deposit', network, amount: amountInCKB, toAddress, txHash }); + return txHash; } // deposit from devnet miner @@ -65,6 +67,7 @@ async function depositFromTestnetFaucet(ckbAddress: string, ckb: CKB) { const txHash = await ckb.transferAll({ privateKey: randomAccountPrivateKey, toAddress: ckbAddress }); logger.info(`Done, check ${buildTestnetTxLink(txHash)} for details.`); + return txHash; } async function sendClaimRequest(toAddress: string) { diff --git a/src/cmd/devnet-info.ts b/src/cmd/devnet-info.ts index 4fc33ccf..7b50683f 100644 --- a/src/cmd/devnet-info.ts +++ b/src/cmd/devnet-info.ts @@ -32,7 +32,11 @@ export async function devnetInfo() { logger.info(`Node ready: ${result.ready ? 'yes' : 'no'}`); if (readiness.nodeTip != null) logger.info(`Node tip: ${readiness.nodeTip}`); if (readiness.indexerTip != null) logger.info(`Indexer tip: ${readiness.indexerTip}`); - if (readiness.indexerLag != null) logger.info(`Indexer lag: ${readiness.indexerLag}`); + if (readiness.indexerLag != null) { + const message = `Indexer lag: ${readiness.indexerLag}`; + if (readiness.indexerLag > BigInt(0)) logger.warn(`${message}; indexed queries may be stale.`); + else logger.info(message); + } logger.info(`Indexer ready: ${indexerReady ? 'yes' : 'no'}`); if (readiness.peers != null) logger.info(`Peers: ${readiness.peers}`); if (fork) diff --git a/src/cmd/node.ts b/src/cmd/node.ts index 4cf2f7eb..a6ce7fe8 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -133,6 +133,10 @@ export async function nodeDevnet({ version, binaryPath, daemon }: NodeProp) { ckbProcess.kill('SIGTERM'); throw error; } + if (ckbExited) { + if (!minerProcess.killed) minerProcess.kill('SIGTERM'); + throw new Error('CKB devnet exited while the miner was starting.'); + } const proxy = createRPCProxy(Network.devnet, settings.devnet.rpcUrl, settings.devnet.rpcProxyPort); proxy.start(); @@ -216,7 +220,10 @@ async function clearForkFirstRunWhenNodeUp( ); return; } - markForkFirstRunComplete(configPath); + // The miner has not started yet, so this tip is the exact boundary + // between copied public-chain state and cells mined on the local fork. + const forkBlockNumber = BigInt(String(await callJsonRpc(rpcUrl, 'get_tip_block_number', [], 5000))).toString(); + markForkFirstRunComplete(configPath, forkBlockNumber); logger.success('Forked devnet is up; first-run spec flags cleared.'); return; } catch { diff --git a/src/cmd/transfer.ts b/src/cmd/transfer.ts index 768173bc..0be49112 100644 --- a/src/cmd/transfer.ts +++ b/src/cmd/transfer.ts @@ -5,13 +5,14 @@ import { validateNetworkOpt, validateUdtAmount, validateUdtKind, validateUdtType import { resolvePrivateKey } from '../util/private-key'; import { logger } from '../util/logger'; import { warnIfForkIndexerIsBehind } from '../devnet/readiness'; -import { warnIfMainnetForkSigning } from '../util/fork-safety'; +import { validateMainnetForkSigning } from '../util/fork-safety'; export interface TransferOptions extends NetworkOption { privkey?: string | null; privkeyFile?: string | null; udtKind?: UdtKind; udtTypeArgs?: string; + allowMainnetReplayRisk?: boolean; } export async function transfer(toAddress: string, amount: string, opt: TransferOptions = { network: Network.devnet }) { @@ -20,7 +21,10 @@ export async function transfer(toAddress: string, amount: string, opt: TransferO let udtKind: UdtKind | undefined; let udtTypeArgs: string | undefined; - if (opt.udtTypeArgs) { + if (opt.udtKind != null || opt.udtTypeArgs != null) { + if (!opt.udtTypeArgs) { + throw new Error('UDT type args are required for a UDT transfer'); + } validateUdtAmount(amount); udtKind = opt.udtKind ?? 'sudt'; validateUdtKind(udtKind); @@ -28,7 +32,7 @@ export async function transfer(toAddress: string, amount: string, opt: TransferO } const privateKey = resolvePrivateKey(opt); - warnIfMainnetForkSigning(network, privateKey); + const rejectInputsAtOrBeforeBlock = validateMainnetForkSigning(network, privateKey, opt.allowMainnetReplayRisk); await warnIfForkIndexerIsBehind(network); const ckb = new CKB({ network }); @@ -40,6 +44,7 @@ export async function transfer(toAddress: string, amount: string, opt: TransferO privateKey, udtType, kind: udtKind, + rejectInputsAtOrBeforeBlock, }); logTxSuccess(network, txHash, 'transfer UDT'); @@ -59,6 +64,7 @@ export async function transfer(toAddress: string, amount: string, opt: TransferO toAddress, amountInCKB: amount, privateKey, + rejectInputsAtOrBeforeBlock, }); logTxSuccess(network, txHash, 'transfer'); logger.result({ command: 'transfer', network, amount, toAddress, txHash }); diff --git a/src/devnet/fork.ts b/src/devnet/fork.ts index 9aa6f84c..1200bea9 100644 --- a/src/devnet/fork.ts +++ b/src/devnet/fork.ts @@ -19,6 +19,7 @@ export interface ForkState { genesisHash: string; forkedAt: string; firstRunPending: boolean; + forkBlockNumber?: string; databaseMigrated?: boolean; networkIsolated?: boolean; } @@ -70,10 +71,14 @@ export function writeForkState(configPath: string, state: ForkState): void { fs.renameSync(tempPath, statePath); } -export function markForkFirstRunComplete(configPath: string): void { +export function markForkFirstRunComplete(configPath: string, forkBlockNumber?: string): void { const state = readForkState(configPath); if (!state || !state.firstRunPending) return; - writeForkState(configPath, { ...state, firstRunPending: false }); + writeForkState(configPath, { + ...state, + firstRunPending: false, + ...(forkBlockNumber == null ? {} : { forkBlockNumber }), + }); } export function detectSourceFromCkbToml(ckbTomlContent: string): 'mainnet' | 'testnet' | null { diff --git a/src/sdk/ckb.ts b/src/sdk/ckb.ts index f498e8b3..08218a5d 100644 --- a/src/sdk/ckb.ts +++ b/src/sdk/ckb.ts @@ -40,6 +40,7 @@ export interface TransferOption { privateKey: HexString; toAddress: string; amountInCKB: HexNumber; + rejectInputsAtOrBeforeBlock?: bigint; } export type TransferAllOption = Pick; @@ -50,6 +51,7 @@ export interface UdtTransferOption { amount: HexNumber; udtType: ccc.Script; kind: UdtKind; + rejectInputsAtOrBeforeBlock?: bigint; } export interface UdtIssueOption { @@ -178,7 +180,12 @@ export class CKB { return balanceInCKB; } - async transfer({ privateKey, toAddress, amountInCKB }: TransferOption): Promise { + async transfer({ + privateKey, + toAddress, + amountInCKB, + rejectInputsAtOrBeforeBlock, + }: TransferOption): Promise { const signer = this.buildSigner(privateKey); const to = await ccc.Address.fromString(toAddress, this.client); const tx = ccc.Transaction.from({ @@ -191,6 +198,7 @@ export class CKB { }); await tx.completeInputsByCapacity(signer); await tx.completeFeeBy(signer, this.feeRate); + await this.assertInputsCreatedAfter(tx, rejectInputsAtOrBeforeBlock); const txHash = await signer.sendTransaction(tx); return txHash; } @@ -354,7 +362,14 @@ export class CKB { return balance.toString(); } - async udtTransfer({ privateKey, toAddress, amount, udtType, kind }: UdtTransferOption): Promise { + async udtTransfer({ + privateKey, + toAddress, + amount, + udtType, + kind, + rejectInputsAtOrBeforeBlock, + }: UdtTransferOption): Promise { const signer = this.buildSigner(privateKey); const to = await ccc.Address.fromString(toAddress, this.client); const amountBigInt = validateUdtAmount(amount); @@ -395,10 +410,31 @@ export class CKB { } await tx.completeFeeBy(signer, this.feeRate); + await this.assertInputsCreatedAfter(tx, rejectInputsAtOrBeforeBlock); const txHash = await signer.sendTransaction(tx); return txHash; } + private async assertInputsCreatedAfter(tx: ccc.Transaction, blockNumber?: bigint): Promise { + if (blockNumber == null) return; + + for (const input of tx.inputs) { + const outPoint = input.previousOutput; + const origin = await this.client.getTransactionNoCache(outPoint.txHash); + if (origin?.blockNumber == null) { + throw new Error( + `Refusing to sign: could not verify the origin block of input ${outPoint.txHash}:${outPoint.index}.`, + ); + } + if (origin.blockNumber <= blockNumber) { + throw new Error( + `Refusing to sign: input ${outPoint.txHash}:${outPoint.index} was created at block ${origin.blockNumber}, ` + + `at or before the Mainnet fork boundary ${blockNumber}.`, + ); + } + } + } + async udtIssue({ privateKey, kind, amount, typeArgs, toAddress }: UdtIssueOption): Promise { const signer = this.buildSigner(privateKey); const signerAddress = await signer.getAddressObjSecp256k1(); diff --git a/src/tools/ckb-tui.ts b/src/tools/ckb-tui.ts index 3d46ab9d..086719fa 100644 --- a/src/tools/ckb-tui.ts +++ b/src/tools/ckb-tui.ts @@ -210,45 +210,16 @@ export class CKBTui { } } - /** Verify against a pinned digest or an upstream checksum manifest. */ + /** Verify against an independently pinned digest. */ private static verifyChecksum(version: string, assetName: string, archivePath: string): void { const pinnedHash = KNOWN_SHA256[version]?.[assetName]; - if (pinnedHash) { - this.assertChecksum(archivePath, assetName, pinnedHash); - return; - } - - const checksumUrl = `https://github.com/Officeyutong/ckb-tui/releases/download/${version}/checksums-sha256.txt`; - - const checksumPath = path.join(path.dirname(archivePath), 'checksums-sha256.txt'); - - const fetchResult = spawnSync('curl', ['-fsSL', '--max-time', '30', '-o', checksumPath, checksumUrl], { - stdio: 'pipe', - timeout: 30_000, - }); - - if (fetchResult.status !== 0) { + if (!pinnedHash) { throw new Error( - `No trusted SHA-256 checksum is available for ckb-tui ${version} (${assetName}). ` + + `No trusted SHA-256 checksum is pinned for ckb-tui ${version} (${assetName}). ` + 'Refusing to install an unverified binary.', ); } - - try { - const checksumContent = fs.readFileSync(checksumPath, 'utf8'); - const expectedHash = this.parseChecksumFile(checksumContent, assetName); - - if (!expectedHash) { - throw new Error(`Checksum entry for "${assetName}" was not found; refusing to install an unverified binary.`); - } - this.assertChecksum(archivePath, assetName, expectedHash); - } finally { - try { - fs.unlinkSync(checksumPath); - } catch { - // Best effort - } - } + this.assertChecksum(archivePath, assetName, pinnedHash); } private static assertChecksum(archivePath: string, assetName: string, expectedHash: string): void { @@ -263,23 +234,6 @@ export class CKBTui { logger.info('SHA-256 checksum verified successfully.'); } - /** - * Parse a standard SHA-256 checksum file (format: " " per line) - * and return the hex hash for the given asset name, or null if not found. - */ - private static parseChecksumFile(content: string, assetName: string): string | null { - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - - const match = trimmed.match(/^([0-9a-fA-F]{64})\s+[*]?(.+)$/); - if (match && match[2] === assetName) { - return match[1].toLowerCase(); - } - } - return null; - } - /** * Extract a downloaded archive to the given directory. * Uses AdmZip for .zip files (Node-native, no shell) and spawnSync with array diff --git a/src/util/fork-safety.ts b/src/util/fork-safety.ts index 1deb1b2e..03833c9f 100644 --- a/src/util/fork-safety.ts +++ b/src/util/fork-safety.ts @@ -1,7 +1,7 @@ import accountConfig from '../../account/account.json'; import { ckbDevnetMinerAccount } from '../cfg/account'; import { readSettings } from '../cfg/setting'; -import { readForkState } from '../devnet/fork'; +import { ForkState, readForkState } from '../devnet/fork'; import { Network } from '../type/base'; import { logger } from './logger'; @@ -10,11 +10,51 @@ const BUILT_IN_DEV_KEYS = new Set( ); export function warnIfMainnetForkSigning(network: Network, privateKey: string): void { - if (network !== Network.devnet) return; + if (!readMainnetForkState(network)) return; + logMainnetForkSigningWarning(privateKey); +} + +/** + * Fail closed before a Mainnet-fork transfer is constructed or signed. + * Returns the copied-chain tip so the transaction layer can reject inputs + * created at or before the fork boundary after input selection. + */ +export function validateMainnetForkSigning( + network: Network, + privateKey: string, + allowMainnetReplayRisk = false, +): bigint | undefined { + const fork = readMainnetForkState(network); + if (!fork) return undefined; + + logMainnetForkSigningWarning(privateKey); + if (!BUILT_IN_DEV_KEYS.has(privateKey.trim().toLowerCase()) && !allowMainnetReplayRisk) { + throw new Error( + 'Refusing to sign with a non-built-in private key on a Mainnet fork. ' + + 'Use --allow-mainnet-replay-risk only after verifying that no copied Mainnet input will be selected.', + ); + } + if (fork.forkBlockNumber == null) { + throw new Error( + 'Mainnet fork boundary metadata is missing. Restart or recreate the fork before signing so input origins can be verified.', + ); + } + try { + return BigInt(fork.forkBlockNumber); + } catch { + throw new Error(`Invalid Mainnet fork boundary metadata: ${fork.forkBlockNumber}`); + } +} + +function readMainnetForkState(network: Network): ForkState | null { + if (network !== Network.devnet) return null; const settings = readSettings(); - if (readForkState(settings.devnet.configPath)?.source !== 'mainnet') return; + const fork = readForkState(settings.devnet.configPath); + return fork?.source === 'mainnet' ? fork : null; +} +function logMainnetForkSigningWarning(privateKey: string): void { logger.warn([ 'MAINNET FORK REPLAY RISK: CKB transactions have no chain id.', 'A transaction spending cells copied from Mainnet can also be valid on Mainnet.', diff --git a/src/util/logger.ts b/src/util/logger.ts index 0a76941d..8a6955ec 100644 --- a/src/util/logger.ts +++ b/src/util/logger.ts @@ -61,6 +61,7 @@ class UnifiedLogger { }), ], }); + this.setJsonMode(this.jsonMode); } /** diff --git a/tests/ckb-tui-checksum.test.ts b/tests/ckb-tui-checksum.test.ts index 5199688f..e17bbfdf 100644 --- a/tests/ckb-tui-checksum.test.ts +++ b/tests/ckb-tui-checksum.test.ts @@ -38,8 +38,8 @@ describe('ckb-tui checksum policy', () => { expect(mockSpawnSync).not.toHaveBeenCalled(); }); - it('fails closed when an unpinned release has no checksum manifest', () => { - mockSpawnSync.mockReturnValue({ status: 22 }); + it('fails closed for an unpinned release without trusting its release manifest', () => { + mockSpawnSync.mockReturnValue({ status: 0 }); expect(() => (CKBTui as unknown as { verifyChecksum: (...args: string[]) => void }).verifyChecksum( 'v9.9.9', @@ -47,5 +47,6 @@ describe('ckb-tui checksum policy', () => { archive, ), ).toThrow('Refusing to install an unverified binary'); + expect(mockSpawnSync).not.toHaveBeenCalled(); }); }); diff --git a/tests/deposit.test.ts b/tests/deposit.test.ts new file mode 100644 index 00000000..09c78097 --- /dev/null +++ b/tests/deposit.test.ts @@ -0,0 +1,46 @@ +import { Network } from '../src/type/base'; + +const mockBuildAddress = jest.fn().mockResolvedValue('ckt1random'); +const mockWaitForTxConfirm = jest.fn().mockResolvedValue(undefined); +const mockTransferAll = jest.fn().mockResolvedValue('0xtransferhash'); +const mockRequestSend = jest.fn().mockResolvedValue({ + status: 200, + json: async () => ({ data: { attributes: { txHash: '0xclaimhash' } } }), +}); + +jest.mock('../src/sdk/ckb', () => ({ + CKB: jest.fn().mockImplementation(() => ({ + buildSecp256k1Address: mockBuildAddress, + waitForTxConfirm: mockWaitForTxConfirm, + transferAll: mockTransferAll, + })), +})); +jest.mock('../src/util/request', () => ({ Request: { send: (...args: unknown[]) => mockRequestSend(...args) } })); +jest.mock('../src/util/logger', () => ({ + logger: { info: jest.fn(), error: jest.fn(), result: jest.fn() }, +})); +jest.mock('../src/devnet/readiness', () => ({ warnIfForkIndexerIsBehind: jest.fn() })); +jest.mock('../src/util/fork-safety', () => ({ warnIfMainnetForkSigning: jest.fn() })); + +import { deposit } from '../src/cmd/deposit'; +import { logger } from '../src/util/logger'; + +describe('deposit command', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns and reports the Testnet faucet transfer hash', async () => { + await expect(deposit('ckt1receiver', '10000', { network: Network.testnet })).resolves.toBe('0xtransferhash'); + + expect(mockWaitForTxConfirm).toHaveBeenCalledWith('0xclaimhash'); + expect(mockTransferAll).toHaveBeenCalledWith( + expect.objectContaining({ toAddress: 'ckt1receiver', privateKey: expect.stringMatching(/^0x[0-9a-f]{64}$/) }), + ); + expect(logger.result).toHaveBeenCalledWith({ + command: 'deposit', + network: Network.testnet, + amount: '10000', + toAddress: 'ckt1receiver', + txHash: '0xtransferhash', + }); + }); +}); diff --git a/tests/devnet-fork.test.ts b/tests/devnet-fork.test.ts index 5d67c80b..03feb6e7 100644 --- a/tests/devnet-fork.test.ts +++ b/tests/devnet-fork.test.ts @@ -242,11 +242,11 @@ describe('fork state file', () => { expect(readForkState(dir)).toBeNull(); }); - it('clears firstRunPending while preserving the other fields', () => { + it('clears firstRunPending and records the fork boundary while preserving the other fields', () => { writeForkState(dir, state); - markForkFirstRunComplete(dir); + markForkFirstRunComplete(dir, '123'); const updated = readForkState(dir); - expect(updated).toEqual({ ...state, firstRunPending: false }); + expect(updated).toEqual({ ...state, firstRunPending: false, forkBlockNumber: '123' }); }); it('markForkFirstRunComplete is a no-op without a state file', () => { diff --git a/tests/devnet-info.test.ts b/tests/devnet-info.test.ts new file mode 100644 index 00000000..23973e46 --- /dev/null +++ b/tests/devnet-info.test.ts @@ -0,0 +1,52 @@ +let mockFork: { source: 'mainnet' | 'testnet' } | null = null; +const mockReadiness = jest.fn(); + +jest.mock('../src/cfg/setting', () => ({ + readSettings: () => ({ + devnet: { configPath: '/tmp/devnet', rpcUrl: 'http://127.0.0.1:8114', rpcProxyPort: 28114 }, + }), +})); +jest.mock('../src/devnet/fork', () => ({ readForkState: () => mockFork })); +jest.mock('../src/devnet/readiness', () => ({ checkNodeReadiness: (...args: unknown[]) => mockReadiness(...args) })); +jest.mock('../src/util/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), result: jest.fn() }, +})); + +import { devnetInfo } from '../src/cmd/devnet-info'; +import { logger } from '../src/util/logger'; + +describe('devnet info', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFork = null; + }); + + it('warns when indexed queries lag behind the node', async () => { + mockReadiness.mockResolvedValue({ + ready: true, + nodeTip: 100n, + indexerTip: 90n, + indexerLag: 10n, + peers: 0, + }); + + await devnetInfo(); + + expect(logger.warn).toHaveBeenCalledWith('Indexer lag: 10; indexed queries may be stale.'); + expect(logger.info).not.toHaveBeenCalledWith('Indexer lag: 10'); + }); + + it('logs zero lag as informational', async () => { + mockReadiness.mockResolvedValue({ + ready: true, + nodeTip: 100n, + indexerTip: 100n, + indexerLag: 0n, + peers: 0, + }); + + await devnetInfo(); + + expect(logger.info).toHaveBeenCalledWith('Indexer lag: 0'); + }); +}); diff --git a/tests/fork-safety.test.ts b/tests/fork-safety.test.ts index 8a1f4ce3..e78b0317 100644 --- a/tests/fork-safety.test.ts +++ b/tests/fork-safety.test.ts @@ -1,4 +1,4 @@ -let mockFork: { source: 'mainnet' | 'testnet' } | null = null; +let mockFork: { source: 'mainnet' | 'testnet'; forkBlockNumber?: string } | null = null; jest.mock('../src/cfg/setting', () => ({ readSettings: () => ({ devnet: { configPath: '/tmp/offckb-devnet' } }), @@ -7,7 +7,7 @@ jest.mock('../src/devnet/fork', () => ({ readForkState: () => mockFork })); jest.mock('../src/util/logger', () => ({ logger: { warn: jest.fn() } })); import accountConfig from '../account/account.json'; -import { warnIfMainnetForkSigning } from '../src/util/fork-safety'; +import { validateMainnetForkSigning, warnIfMainnetForkSigning } from '../src/util/fork-safety'; import { logger } from '../src/util/logger'; import { Network } from '../src/type/base'; @@ -37,4 +37,23 @@ describe('Mainnet fork signing warning', () => { warnIfMainnetForkSigning(Network.testnet, accountConfig[0].privkey); expect(logger.warn).not.toHaveBeenCalled(); }); + + it('requires an explicit override for an external key', () => { + mockFork = { source: 'mainnet', forkBlockNumber: '100' }; + expect(() => validateMainnetForkSigning(Network.devnet, '0x' + '11'.repeat(32))).toThrow( + '--allow-mainnet-replay-risk', + ); + }); + + it('returns the fork boundary after an explicit external-key override', () => { + mockFork = { source: 'mainnet', forkBlockNumber: '100' }; + expect(validateMainnetForkSigning(Network.devnet, '0x' + '11'.repeat(32), true)).toBe(100n); + }); + + it('fails closed when fork boundary metadata is missing', () => { + mockFork = { source: 'mainnet' }; + expect(() => validateMainnetForkSigning(Network.devnet, accountConfig[0].privkey)).toThrow( + 'boundary metadata is missing', + ); + }); }); diff --git a/tests/logger.test.ts b/tests/logger.test.ts index 71e151eb..5c3deefd 100644 --- a/tests/logger.test.ts +++ b/tests/logger.test.ts @@ -44,6 +44,16 @@ describe('UnifiedLogger JSON mode', () => { expect(parsed.timestamp).toBeDefined(); }); + it('initializes JSON stderr routing when created in JSON mode', () => { + const { transport } = createCapturingTransport(); + UnifiedLogger.create({ transports: [transport], jsonMode: true }); + + const state = transport as unknown as { stderrLevels: Record }; + expect(state.stderrLevels).toEqual( + expect.objectContaining({ error: true, warn: true, success: true, info: true, debug: true }), + ); + }); + it('joins array messages into a single string in JSON mode', () => { const { transport, logs } = createCapturingTransport(); const log = UnifiedLogger.create({ transports: [transport] }); diff --git a/tests/node-supervisor.test.ts b/tests/node-supervisor.test.ts index a72e280f..840e4a90 100644 --- a/tests/node-supervisor.test.ts +++ b/tests/node-supervisor.test.ts @@ -3,6 +3,9 @@ import { EventEmitter } from 'events'; const mockSpawn = jest.fn(); const mockProxyStart = jest.fn(); const mockProxyStop = jest.fn(); +const mockMarkForkFirstRunComplete = jest.fn(); +const mockCallJsonRpc = jest.fn(); +let mockForkState: { source: 'mainnet'; firstRunPending: boolean; genesisHash: string } | null = null; jest.mock('child_process', () => ({ ...jest.requireActual('child_process'), @@ -23,9 +26,10 @@ jest.mock('../src/cfg/setting', () => ({ getCKBBinaryPath: () => '/tmp/ckb', })); jest.mock('../src/devnet/fork', () => ({ - readForkState: jest.fn().mockReturnValue(null), - markForkFirstRunComplete: jest.fn(), + readForkState: () => mockForkState, + markForkFirstRunComplete: (...args: unknown[]) => mockMarkForkFirstRunComplete(...args), })); +jest.mock('../src/util/json-rpc', () => ({ callJsonRpc: (...args: unknown[]) => mockCallJsonRpc(...args) })); jest.mock('../src/devnet/readiness', () => ({ checkNodeReadiness: jest.fn(), waitForNodeReady: jest.fn().mockResolvedValue({ ready: true, rpcUrl: 'http://127.0.0.1:8114' }), @@ -63,6 +67,7 @@ describe('foreground devnet supervisor', () => { beforeEach(() => { jest.clearAllMocks(); process.exitCode = undefined; + mockForkState = null; ckb = new FakeChild(); miner = new FakeChild(); mockSpawn.mockReturnValueOnce(ckb).mockImplementationOnce(() => { @@ -90,4 +95,34 @@ describe('foreground devnet supervisor', () => { expect(mockProxyStop).toHaveBeenCalled(); expect(process.exitCode).toBe(1); }); + + it('does not start the proxy when CKB exits while the miner is starting', async () => { + mockSpawn.mockReset(); + mockSpawn.mockReturnValueOnce(ckb).mockImplementationOnce(() => { + process.nextTick(() => { + ckb.emit('exit', 1, null); + miner.emit('spawn'); + }); + return miner; + }); + + await expect(nodeDevnet({})).rejects.toThrow('exited while the miner was starting'); + expect(miner.kill).toHaveBeenCalledWith('SIGTERM'); + expect(mockProxyStart).not.toHaveBeenCalled(); + }); + + it('records the source tip as the fork boundary before the miner starts', async () => { + const genesisHash = '0x' + 'ab'.repeat(32); + mockForkState = { source: 'mainnet', firstRunPending: true, genesisHash }; + mockCallJsonRpc.mockImplementation(async (_url: string, method: string) => { + if (method === 'get_block_hash') return genesisHash; + if (method === 'get_tip_block_number') return '0x64'; + throw new Error(method); + }); + + await nodeDevnet({}); + + expect(mockMarkForkFirstRunComplete).toHaveBeenCalledWith('/tmp/offckb-devnet', '100'); + expect(mockProxyStart).toHaveBeenCalled(); + }); }); diff --git a/tests/sdk/ckb.udt.test.ts b/tests/sdk/ckb.udt.test.ts index 33c60b47..ddac4c31 100644 --- a/tests/sdk/ckb.udt.test.ts +++ b/tests/sdk/ckb.udt.test.ts @@ -26,10 +26,12 @@ jest.mock('../../src/scripts/private', () => ({ const mockKnownScript = jest.fn(); const mockFindCellsByLock = jest.fn(); const mockFindCells = jest.fn(); +const mockGetTransactionNoCache = jest.fn(); const mockClient = { getKnownScript: mockKnownScript, findCellsByLock: mockFindCellsByLock, findCells: mockFindCells, + getTransactionNoCache: mockGetTransactionNoCache, }; jest.mock('@ckb-ccc/core', () => { @@ -180,6 +182,49 @@ describe('CKB SDK UDT helpers', () => { warnSpy.mockRestore(); }); }); + + describe('Mainnet fork input boundary', () => { + const input = { previousOutput: { txHash: '0x' + 'ab'.repeat(32), index: 0 } }; + + it('rejects an input copied from at or before the fork boundary', async () => { + mockGetTransactionNoCache.mockResolvedValue({ blockNumber: 100n }); + const ckb = createCKB(); + + await expect( + ( + ckb as unknown as { + assertInputsCreatedAfter: (tx: { inputs: typeof input[] }, block: bigint) => Promise; + } + ).assertInputsCreatedAfter({ inputs: [input] }, 100n), + ).rejects.toThrow('at or before the Mainnet fork boundary'); + }); + + it('allows an input mined after the fork boundary', async () => { + mockGetTransactionNoCache.mockResolvedValue({ blockNumber: 101n }); + const ckb = createCKB(); + + await expect( + ( + ckb as unknown as { + assertInputsCreatedAfter: (tx: { inputs: typeof input[] }, block: bigint) => Promise; + } + ).assertInputsCreatedAfter({ inputs: [input] }, 100n), + ).resolves.toBeUndefined(); + }); + + it('fails closed when an input origin cannot be verified', async () => { + mockGetTransactionNoCache.mockResolvedValue(undefined); + const ckb = createCKB(); + + await expect( + ( + ckb as unknown as { + assertInputsCreatedAfter: (tx: { inputs: typeof input[] }, block: bigint) => Promise; + } + ).assertInputsCreatedAfter({ inputs: [input] }, 100n), + ).rejects.toThrow('could not verify the origin block'); + }); + }); }); function makeCell(kind: UdtKind, args: string, balance: string) { diff --git a/tests/udt.test.ts b/tests/udt.test.ts index f1b851c0..03f8f567 100644 --- a/tests/udt.test.ts +++ b/tests/udt.test.ts @@ -7,6 +7,7 @@ import { logger } from '../src/util/logger'; const mockTypeArgs = '0x' + 'ab'.repeat(32); const mockUdtType = { codeHash: '0x1234', hashType: 'type', args: mockTypeArgs }; +const mockValidateMainnetForkSigning = jest.fn().mockReturnValue(undefined); jest.mock('../src/sdk/ckb', () => { return { @@ -51,11 +52,13 @@ jest.mock('../src/devnet/readiness', () => ({ jest.mock('../src/util/fork-safety', () => ({ warnIfMainnetForkSigning: jest.fn(), + validateMainnetForkSigning: (...args: unknown[]) => mockValidateMainnetForkSigning(...args), })); describe('balance command', () => { beforeEach(() => { jest.clearAllMocks(); + mockValidateMainnetForkSigning.mockReturnValue(undefined); }); it('should print CKB and detected UDT balances by default', async () => { @@ -99,6 +102,7 @@ describe('balance command', () => { describe('transfer command', () => { beforeEach(() => { jest.clearAllMocks(); + mockValidateMainnetForkSigning.mockReturnValue(undefined); }); it('should transfer CKB by default', async () => { @@ -113,6 +117,23 @@ describe('transfer command', () => { expect(logger.info).toHaveBeenCalledWith('Successfully transfer, txHash:', '0xtxhash'); }); + it('passes the Mainnet fork boundary to input selection checks', async () => { + mockValidateMainnetForkSigning.mockReturnValue(100n); + const privateKey = '0x1234567812345678123456781234567812345678123456781234567812345678'; + + await transfer('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', '100', { + network: Network.devnet, + privkey: privateKey, + allowMainnetReplayRisk: true, + }); + + const ckbInstance = (CKB as jest.Mock).mock.results[0].value; + expect(mockValidateMainnetForkSigning).toHaveBeenCalledWith(Network.devnet, privateKey, true); + expect(ckbInstance.transfer).toHaveBeenCalledWith( + expect.objectContaining({ rejectInputsAtOrBeforeBlock: 100n }), + ); + }); + it('should transfer UDT when --udt-type-args is provided', async () => { await transfer('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', '100', { network: Network.devnet, @@ -127,6 +148,30 @@ describe('transfer command', () => { expect(logger.info).toHaveBeenCalledWith('Successfully transfer UDT, txHash:', '0xtxhash'); }); + it('should reject --udt-kind without type args instead of transferring CKB', async () => { + await expect( + transfer('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', '100', { + network: Network.devnet, + privkey: '0x1234567812345678123456781234567812345678123456781234567812345678', + udtKind: 'sudt', + }), + ).rejects.toThrow('UDT type args are required'); + + expect(CKB).not.toHaveBeenCalled(); + }); + + it('should reject empty UDT type args instead of transferring CKB', async () => { + await expect( + transfer('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', '100', { + network: Network.devnet, + privkey: '0x1234567812345678123456781234567812345678123456781234567812345678', + udtTypeArgs: '', + }), + ).rejects.toThrow('UDT type args are required'); + + expect(CKB).not.toHaveBeenCalled(); + }); + it('should throw when privkey is missing for UDT transfer', async () => { await expect( transfer('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', '100', { From f1f652bd6adf61743075ac299df426240b988238 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Tue, 21 Jul 2026 08:12:36 +0800 Subject: [PATCH 5/8] address follow-up review feedback --- src/cmd/deposit.ts | 19 ++++-- src/cmd/node.ts | 117 +++++++++++++++++++++++++++------- src/util/fork-safety.ts | 4 +- tests/deposit.test.ts | 26 +++++++- tests/fork-safety.test.ts | 7 ++ tests/node-command.test.ts | 78 +++++++++++++++++++++-- tests/node-supervisor.test.ts | 1 + 7 files changed, 217 insertions(+), 35 deletions(-) diff --git a/src/cmd/deposit.ts b/src/cmd/deposit.ts index bdc3e4b6..9e2396da 100644 --- a/src/cmd/deposit.ts +++ b/src/cmd/deposit.ts @@ -7,10 +7,12 @@ import { Request } from '../util/request'; import { RequestInit } from 'node-fetch'; import { logger } from '../util/logger'; import { warnIfForkIndexerIsBehind } from '../devnet/readiness'; -import { warnIfMainnetForkSigning } from '../util/fork-safety'; +import { validateMainnetForkSigning } from '../util/fork-safety'; export interface DepositOptions extends NetworkOption {} +const TESTNET_FAUCET_CLAIM_AMOUNT = '10000'; + export async function deposit( toAddress: string, amountInCKB: string, @@ -23,18 +25,27 @@ export async function deposit( if (network === 'testnet') { const txHash = await depositFromTestnetFaucet(toAddress, ckb); - logger.result({ command: 'deposit', network, amount: amountInCKB, toAddress, txHash }); + logger.result({ + command: 'deposit', + network, + source: 'fixed-testnet-faucet-claim', + requestedAmount: amountInCKB, + faucetClaimAmount: TESTNET_FAUCET_CLAIM_AMOUNT, + toAddress, + txHash, + }); return txHash; } // deposit from devnet miner const privateKey = ckbDevnetMinerAccount.privkey; - warnIfMainnetForkSigning(network, privateKey); + const rejectInputsAtOrBeforeBlock = validateMainnetForkSigning(network, privateKey); await warnIfForkIndexerIsBehind(network); const txHash = await ckb.transfer({ toAddress, privateKey, amountInCKB, + rejectInputsAtOrBeforeBlock, }); logger.info('tx hash: ', txHash); logger.result({ command: 'deposit', network, amount: amountInCKB, toAddress, txHash }); @@ -84,7 +95,7 @@ async function sendClaimRequest(toAddress: string) { const body = JSON.stringify({ claim_event: { address_hash: toAddress, - amount: '10000', // unit: CKB + amount: TESTNET_FAUCET_CLAIM_AMOUNT, // unit: CKB }, }); diff --git a/src/cmd/node.ts b/src/cmd/node.ts index a6ce7fe8..101103e2 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -22,6 +22,7 @@ interface PidMetadata { pid: number; scriptPath: string; startedAt: string; + status?: 'starting' | 'running'; } const DAEMON_LOG_DIR = 'logs'; @@ -264,6 +265,7 @@ function readPidFile(pidFile: string): PidMetadata | null { pid, scriptPath: parsed.scriptPath, startedAt: parsed.startedAt ?? new Date(0).toISOString(), + status: parsed.status, }; } } catch { @@ -279,6 +281,38 @@ function writePidFile(pidFile: string, metadata: PidMetadata) { fs.writeFileSync(pidFile, JSON.stringify(metadata, null, 2)); } +function reservePidFile(pidFile: string, scriptPath: string): void { + let fd: number; + try { + fd = fs.openSync(pidFile, 'wx'); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === 'EEXIST') { + throw new Error('A CKB devnet daemon startup is already in progress. Try again after it completes.'); + } + throw new Error(`Failed to reserve daemon PID file ${pidFile}: ${err.message}`); + } + + let writeError: Error | undefined; + try { + const reservation: PidMetadata = { + pid: process.pid, + scriptPath, + startedAt: new Date().toISOString(), + status: 'starting', + }; + fs.writeFileSync(fd, JSON.stringify(reservation, null, 2)); + } catch (error) { + writeError = error as Error; + } finally { + fs.closeSync(fd); + } + if (writeError) { + cleanupPidFile(pidFile); + throw new Error(`Failed to initialize daemon PID reservation ${pidFile}: ${writeError.message}`); + } +} + function resolveCliEntry(): string | null { // In priority order. process.argv[1] is the most reliable for a Node CLI. // OFFCKB_CLI_PATH is an escape hatch for packaged/npx/weird environments. @@ -303,11 +337,15 @@ function resolveCliEntry(): string | null { } function isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; - } catch { - return false; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === 'ESRCH') return false; + if (err.code === 'EPERM') throw new Error(`Permission denied when checking daemon process ${pid}.`); + throw error; } } @@ -321,10 +359,15 @@ function cleanupPidFile(pidFile: string) { function waitForProcessExit(pid: number, timeoutMs: number): Promise { const start = Date.now(); - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const check = () => { - if (!isProcessAlive(pid)) { - resolve(true); + try { + if (!isProcessAlive(pid)) { + resolve(true); + return; + } + } catch (error) { + reject(error); return; } if (Date.now() - start >= timeoutMs) { @@ -411,6 +454,12 @@ function terminateProcess(pid: number, signal: 'SIGTERM' | 'SIGKILL'): Promise arg !== '--daemon'); @@ -462,11 +516,13 @@ async function startDaemon() { }); } catch (error) { closeFileDescriptors(out, err); + cleanupPidFile(pidFile); throw new Error(`Failed to spawn daemon process: ${(error as Error).message}`); } if (!child.pid) { closeFileDescriptors(out, err); + cleanupPidFile(pidFile); throw new Error('Failed to spawn daemon process: no PID returned.'); } @@ -481,6 +537,7 @@ async function startDaemon() { pid: child.pid, scriptPath, startedAt: new Date().toISOString(), + status: 'running', }; writePidFile(pidFile, metadata); @@ -495,14 +552,22 @@ async function startDaemon() { const proxyUrl = `http://127.0.0.1:${settings.devnet.rpcProxyPort}`; const readiness = await waitForNodeReady(proxyUrl, timeoutMs, () => isProcessAlive(child.pid!)); if (!readiness.ready) { + let exited = !isProcessAlive(child.pid); try { - await terminateProcess(child.pid, 'SIGTERM'); + if (!exited) { + await terminateProcess(child.pid, 'SIGTERM'); + exited = await waitForProcessExit(child.pid, 5000); + } } catch { // The failed child may already have exited. + exited = !isProcessAlive(child.pid); + } + if (exited) { + cleanupPidFile(pidFile); } - cleanupPidFile(pidFile); throw new Error( - `CKB devnet daemon failed to become ready. See ${logFile}. ${readiness.error ?? 'Daemon process exited.'}`, + `CKB devnet daemon failed to become ready. See ${logFile}. ${readiness.error ?? 'Daemon process exited.'}` + + (exited ? '' : ` Process ${child.pid} is still running; PID file was preserved.`), ); } @@ -549,12 +614,16 @@ export async function stopNode() { throw new Error(`Invalid PID in ${pidFile}: ${pid}`); } - if (!isProcessAlive(pid)) { + const processAlive = isProcessAlive(pid); + if (!processAlive) { logger.warn(`Daemon process ${pid} is not running.`); cleanupPidFile(pidFile); logger.result({ command: 'node.stop', stopped: false, reason: 'stale-pid', pid }); return; } + if (metadata.status === 'starting') { + throw new Error(`CKB devnet daemon startup is still in progress (PID ${pid}). Try stopping it again shortly.`); + } const identityOk = await verifyDaemonIdentity(pid, metadata); if (!identityOk) { diff --git a/src/util/fork-safety.ts b/src/util/fork-safety.ts index 03833c9f..b5508dd5 100644 --- a/src/util/fork-safety.ts +++ b/src/util/fork-safety.ts @@ -41,7 +41,9 @@ export function validateMainnetForkSigning( ); } try { - return BigInt(fork.forkBlockNumber); + const blockNumber = BigInt(fork.forkBlockNumber); + if (blockNumber < BigInt(0)) throw new Error('negative block number'); + return blockNumber; } catch { throw new Error(`Invalid Mainnet fork boundary metadata: ${fork.forkBlockNumber}`); } diff --git a/tests/deposit.test.ts b/tests/deposit.test.ts index 09c78097..d1c9b80f 100644 --- a/tests/deposit.test.ts +++ b/tests/deposit.test.ts @@ -3,6 +3,8 @@ import { Network } from '../src/type/base'; const mockBuildAddress = jest.fn().mockResolvedValue('ckt1random'); const mockWaitForTxConfirm = jest.fn().mockResolvedValue(undefined); const mockTransferAll = jest.fn().mockResolvedValue('0xtransferhash'); +const mockTransfer = jest.fn().mockResolvedValue('0xdevnethash'); +const mockValidateMainnetForkSigning = jest.fn(); const mockRequestSend = jest.fn().mockResolvedValue({ status: 200, json: async () => ({ data: { attributes: { txHash: '0xclaimhash' } } }), @@ -13,6 +15,7 @@ jest.mock('../src/sdk/ckb', () => ({ buildSecp256k1Address: mockBuildAddress, waitForTxConfirm: mockWaitForTxConfirm, transferAll: mockTransferAll, + transfer: mockTransfer, })), })); jest.mock('../src/util/request', () => ({ Request: { send: (...args: unknown[]) => mockRequestSend(...args) } })); @@ -20,7 +23,9 @@ jest.mock('../src/util/logger', () => ({ logger: { info: jest.fn(), error: jest.fn(), result: jest.fn() }, })); jest.mock('../src/devnet/readiness', () => ({ warnIfForkIndexerIsBehind: jest.fn() })); -jest.mock('../src/util/fork-safety', () => ({ warnIfMainnetForkSigning: jest.fn() })); +jest.mock('../src/util/fork-safety', () => ({ + validateMainnetForkSigning: (...args: unknown[]) => mockValidateMainnetForkSigning(...args), +})); import { deposit } from '../src/cmd/deposit'; import { logger } from '../src/util/logger'; @@ -38,9 +43,26 @@ describe('deposit command', () => { expect(logger.result).toHaveBeenCalledWith({ command: 'deposit', network: Network.testnet, - amount: '10000', + source: 'fixed-testnet-faucet-claim', + requestedAmount: '10000', + faucetClaimAmount: '10000', toAddress: 'ckt1receiver', txHash: '0xtransferhash', }); }); + + it('enforces the Mainnet fork boundary for devnet deposits', async () => { + mockValidateMainnetForkSigning.mockReturnValue(100n); + + await expect(deposit('ckt1receiver', '42', { network: Network.devnet })).resolves.toBe('0xdevnethash'); + + expect(mockValidateMainnetForkSigning).toHaveBeenCalledWith(Network.devnet, expect.any(String)); + expect(mockTransfer).toHaveBeenCalledWith( + expect.objectContaining({ + toAddress: 'ckt1receiver', + amountInCKB: '42', + rejectInputsAtOrBeforeBlock: 100n, + }), + ); + }); }); diff --git a/tests/fork-safety.test.ts b/tests/fork-safety.test.ts index e78b0317..87d93b86 100644 --- a/tests/fork-safety.test.ts +++ b/tests/fork-safety.test.ts @@ -56,4 +56,11 @@ describe('Mainnet fork signing warning', () => { 'boundary metadata is missing', ); }); + + it('rejects a negative fork boundary', () => { + mockFork = { source: 'mainnet', forkBlockNumber: '-1' }; + expect(() => validateMainnetForkSigning(Network.devnet, accountConfig[0].privkey)).toThrow( + 'Invalid Mainnet fork boundary metadata', + ); + }); }); diff --git a/tests/node-command.test.ts b/tests/node-command.test.ts index d55f883a..fef0f2bd 100644 --- a/tests/node-command.test.ts +++ b/tests/node-command.test.ts @@ -12,6 +12,7 @@ const mockExistsSync = jest.fn(); const mockUnlinkSync = jest.fn(); const mockStatSync = jest.fn(); const mockCloseSync = jest.fn(); +const mockWaitForNodeReady = jest.fn(); jest.mock('child_process', () => ({ ...jest.requireActual('child_process'), @@ -40,7 +41,7 @@ jest.mock('../src/tools/rpc-proxy', () => ({ jest.mock('../src/devnet/readiness', () => ({ checkNodeReadiness: jest.fn().mockResolvedValue({ ready: false, rpcUrl: 'http://127.0.0.1:8114' }), - waitForNodeReady: jest.fn().mockResolvedValue({ ready: true, rpcUrl: 'http://127.0.0.1:8114', nodeTip: 0n }), + waitForNodeReady: (...args: unknown[]) => mockWaitForNodeReady(...args), })); jest.mock('../src/cfg/setting', () => ({ @@ -104,6 +105,7 @@ describe('node command daemon mode', () => { beforeEach(() => { jest.clearAllMocks(); mockReadFileSync.mockReset(); + mockWaitForNodeReady.mockResolvedValue({ ready: true, rpcUrl: 'http://127.0.0.1:8114', nodeTip: 0n }); process.argv = ['node', '/path/to/offckb', 'node', '--daemon']; mockOpenSync.mockReturnValue(3); mockStatSync.mockReturnValue({ isFile: () => true }); @@ -141,10 +143,11 @@ describe('node command daemon mode', () => { }), ); - const writtenMetadata = JSON.parse(mockWriteFileSync.mock.calls[0][1]); + const writtenMetadata = JSON.parse(mockWriteFileSync.mock.calls.at(-1)![1]); expect(writtenMetadata.pid).toBe(12345); expect(writtenMetadata.scriptPath).toBe(resolvedScriptPath); expect(writtenMetadata.startedAt).toBeDefined(); + expect(writtenMetadata.status).toBe('running'); expect(logger.success).toHaveBeenCalledWith( 'CKB devnet daemon started with PID 12345 and passed its RPC/proxy health check.', @@ -170,6 +173,23 @@ describe('node command daemon mode', () => { expect(mockSpawn).not.toHaveBeenCalled(); }); + it('atomically refuses startup when another invocation owns the PID reservation', async () => { + mockOpenSync.mockImplementation((file: string, flags: string) => { + if (file === pidFile && flags === 'wx') { + const error = new Error('EEXIST') as NodeJS.ErrnoException; + error.code = 'EEXIST'; + throw error; + } + return 3; + }); + + await expect(startNode({ network: Network.devnet, daemon: true })).rejects.toThrow( + 'startup is already in progress', + ); + + expect(mockSpawn).not.toHaveBeenCalled(); + }); + it('cleans up a stale PID file and starts a new daemon', async () => { mockReadFileSync.mockReturnValue( JSON.stringify({ pid: 9999, scriptPath: '/path/to/offckb', startedAt: new Date().toISOString() }), @@ -198,7 +218,8 @@ describe('node command daemon mode', () => { 'Failed to spawn daemon process', ); expect(mockCloseSync).toHaveBeenCalledWith(3); - expect(mockWriteFileSync).not.toHaveBeenCalled(); + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); }); it('errors when the spawned child has no PID', async () => { @@ -209,7 +230,30 @@ describe('node command daemon mode', () => { }); await expect(startNode({ network: Network.devnet, daemon: true })).rejects.toThrow('no PID returned'); - expect(mockWriteFileSync).not.toHaveBeenCalled(); + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); + }); + + it('keeps the PID reservation until failed startup termination is confirmed', async () => { + let livenessChecks = 0; + mockWaitForNodeReady.mockResolvedValueOnce({ ready: false, error: 'proxy unavailable' }); + killSpy.mockImplementation((_pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) { + livenessChecks += 1; + if (livenessChecks >= 3) { + const error = new Error('ESRCH') as NodeJS.ErrnoException; + error.code = 'ESRCH'; + throw error; + } + } + return true; + }); + + await expect(startNode({ network: Network.devnet, daemon: true })).rejects.toThrow('proxy unavailable'); + + expect(killSpy).toHaveBeenCalledWith(-12345, 'SIGTERM'); + expect(livenessChecks).toBeGreaterThanOrEqual(3); + expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); }); it('handles backward-compatible plain PID files', async () => { @@ -302,6 +346,17 @@ describe('node command stop', () => { expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); }); + it('does not signal the CLI process while daemon startup is in progress', async () => { + mockReadFileSync.mockReturnValue( + JSON.stringify({ pid: 12345, scriptPath, startedAt: new Date().toISOString(), status: 'starting' }), + ); + + await expect(stopNode()).rejects.toThrow('startup is still in progress'); + + expect(killSpy).not.toHaveBeenCalledWith(-12345, expect.anything()); + expect(mockUnlinkSync).not.toHaveBeenCalledWith(pidFile); + }); + it('stops the daemon gracefully with SIGTERM', async () => { await stopNode(); expect(killSpy).toHaveBeenCalledWith(-12345, 'SIGTERM'); @@ -356,6 +411,21 @@ describe('node command stop', () => { expect(mockUnlinkSync).not.toHaveBeenCalledWith(pidFile); }); + it('preserves the PID file when process liveness cannot be checked', async () => { + killSpy.mockImplementation((_pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) { + const err = new Error('EPERM') as NodeJS.ErrnoException; + err.code = 'EPERM'; + throw err; + } + return true; + }); + + await expect(stopNode()).rejects.toThrow('Permission denied when checking daemon process'); + + expect(mockUnlinkSync).not.toHaveBeenCalledWith(pidFile); + }); + it('cleans up the PID file when the process disappears between alive-check and SIGTERM', async () => { killSpy.mockImplementation((pid: number, signal?: NodeJS.Signals | number) => { if (signal === 0) { diff --git a/tests/node-supervisor.test.ts b/tests/node-supervisor.test.ts index 840e4a90..f6b8f10e 100644 --- a/tests/node-supervisor.test.ts +++ b/tests/node-supervisor.test.ts @@ -123,6 +123,7 @@ describe('foreground devnet supervisor', () => { await nodeDevnet({}); expect(mockMarkForkFirstRunComplete).toHaveBeenCalledWith('/tmp/offckb-devnet', '100'); + expect(mockMarkForkFirstRunComplete.mock.invocationCallOrder[0]).toBeLessThan(mockSpawn.mock.invocationCallOrder[1]); expect(mockProxyStart).toHaveBeenCalled(); }); }); From 94b2c073067c86e4ab9bb489ab36f49fd172d09a Mon Sep 17 00:00:00 2001 From: RetricSu Date: Tue, 21 Jul 2026 08:15:33 +0800 Subject: [PATCH 6/8] fix daemon test on Windows runners --- tests/node-command.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/node-command.test.ts b/tests/node-command.test.ts index fef0f2bd..a0b1357a 100644 --- a/tests/node-command.test.ts +++ b/tests/node-command.test.ts @@ -100,12 +100,18 @@ function mockDaemonCommandLine(scriptPath: string) { describe('node command daemon mode', () => { const originalArgv = process.argv; + const originalPlatform = process.platform; let killSpy: jest.SpyInstance; + function setPlatform(value: string) { + Object.defineProperty(process, 'platform', { value }); + } + beforeEach(() => { jest.clearAllMocks(); mockReadFileSync.mockReset(); mockWaitForNodeReady.mockResolvedValue({ ready: true, rpcUrl: 'http://127.0.0.1:8114', nodeTip: 0n }); + setPlatform('linux'); process.argv = ['node', '/path/to/offckb', 'node', '--daemon']; mockOpenSync.mockReturnValue(3); mockStatSync.mockReturnValue({ isFile: () => true }); @@ -126,6 +132,7 @@ describe('node command daemon mode', () => { afterEach(() => { process.argv = originalArgv; killSpy.mockRestore(); + setPlatform(originalPlatform); }); it('spawns a detached child process without the --daemon flag', async () => { From 5afeb3026c0cea0399b015ee00745e0010c432c4 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Tue, 21 Jul 2026 08:25:17 +0800 Subject: [PATCH 7/8] complete failed daemon cleanup --- src/cmd/node.ts | 6 +++++- tests/node-command.test.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/cmd/node.ts b/src/cmd/node.ts index 101103e2..68dc855e 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -557,9 +557,13 @@ async function startDaemon() { if (!exited) { await terminateProcess(child.pid, 'SIGTERM'); exited = await waitForProcessExit(child.pid, 5000); + if (!exited) { + await terminateProcess(child.pid, 'SIGKILL'); + exited = await waitForProcessExit(child.pid, 5000); + } } } catch { - // The failed child may already have exited. + // The failed child may already have exited while signals were sent. exited = !isProcessAlive(child.pid); } if (exited) { diff --git a/tests/node-command.test.ts b/tests/node-command.test.ts index a0b1357a..79ae99ea 100644 --- a/tests/node-command.test.ts +++ b/tests/node-command.test.ts @@ -243,6 +243,10 @@ describe('node command daemon mode', () => { it('keeps the PID reservation until failed startup termination is confirmed', async () => { let livenessChecks = 0; + let livenessChecksWhenPidFileRemoved: number | undefined; + mockUnlinkSync.mockImplementation((file: string) => { + if (file === pidFile) livenessChecksWhenPidFileRemoved = livenessChecks; + }); mockWaitForNodeReady.mockResolvedValueOnce({ ready: false, error: 'proxy unavailable' }); killSpy.mockImplementation((_pid: number, signal?: NodeJS.Signals | number) => { if (signal === 0) { @@ -261,6 +265,39 @@ describe('node command daemon mode', () => { expect(killSpy).toHaveBeenCalledWith(-12345, 'SIGTERM'); expect(livenessChecks).toBeGreaterThanOrEqual(3); expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); + expect(livenessChecksWhenPidFileRemoved).toBeGreaterThanOrEqual(3); + }); + + it('escalates failed startup cleanup to SIGKILL before removing the PID file', async () => { + jest.useFakeTimers(); + let processAlive = true; + mockWaitForNodeReady.mockResolvedValueOnce({ ready: false, error: 'proxy unavailable' }); + killSpy.mockImplementation((_pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) { + if (!processAlive) { + const error = new Error('ESRCH') as NodeJS.ErrnoException; + error.code = 'ESRCH'; + throw error; + } + return true; + } + if (signal === 'SIGKILL') processAlive = false; + return true; + }); + + try { + const startupFailure = expect(startNode({ network: Network.devnet, daemon: true })).rejects.toThrow( + 'proxy unavailable', + ); + await jest.advanceTimersByTimeAsync(5000); + await startupFailure; + + expect(killSpy).toHaveBeenCalledWith(-12345, 'SIGTERM'); + expect(killSpy).toHaveBeenCalledWith(-12345, 'SIGKILL'); + expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); + } finally { + jest.useRealTimers(); + } }); it('handles backward-compatible plain PID files', async () => { From e97881403a2fd986683cf4cb2a5d2f24fa746353 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Tue, 21 Jul 2026 08:35:23 +0800 Subject: [PATCH 8/8] harden daemon startup recovery --- src/cmd/node.ts | 94 +++++++++++++++++++++++++------------- tests/node-command.test.ts | 75 ++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 33 deletions(-) diff --git a/src/cmd/node.ts b/src/cmd/node.ts index 68dc855e..a9f6b477 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -451,6 +451,36 @@ function terminateProcess(pid: number, signal: 'SIGTERM' | 'SIGKILL'): Promise { + let exited = false; + try { + exited = !isProcessAlive(pid); + if (!exited) { + await terminateProcess(pid, 'SIGTERM'); + exited = await waitForProcessExit(pid, 5000); + if (!exited) { + await terminateProcess(pid, 'SIGKILL'); + exited = await waitForProcessExit(pid, 5000); + } + } + } catch { + // The child may have exited while cleanup signals were sent. If liveness + // cannot be checked, preserve the PID file for a later explicit stop. + try { + exited = !isProcessAlive(pid); + } catch { + exited = false; + } + } + + if (exited) { + cleanupPidFile(pidFile); + } else { + error.message += ` Process ${pid} is still running; PID file was preserved.`; + } + throw error; +} + async function startDaemon() { const { logDir, logFile, pidFile } = resolveDaemonPaths(); @@ -473,11 +503,17 @@ async function startDaemon() { const existing = readPidFile(pidFile); if (existing) { if (isProcessAlive(existing.pid)) { - if (existing.status === 'starting') { - throw new Error(`Another CKB devnet daemon startup is already in progress (PID ${existing.pid}).`); + const identityOk = await verifyDaemonIdentity(existing.pid, existing); + if (identityOk) { + if (existing.status === 'starting') { + throw new Error(`Another CKB devnet daemon startup is already in progress (PID ${existing.pid}).`); + } + throw new Error( + `A CKB devnet daemon is already running (PID ${existing.pid}). Stop it first with: offckb node stop`, + ); } - throw new Error( - `A CKB devnet daemon is already running (PID ${existing.pid}). Stop it first with: offckb node stop`, + logger.warn( + `PID ${existing.pid} from ${pidFile} belongs to another process; removing stale daemon metadata without signaling it.`, ); } // Stale PID file from a crashed daemon; clean it up before atomically @@ -537,42 +573,34 @@ async function startDaemon() { pid: child.pid, scriptPath, startedAt: new Date().toISOString(), - status: 'running', + status: 'starting', }; - writePidFile(pidFile, metadata); + try { + writePidFile(pidFile, metadata); + } catch (error) { + closeFileDescriptors(out, err); + return failDaemonStartup(error as Error, child.pid, pidFile); + } // File descriptors are now owned by the spawned child; close our copies. closeFileDescriptors(out, err); - const forkState = readForkState(settings.devnet.configPath); - const timeoutMs = forkState ? FORK_NODE_READY_TIMEOUT_MS : NODE_READY_TIMEOUT_MS; - // The proxy only starts after the child has a healthy CKB RPC and has - // successfully spawned the miner, so this is the daemon's service-level - // readiness check rather than a port/process check. const proxyUrl = `http://127.0.0.1:${settings.devnet.rpcProxyPort}`; - const readiness = await waitForNodeReady(proxyUrl, timeoutMs, () => isProcessAlive(child.pid!)); - if (!readiness.ready) { - let exited = !isProcessAlive(child.pid); - try { - if (!exited) { - await terminateProcess(child.pid, 'SIGTERM'); - exited = await waitForProcessExit(child.pid, 5000); - if (!exited) { - await terminateProcess(child.pid, 'SIGKILL'); - exited = await waitForProcessExit(child.pid, 5000); - } - } - } catch { - // The failed child may already have exited while signals were sent. - exited = !isProcessAlive(child.pid); - } - if (exited) { - cleanupPidFile(pidFile); + try { + const forkState = readForkState(settings.devnet.configPath); + const timeoutMs = forkState ? FORK_NODE_READY_TIMEOUT_MS : NODE_READY_TIMEOUT_MS; + // The proxy only starts after the child has a healthy CKB RPC and has + // successfully spawned the miner, so this is the daemon's service-level + // readiness check rather than a port/process check. + const readiness = await waitForNodeReady(proxyUrl, timeoutMs, () => isProcessAlive(child.pid!)); + if (!readiness.ready) { + throw new Error( + `CKB devnet daemon failed to become ready. See ${logFile}. ${readiness.error ?? 'Daemon process exited.'}`, + ); } - throw new Error( - `CKB devnet daemon failed to become ready. See ${logFile}. ${readiness.error ?? 'Daemon process exited.'}` + - (exited ? '' : ` Process ${child.pid} is still running; PID file was preserved.`), - ); + writePidFile(pidFile, { ...metadata, status: 'running' }); + } catch (error) { + return failDaemonStartup(error as Error, child.pid, pidFile); } logger.success(`CKB devnet daemon started with PID ${child.pid} and passed its RPC/proxy health check.`); diff --git a/tests/node-command.test.ts b/tests/node-command.test.ts index 79ae99ea..c7807200 100644 --- a/tests/node-command.test.ts +++ b/tests/node-command.test.ts @@ -110,9 +110,12 @@ describe('node command daemon mode', () => { beforeEach(() => { jest.clearAllMocks(); mockReadFileSync.mockReset(); + mockWriteFileSync.mockReset(); + mockUnlinkSync.mockReset(); mockWaitForNodeReady.mockResolvedValue({ ready: true, rpcUrl: 'http://127.0.0.1:8114', nodeTip: 0n }); setPlatform('linux'); process.argv = ['node', '/path/to/offckb', 'node', '--daemon']; + mockDaemonCommandLine(path.resolve('/path/to/offckb')); mockOpenSync.mockReturnValue(3); mockStatSync.mockReturnValue({ isFile: () => true }); mockSpawn.mockReturnValue({ @@ -155,6 +158,14 @@ describe('node command daemon mode', () => { expect(writtenMetadata.scriptPath).toBe(resolvedScriptPath); expect(writtenMetadata.startedAt).toBeDefined(); expect(writtenMetadata.status).toBe('running'); + const childStatuses = mockWriteFileSync.mock.calls + .map(([, data]) => JSON.parse(data)) + .filter((metadata) => metadata.pid === 12345) + .map((metadata) => metadata.status); + expect(childStatuses).toEqual(['starting', 'running']); + expect(mockWaitForNodeReady.mock.invocationCallOrder[0]).toBeLessThan( + mockWriteFileSync.mock.invocationCallOrder.at(-1)!, + ); expect(logger.success).toHaveBeenCalledWith( 'CKB devnet daemon started with PID 12345 and passed its RPC/proxy health check.', @@ -180,6 +191,22 @@ describe('node command daemon mode', () => { expect(mockSpawn).not.toHaveBeenCalled(); }); + it('removes reused PID metadata without signaling the unrelated process', async () => { + mockReadFileSync.mockReturnValue( + JSON.stringify({ pid: 9999, scriptPath: '/path/to/offckb', startedAt: new Date().toISOString() }), + ); + mockExec.mockImplementation((_cmd: string, callback: (err: Error | null, stdout?: string) => void) => { + callback(null, '/usr/bin/some-unrelated-process'); + return undefined as unknown as ReturnType; + }); + + await startNode({ network: Network.devnet, daemon: true }); + + expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); + expect(killSpy).not.toHaveBeenCalledWith(-9999, expect.anything()); + expect(mockSpawn).toHaveBeenCalled(); + }); + it('atomically refuses startup when another invocation owns the PID reservation', async () => { mockOpenSync.mockImplementation((file: string, flags: string) => { if (file === pidFile && flags === 'wx') { @@ -241,6 +268,54 @@ describe('node command daemon mode', () => { expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); }); + it('terminates the detached child when readiness checking throws', async () => { + let processAlive = true; + mockWaitForNodeReady.mockRejectedValueOnce(new Error('readiness check failed')); + killSpy.mockImplementation((_pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) { + if (!processAlive) { + const error = new Error('ESRCH') as NodeJS.ErrnoException; + error.code = 'ESRCH'; + throw error; + } + return true; + } + if (signal === 'SIGTERM') processAlive = false; + return true; + }); + + await expect(startNode({ network: Network.devnet, daemon: true })).rejects.toThrow('readiness check failed'); + + expect(killSpy).toHaveBeenCalledWith(-12345, 'SIGTERM'); + expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); + }); + + it('terminates the detached child when child PID metadata cannot be written', async () => { + let processAlive = true; + mockWriteFileSync.mockImplementation((file: number | string, data: string) => { + const metadata = JSON.parse(data); + if (file === pidFile && metadata.pid === 12345) throw new Error('PID write failed'); + }); + killSpy.mockImplementation((_pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) { + if (!processAlive) { + const error = new Error('ESRCH') as NodeJS.ErrnoException; + error.code = 'ESRCH'; + throw error; + } + return true; + } + if (signal === 'SIGTERM') processAlive = false; + return true; + }); + + await expect(startNode({ network: Network.devnet, daemon: true })).rejects.toThrow('PID write failed'); + + expect(killSpy).toHaveBeenCalledWith(-12345, 'SIGTERM'); + expect(mockWaitForNodeReady).not.toHaveBeenCalled(); + expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); + }); + it('keeps the PID reservation until failed startup termination is confirmed', async () => { let livenessChecks = 0; let livenessChecksWhenPidFileRemoved: number | undefined;