From 8f8d0a47273cb8e64d8f7d38fcb2632d1220b31b Mon Sep 17 00:00:00 2001 From: RetricSu Date: Tue, 21 Jul 2026 10:56:07 +0800 Subject: [PATCH 1/5] fix canary daemon, UDT, and fork reliability (#457) * fix canary devrel reliability issues * fix fork copy isolation on windows * remove implicit fork source discovery * address PR review feedback * address follow-up review feedback * fix daemon test on Windows runners * complete failed daemon cleanup * harden daemon startup recovery --- .changeset/clean-pandas-report.md | 5 + README.md | 53 +++- src/cli.ts | 139 +++++++++-- 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 | 24 +- src/cmd/devnet-config.ts | 19 +- src/cmd/devnet-fork.ts | 12 +- src/cmd/devnet-info.ts | 53 ++++ src/cmd/node.ts | 367 +++++++++++++++++++++------- src/cmd/status.ts | 54 +--- src/cmd/transfer-all.ts | 17 +- src/cmd/transfer.ts | 48 +++- src/cmd/udt.ts | 63 +++-- src/devnet/fork.ts | 249 +++++++++++++++---- src/devnet/readiness.ts | 107 ++++++++ src/node/init-chain.ts | 30 ++- src/node/install.ts | 1 + src/sdk/ckb.ts | 52 +++- src/tools/ckb-tui.ts | 92 +++---- src/util/fork-safety.ts | 68 ++++++ src/util/fs.ts | 18 +- src/util/logger.ts | 49 +++- src/util/private-key.ts | 25 ++ src/util/validator.ts | 13 +- tests/accounts.test.ts | 48 ++++ tests/ckb-tui-checksum.test.ts | 52 ++++ tests/deposit.test.ts | 68 ++++++ tests/devnet-config-command.test.ts | 56 ++--- tests/devnet-fork.test.ts | 65 ++++- tests/devnet-info.test.ts | 52 ++++ tests/fork-safety.test.ts | 66 +++++ tests/init-chain.test.ts | 61 +++++ tests/logger.test.ts | 38 +++ tests/node-command.test.ts | 252 ++++++++++++++++--- tests/node-supervisor.test.ts | 129 ++++++++++ tests/private-key.test.ts | 37 +++ tests/readiness-warning.test.ts | 42 ++++ tests/readiness.test.ts | 50 ++++ tests/sdk/ckb.udt.test.ts | 51 +++- tests/status.test.ts | 58 +++++ tests/udt.test.ts | 82 +++++-- tests/validator.test.ts | 8 +- 48 files changed, 2386 insertions(+), 510 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/deposit.test.ts create mode 100644 tests/devnet-info.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..b2e285f1 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,21 +391,31 @@ Pay attention to the `devnet.configPath` and `devnet.dataPath`. You can fork an existing Mainnet/Testnet data directory into your local devnet, so it keeps the real on-chain state (deployed contracts, cells) while mining locally with Dummy PoW. This implements the same flow as [Devnet From Existing Data](https://docs.nervos.org/docs/node/devnet-from-existing-data). ```sh +# Point at the directory used by the source node's `ckb -C`: +offckb devnet fork --from /path/to/ckb-data --dry-run offckb devnet fork --from /path/to/ckb-data -offckb node +offckb node --daemon +offckb devnet info ``` -- `--from` points at the directory the source node runs with (`-C`), which must contain `data/db`. Stop the source node first. +- Database fork mode requires `--from`; it points at the directory the source node runs with (`ckb -C`), which must contain `data/db`. Keeping the source explicit makes large database copies predictable in local scripts and CI. +- Stop the source node first. Use `--dry-run` to validate the source chain, CKB/DB compatibility, migration requirement, and target without replacing the current devnet. - The source chain is auto-detected from the source `ckb.toml`; pass `--source mainnet|testnet` when it cannot be detected, and `--spec-file ` 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] > 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 @@ -455,12 +477,21 @@ LOG_LEVEL=debug offckb node ## Accounts -OffCKB comes with 20 pre-funded accounts, each initialized with `42_000_000_00000000` capacity in the genesis block. +On a pure OffCKB devnet, OffCKB comes with 20 pre-funded accounts, each initialized with `42_000_000_00000000` capacity in the genesis block. A fork keeps the source chain genesis and therefore has no OffCKB genesis allocation; built-in dev accounts are funded by locally mined cellbase cells instead. + +```sh +offckb accounts +offckb accounts --show-private-keys # trusted local terminals only +``` + +On a Mainnet fork, `accounts` re-encodes the same dev lock scripts with the `ckb` address prefix. Once the Indexer is caught up it also reports each account's spendable pure-CKB balance; until then the field is omitted with a warning. Private keys are hidden by default so JSON and agent logs do not collect them. - All private keys are stored in the `account/keys` file. - Detailed information for each account is recorded in `account/account.json`. - When deploying contracts, the deployment cost are automatically deducted from these pre-funded accounts. This allows you to test deployments without faucets or manual funding. +For commands that accept a private key, prefer `--privkey-file ` 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..a174a65b 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,32 @@ 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') - .addOption(new Option('--udt-kind ', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt')) + .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'])) .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) => { - 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 +191,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 +203,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 +215,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 +234,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 +261,77 @@ devnetCommand ) .action(devnetConfig); +devnetCommand + .command('info') + .description('Show fork metadata and node/indexer readiness') + .action(async () => { + await devnetInfo(); + }); + devnetCommand .command('fork') .description('Fork an existing mainnet/testnet chain data directory into the local devnet') - .requiredOption('--from ', 'Path to the source CKB node directory (the one passed to ckb -C)') + .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)') + .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..9e2396da 100644 --- a/src/cmd/deposit.ts +++ b/src/cmd/deposit.ts @@ -6,9 +6,13 @@ 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 { validateMainnetForkSigning } from '../util/fork-safety'; export interface DepositOptions extends NetworkOption {} +const TESTNET_FAUCET_CLAIM_AMOUNT = '10000'; + export async function deposit( toAddress: string, amountInCKB: string, @@ -20,17 +24,32 @@ 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, + source: 'fixed-testnet-faucet-claim', + requestedAmount: amountInCKB, + faucetClaimAmount: TESTNET_FAUCET_CLAIM_AMOUNT, + toAddress, + txHash, + }); + return txHash; } // deposit from devnet miner const privateKey = ckbDevnetMinerAccount.privkey; + 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 }); + return txHash; } async function depositFromTestnetFaucet(ckbAddress: string, ckb: CKB) { @@ -59,6 +78,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) { @@ -75,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/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..7b50683f --- /dev/null +++ b/src/cmd/devnet-info.ts @@ -0,0 +1,53 @@ +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) { + 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) + 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..a9f6b477 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; @@ -22,12 +22,21 @@ interface PidMetadata { pid: number; scriptPath: string; startedAt: string; + status?: 'starting' | 'running'; } 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 +68,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 +85,101 @@ 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; + } + 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(); + 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() { @@ -167,7 +221,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 { @@ -208,6 +265,7 @@ function readPidFile(pidFile: string): PidMetadata | null { pid, scriptPath: parsed.scriptPath, startedAt: parsed.startedAt ?? new Date(0).toISOString(), + status: parsed.status, }; } } catch { @@ -223,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. @@ -247,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; } } @@ -265,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) { @@ -352,37 +451,93 @@ 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(); + try { + fs.mkdirSync(logDir, { recursive: true }); + } catch (error) { + throw new Error(`Failed to prepare daemon log directory at ${logDir}: ${(error as Error).message}`); + } + + const settings = readSettings(); + const activeNode = await checkNodeReadiness(settings.devnet.rpcUrl, 1000); + if (activeNode.ready) { + throw new Error( + `A CKB node is already answering at ${settings.devnet.rpcUrl}. Stop it before starting daemon mode.`, + ); + } + // Prevent duplicate daemon starts. If a daemon is already running, refuse // to overwrite its PID file. const existing = readPidFile(pidFile); - if (existing && isProcessAlive(existing.pid)) { - logger.error(`A CKB devnet daemon is already running (PID ${existing.pid}). Stop it first with: offckb node stop`); - return; - } - if (existing && !isProcessAlive(existing.pid)) { - // Stale PID file from a crashed daemon; clean it up before starting anew. + if (existing) { + if (isProcessAlive(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`, + ); + } + 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 + // reserving the same control file for this startup attempt. cleanupPidFile(pidFile); } + const scriptPath = resolveCliEntry(); + if (!scriptPath) { + throw new Error( + 'Unable to determine the CLI entry point for daemon mode. Set OFFCKB_CLI_PATH to the offckb script.', + ); + } + reservePidFile(pidFile, scriptPath); + let out: number | undefined; let err: number | undefined; try { - fs.mkdirSync(logDir, { recursive: true }); out = fs.openSync(logFile, 'a'); err = fs.openSync(logFile, 'a'); } catch (error) { - logger.error(`Failed to prepare daemon log directory or log file at ${logFile}:`, error); - return; - } - - const scriptPath = resolveCliEntry(); - if (!scriptPath) { - logger.error('Unable to determine the CLI entry point for daemon mode. Set OFFCKB_CLI_PATH to the offckb script.'); closeFileDescriptors(out, err); - return; + cleanupPidFile(pidFile); + throw new Error(`Failed to prepare daemon log directory or log file at ${logFile}: ${(error as Error).message}`); } const childArgs = process.argv.slice(2).filter((arg) => arg !== '--daemon'); @@ -396,15 +551,15 @@ function startDaemon() { env: childEnv, }); } catch (error) { - logger.error('Failed to spawn daemon process:', error); closeFileDescriptors(out, err); - return; + cleanupPidFile(pidFile); + 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; + cleanupPidFile(pidFile); + throw new Error('Failed to spawn daemon process: no PID returned.'); } child.unref(); @@ -418,16 +573,50 @@ function startDaemon() { pid: child.pid, scriptPath, startedAt: new Date().toISOString(), + 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); - logger.success(`CKB devnet daemon started with PID ${child.pid}.`); + const proxyUrl = `http://127.0.0.1:${settings.devnet.rpcProxyPort}`; + 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.'}`, + ); + } + 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.`); 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 +636,33 @@ 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)) { + 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) { - 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 +673,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 +688,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..0be49112 100644 --- a/src/cmd/transfer.ts +++ b/src/cmd/transfer.ts @@ -1,46 +1,72 @@ 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 { 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 }) { 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.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); + udtTypeArgs = validateUdtTypeArgs(udtKind, opt.udtTypeArgs); } - const privateKey = opt.privkey; + const privateKey = resolvePrivateKey(opt); + const rejectInputsAtOrBeforeBlock = validateMainnetForkSigning(network, privateKey, opt.allowMainnetReplayRisk); + 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, + rejectInputsAtOrBeforeBlock, }); 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({ toAddress, amountInCKB: amount, privateKey, + rejectInputsAtOrBeforeBlock, }); 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..1200bea9 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,18 @@ export interface ForkState { genesisHash: string; forkedAt: string; firstRunPending: boolean; + forkBlockNumber?: string; + 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'; @@ -65,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 { @@ -244,7 +254,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 +262,28 @@ 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.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.'); +} + +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 +292,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 +354,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 +400,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'); @@ -300,6 +416,9 @@ 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; @@ -307,18 +426,6 @@ export async function forkDevnet(options: ForkOptions): Promise { const sourceDir = path.resolve(options.from); validateSourceDir(sourceDir); assertSourceNodeStopped(sourceDir); - assertOffckbDevnetStopped(); - - if (isFolderExists(configPath)) { - if (!options.force) { - throw new Error( - `A devnet already exists at ${configPath}. Re-run with --force to replace it, ` + - `or reset it first with: offckb clean`, - ); - } - logger.info(`Removing existing devnet at ${configPath} ..`); - fs.rmSync(configPath, { recursive: true, force: true }); - } // Identify the source chain: explicit flag > ckb.toml bundled spec. let source: ForkSource | null = options.source ?? null; @@ -337,6 +444,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 +498,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 +518,10 @@ export async function forkDevnet(options: ForkOptions): Promise { alignConfigsWithOffckb(configPath); + if (databaseMigrationNeeded) { + migrateDatabaseCopy(ckbBinPath, configPath); + } + const state: ForkState = { source, sourceDir, @@ -399,12 +529,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..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 { @@ -60,6 +62,12 @@ export interface UdtIssueOption { toAddress?: string; } +export interface UdtIssueResult { + txHash: HexString; + typeArgs: HexString; + receiver: string; +} + export interface UdtDestroyOption { privateKey: HexString; kind: UdtKind; @@ -172,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({ @@ -185,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; } @@ -348,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); @@ -389,11 +410,32 @@ export class CKB { } await tx.completeFeeBy(signer, this.feeRate); + await this.assertInputsCreatedAfter(tx, rejectInputsAtOrBeforeBlock); const txHash = await signer.sendTransaction(tx); return txHash; } - async udtIssue({ privateKey, kind, amount, typeArgs, toAddress }: UdtIssueOption): Promise { + 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(); const to = toAddress ? await ccc.Address.fromString(toAddress, this.client) : signerAddress; @@ -405,7 +447,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 +477,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..086719fa 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,75 +210,28 @@ 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 an independently pinned digest. */ private static verifyChecksum(version: string, assetName: string, archivePath: string): void { - 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) { - logger.warn( - `SHA-256 checksum file not available for version ${version}. ` + - 'Skipping integrity verification. Consider asking the upstream maintainer to publish checksum files.', + const pinnedHash = KNOWN_SHA256[version]?.[assetName]; + if (!pinnedHash) { + throw new Error( + `No trusted SHA-256 checksum is pinned for ckb-tui ${version} (${assetName}). ` + + 'Refusing to install an unverified binary.', ); - return; - } - - try { - const checksumContent = fs.readFileSync(checksumPath, 'utf8'); - const expectedHash = this.parseChecksumFile(checksumContent, assetName); - - if (!expectedHash) { - logger.warn(`Checksum entry for "${assetName}" not found in checksums file. Skipping verification.`); - return; - } - - 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.'); - } finally { - try { - fs.unlinkSync(checksumPath); - } catch { - // Best effort - } } + this.assertChecksum(archivePath, assetName, pinnedHash); } - /** - * 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(); - } + 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.', + ); } - return null; + logger.info('SHA-256 checksum verified successfully.'); } /** diff --git a/src/util/fork-safety.ts b/src/util/fork-safety.ts new file mode 100644 index 00000000..b5508dd5 --- /dev/null +++ b/src/util/fork-safety.ts @@ -0,0 +1,68 @@ +import accountConfig from '../../account/account.json'; +import { ckbDevnetMinerAccount } from '../cfg/account'; +import { readSettings } from '../cfg/setting'; +import { ForkState, 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 (!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 { + 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}`); + } +} + +function readMainnetForkState(network: Network): ForkState | null { + if (network !== Network.devnet) return null; + const settings = readSettings(); + 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.', + '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..8a6955ec 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( @@ -55,6 +61,7 @@ class UnifiedLogger { }), ], }); + this.setJsonMode(this.jsonMode); } /** @@ -63,6 +70,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..e17bbfdf --- /dev/null +++ b/tests/ckb-tui-checksum.test.ts @@ -0,0 +1,52 @@ +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 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', + 'ckb-tui-with-node-macos-aarch64.tar.gz', + 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..d1c9b80f --- /dev/null +++ b/tests/deposit.test.ts @@ -0,0 +1,68 @@ +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' } } }), +}); + +jest.mock('../src/sdk/ckb', () => ({ + CKB: jest.fn().mockImplementation(() => ({ + buildSecp256k1Address: mockBuildAddress, + waitForTxConfirm: mockWaitForTxConfirm, + transferAll: mockTransferAll, + transfer: mockTransfer, + })), +})); +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', () => ({ + validateMainnetForkSigning: (...args: unknown[]) => mockValidateMainnetForkSigning(...args), +})); + +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, + 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/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..03feb6e7 100644 --- a/tests/devnet-fork.test.ts +++ b/tests/devnet-fork.test.ts @@ -11,6 +11,10 @@ import { readForkState, writeForkState, ForkState, + copySourceData, + isolateForkCkbConfig, + migrationNeededFromExitCode, + forkDevnet, } from '../src/devnet/fork'; import { identifyPublicChainByGenesisHash, MAINNET_GENESIS_HASH, TESTNET_GENESIS_HASH } from '../src/scripts/const'; @@ -151,6 +155,61 @@ 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 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'); + }); +}); + describe('fork state file', () => { let dir: string; beforeEach(() => { @@ -183,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 new file mode 100644 index 00000000..87d93b86 --- /dev/null +++ b/tests/fork-safety.test.ts @@ -0,0 +1,66 @@ +let mockFork: { source: 'mainnet' | 'testnet'; forkBlockNumber?: string } | 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 { validateMainnetForkSigning, 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(); + }); + + 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', + ); + }); + + 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/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..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] }); @@ -53,4 +63,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..c7807200 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'), @@ -38,6 +39,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: (...args: unknown[]) => mockWaitForNodeReady(...args), +})); + jest.mock('../src/cfg/setting', () => ({ readSettings: () => ({ devnet: { @@ -64,6 +70,7 @@ jest.mock('../src/util/logger', () => ({ warn: jest.fn(), error: jest.fn(), debug: jest.fn(), + result: jest.fn(), setJsonMode: jest.fn(), }, })); @@ -93,12 +100,22 @@ 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(); + 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({ @@ -118,10 +135,11 @@ describe('node command daemon mode', () => { afterEach(() => { process.argv = originalArgv; killSpy.mockRestore(); + setPlatform(originalPlatform); }); - 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'); @@ -135,16 +153,27 @@ 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'); + 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.'); + 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 +181,50 @@ 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('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') { + 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() }), ); @@ -176,41 +237,145 @@ 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(); + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); }); - 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 }); + await expect(startNode({ network: Network.devnet, daemon: true })).rejects.toThrow('no PID returned'); + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + 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; + 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) { + 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(logger.error).toHaveBeenCalledWith(expect.stringContaining('no PID returned')); - expect(mockWriteFileSync).not.toHaveBeenCalled(); + expect(killSpy).toHaveBeenCalledWith(-12345, 'SIGTERM'); + expect(livenessChecks).toBeGreaterThanOrEqual(3); + expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); + expect(livenessChecksWhenPidFileRemoved).toBeGreaterThanOrEqual(3); }); - it('handles backward-compatible plain PID files', () => { + 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 () => { mockReadFileSync.mockReturnValue('9999'); killSpy.mockImplementation((pid: number, signal?: NodeJS.Signals | number) => { if (signal === 0) { @@ -221,7 +386,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 +454,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); }); @@ -301,6 +465,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'); @@ -334,14 +509,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 +525,24 @@ 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('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 () => { diff --git a/tests/node-supervisor.test.ts b/tests/node-supervisor.test.ts new file mode 100644 index 00000000..f6b8f10e --- /dev/null +++ b/tests/node-supervisor.test.ts @@ -0,0 +1,129 @@ +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'), + 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: () => 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' }), +})); +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; + mockForkState = null; + 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); + }); + + 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(mockMarkForkFirstRunComplete.mock.invocationCallOrder[0]).toBeLessThan(mockSpawn.mock.invocationCallOrder[1]); + expect(mockProxyStart).toHaveBeenCalled(); + }); +}); 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..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', () => { @@ -116,9 +118,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,13 +175,56 @@ 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')); 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/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..03f8f567 100644 --- a/tests/udt.test.ts +++ b/tests/udt.test.ts @@ -5,8 +5,9 @@ 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 }; +const mockValidateMainnetForkSigning = jest.fn().mockReturnValue(undefined); jest.mock('../src/sdk/ckb', () => { return { @@ -24,7 +25,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,20 +42,26 @@ 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(), + 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 () => { - const exitSpy = mockProcessExit(); await balanceOf('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', { network: Network.devnet, }); @@ -61,12 +72,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 +85,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,14 +96,13 @@ 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(); }); }); describe('transfer command', () => { beforeEach(() => { jest.clearAllMocks(); + mockValidateMainnetForkSigning.mockReturnValue(undefined); }); it('should transfer CKB by default', async () => { @@ -112,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, @@ -126,13 +148,37 @@ 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', { network: Network.devnet, - udtTypeArgs: '0xabcd', + udtTypeArgs: mockTypeArgs, }), - ).rejects.toThrow('--privkey is required!'); + ).rejects.toThrow('--privkey-file'); }); }); @@ -149,7 +195,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 +220,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 1a362aa4ae6f3cd4431831118938e97b4a8fad10 Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Tue, 21 Jul 2026 12:34:04 +0800 Subject: [PATCH 2/5] chore(release): bump version to 0.4.9 (#458) --- .changeset/brave-falcons-dance.md | 10 --------- .changeset/clean-pandas-report.md | 5 ----- .changeset/devnet-fork.md | 10 --------- .changeset/fix-dependabot-alerts.md | 14 ------------ .changeset/integrate-ckb-tui.md | 5 ----- .changeset/tasty-walls-appear.md | 5 ----- CHANGELOG.md | 33 +++++++++++++++++++++++++++++ package.json | 2 +- 8 files changed, 34 insertions(+), 50 deletions(-) delete mode 100644 .changeset/brave-falcons-dance.md delete mode 100644 .changeset/clean-pandas-report.md delete mode 100644 .changeset/devnet-fork.md delete mode 100644 .changeset/fix-dependabot-alerts.md delete mode 100644 .changeset/integrate-ckb-tui.md delete mode 100644 .changeset/tasty-walls-appear.md diff --git a/.changeset/brave-falcons-dance.md b/.changeset/brave-falcons-dance.md deleted file mode 100644 index 4f1723b0..00000000 --- a/.changeset/brave-falcons-dance.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@offckb/cli": patch ---- - -Add daemon mode and structured JSON output for agent-friendly usage, plus a `node stop` command to terminate the daemon. - -- `offckb node --daemon` starts the CKB devnet as a detached background process and writes the PID and logs to the devnet data folder. -- `offckb --json ` emits structured JSON log output for programmatic consumption. -- `offckb node stop` reads the daemon PID file and gracefully shuts down the daemon, falling back to force-kill if necessary. It now verifies the target process identity, handles stale PID files, and cleans up on error paths. -- Hardened daemon lifecycle: duplicate daemon starts are rejected, CLI entry resolution supports packaged/npx environments via `OFFCKB_CLI_PATH`, and log/PID directory creation failures are handled gracefully. diff --git a/.changeset/clean-pandas-report.md b/.changeset/clean-pandas-report.md deleted file mode 100644 index 701c0deb..00000000 --- a/.changeset/clean-pandas-report.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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/.changeset/devnet-fork.md b/.changeset/devnet-fork.md deleted file mode 100644 index 9530c581..00000000 --- a/.changeset/devnet-fork.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@offckb/cli': minor ---- - -Add `offckb devnet fork` to fork an existing mainnet/testnet data directory into the local devnet ([Devnet From Existing Data](https://docs.nervos.org/docs/node/devnet-from-existing-data) flow), plus fork-aware system scripts and local-first debugging. - -- `offckb devnet fork --from [--source mainnet|testnet] [--spec-file ] [--force]` copies the source chain data, imports the matching chain spec, patches it for local mining (Dummy PoW, `cellbase_maturity = 0`, correct `genesis_epoch_length` per chain), verifies the genesis hash, and records the fork state. The first `offckb node` run boots with `--skip-spec-check --overwrite-spec` automatically; `offckb clean` resets back to a pure devnet. -- Devnet system scripts now self-identify the chain via the genesis hash in `ckb list-hashes`: on a mainnet/testnet fork, genesis scripts come from the chain's own spec and post-genesis deployments (sudt/xudt/omnilock/spore/…) are filled from the well-known static records, so `system-scripts`, transfers, deploys and fee estimation keep working on a fork. -- The devnet ccc client follows the forked chain too: a mainnet fork uses the `ckb` address prefix. -- `offckb debug` falls back to fetching the transaction from the node when it is not in the local proxy cache, and the tx dumper now embeds full header objects in `mock_info.header_deps` (previously bare hashes, which broke debugging for transactions with header deps). diff --git a/.changeset/fix-dependabot-alerts.md b/.changeset/fix-dependabot-alerts.md deleted file mode 100644 index 767e0772..00000000 --- a/.changeset/fix-dependabot-alerts.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@offckb/cli": patch ---- - -Resolve Dependabot security alerts via pnpm overrides for transitive dependencies: - -- `qs` 6.15.0 → 6.15.2 -- `ip-address` 10.1.0 → 10.1.1 -- `js-yaml` 3.14.2 → 3.15.0 / 4.1.1 → 4.2.0 -- `@babel/core` 7.28.6 → 7.29.7 -- `@eslint/plugin-kit` 0.2.8 → 0.3.4 -- `brace-expansion` 5.0.5 → 5.0.6 - -`elliptic` remains unfixed because a patched version (>=6.6.2) is not yet published on npm. diff --git a/.changeset/integrate-ckb-tui.md b/.changeset/integrate-ckb-tui.md deleted file mode 100644 index 899a71d7..00000000 --- a/.changeset/integrate-ckb-tui.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@offckb/cli": minor ---- - -Add `status` command to launch ckb-tui for monitoring CKB network from your node diff --git a/.changeset/tasty-walls-appear.md b/.changeset/tasty-walls-appear.md deleted file mode 100644 index 293777b4..00000000 --- a/.changeset/tasty-walls-appear.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@offckb/cli": minor ---- - -Refactor UDT CLI support: reuse `balance` and `transfer` commands for CKB and UDT queries, and add `offckb udt issue` / `offckb udt destroy` subcommands. diff --git a/CHANGELOG.md b/CHANGELOG.md index b5288319..24d5dcc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # @offckb/cli +## 0.4.9 + +### Patch Changes + +- 6f54296: Add daemon mode and structured JSON output for agent-friendly usage, plus a `node stop` command to terminate the daemon. + + - `offckb node --daemon` starts the CKB devnet as a detached background process and writes the PID and logs to the devnet data folder. + - `offckb --json ` emits structured JSON log output for programmatic consumption. + - `offckb node stop` reads the daemon PID file and gracefully shuts down the daemon, falling back to force-kill if necessary. It now verifies the target process identity, handles stale PID files, and cleans up on error paths. + - Hardened daemon lifecycle: duplicate daemon starts are rejected, CLI entry resolution supports packaged/npx environments via `OFFCKB_CLI_PATH`, and log/PID directory creation failures are handled gracefully. + +- 8f8d0a4: 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. +- b3fe233: Add `offckb devnet fork` to fork an existing mainnet/testnet data directory into the local devnet ([Devnet From Existing Data](https://docs.nervos.org/docs/node/devnet-from-existing-data) flow), plus fork-aware system scripts and local-first debugging. + + - `offckb devnet fork --from [--source mainnet|testnet] [--spec-file ] [--force]` copies the source chain data, imports the matching chain spec, patches it for local mining (Dummy PoW, `cellbase_maturity = 0`, correct `genesis_epoch_length` per chain), verifies the genesis hash, and records the fork state. The first `offckb node` run boots with `--skip-spec-check --overwrite-spec` automatically; `offckb clean` resets back to a pure devnet. + - Devnet system scripts now self-identify the chain via the genesis hash in `ckb list-hashes`: on a mainnet/testnet fork, genesis scripts come from the chain's own spec and post-genesis deployments (sudt/xudt/omnilock/spore/…) are filled from the well-known static records, so `system-scripts`, transfers, deploys and fee estimation keep working on a fork. + - The devnet ccc client follows the forked chain too: a mainnet fork uses the `ckb` address prefix. + - `offckb debug` falls back to fetching the transaction from the node when it is not in the local proxy cache, and the tx dumper now embeds full header objects in `mock_info.header_deps` (previously bare hashes, which broke debugging for transactions with header deps). + +- 303f54e: Resolve Dependabot security alerts via pnpm overrides for transitive dependencies: + + - `qs` 6.15.0 → 6.15.2 + - `ip-address` 10.1.0 → 10.1.1 + - `js-yaml` 3.14.2 → 3.15.0 / 4.1.1 → 4.2.0 + - `@babel/core` 7.28.6 → 7.29.7 + - `@eslint/plugin-kit` 0.2.8 → 0.3.4 + - `brace-expansion` 5.0.5 → 5.0.6 + + `elliptic` remains unfixed because a patched version (>=6.6.2) is not yet published on npm. + +- 43ce3a3: Add `status` command to launch ckb-tui for monitoring CKB network from your node +- 463b2ff: Refactor UDT CLI support: reuse `balance` and `transfer` commands for CKB and UDT queries, and add `offckb udt issue` / `offckb udt destroy` subcommands. + ## 0.4.8 ### Patch Changes diff --git a/package.json b/package.json index f708c0fd..92f4982e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@offckb/cli", - "version": "0.4.8", + "version": "0.4.9", "description": "ckb development network for your first try", "author": "CKB EcoFund", "license": "MIT", From 699a8508b4494083972f93457e2b4f3fb67b4f92 Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Thu, 23 Jul 2026 11:58:56 +0800 Subject: [PATCH 3/5] fix(status): enable Terminal RPC module and TCP streaming for devnet (#463) (#464) * fix(status): enable Terminal RPC module and TCP streaming for devnet ckb-tui panels were always empty on devnet because the bundled devnet ckb.toml did not meet ckb-tui's two data requirements: - the Terminal RPC module (provides get_overview system metrics), which upstream CKB now enables by default, was missing from rpc.modules, so the overview dashboards showed N/A - rpc.tcp_listen_address was commented out and the status command never passed -t, so the mempool (new/rejected transactions) and logs dashboards had no subscription stream to read from Enable both in the devnet config template and have the status command read tcp_listen_address from the running node's ckb.toml and pass it to ckb-tui via -t (wildcard binds are dialed as localhost). Testnet and mainnet keep HTTP-only behavior since their proxied public RPCs expose no TCP stream. * chore: add patch changeset for status devnet fix * fix(devnet): bind RPC to loopback instead of 0.0.0.0 Address CodeRabbit review on PR #463: with the Terminal module enabled, binding the unauthenticated JSON-RPC to 0.0.0.0 exposes host system metrics (and the rest of the RPC surface) to any host on the network. Bind to 127.0.0.1 by default; all offckb-internal consumers (proxy, ckb-tui, miner, forks) already talk to 127.0.0.1:8114. Users who need remote access can edit rpc.listen_address via the config editor. * fix(devnet): align embedded reference template with devnet ckb.toml Add Terminal to rpc.modules and enable tcp_listen_address in the config editor's embedded template so configurations based on it also provide the metrics stream that offckb status needs. --------- Co-authored-by: claude-bear --- .changeset/olive-donkeys-cheer.md | 5 +++ ckb/devnet/ckb.toml | 11 +++-- src/cmd/status.ts | 34 ++++++++++++++- src/tui/devnet-reference-templates.ts | 11 +++-- tests/status.test.ts | 59 ++++++++++++++++++++++++--- 5 files changed, 106 insertions(+), 14 deletions(-) create mode 100644 .changeset/olive-donkeys-cheer.md diff --git a/.changeset/olive-donkeys-cheer.md b/.changeset/olive-donkeys-cheer.md new file mode 100644 index 00000000..478cd24a --- /dev/null +++ b/.changeset/olive-donkeys-cheer.md @@ -0,0 +1,5 @@ +--- +'@offckb/cli': patch +--- + +Fix the `status` command showing missing data on devnet: enable the Terminal RPC module and the TCP listen address in the devnet `ckb.toml` template (and the config editor's embedded reference template), and pass the node's TCP listen address to ckb-tui so the system metrics, mempool, and log panels populate correctly. The devnet RPC now binds to `127.0.0.1` instead of `0.0.0.0` so the unauthenticated RPC (including the new host metrics) is no longer reachable from other machines on the network; edit `rpc.listen_address` in the devnet `ckb.toml` if you rely on remote access. diff --git a/ckb/devnet/ckb.toml b/ckb/devnet/ckb.toml index 5cfd4a45..27ad5259 100644 --- a/ckb/devnet/ckb.toml +++ b/ckb/devnet/ckb.toml @@ -80,16 +80,19 @@ support_protocols = ["Ping", "Discovery", "Identify", "Feeler", "DisconnectMessa # # Allowing arbitrary machines to access the JSON-RPC port is dangerous and strongly discouraged. # Please strictly limit the access to only trusted machines. -listen_address = "0.0.0.0:8114" +listen_address = "127.0.0.1:8114" # Default is 10MiB = 10 * 1024 * 1024 max_request_body_size = 10485760 -# List of API modules: ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer"] -modules = ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer"] +# List of API modules: ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer", "RichIndexer", "Terminal"] +# "Terminal" powers ckb-tui's `get_overview` system metrics; without it those panels show N/A. +modules = ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer", "Terminal"] # By default RPC only binds to HTTP service, you can bind it to TCP and WebSocket. -# tcp_listen_address = "127.0.0.1:18114" +# The TCP service streams subscription topics (new_transaction, rejected_transaction, log, ...) +# that ckb-tui's mempool and logs dashboards rely on. +tcp_listen_address = "127.0.0.1:18114" # ws_listen_address = "127.0.0.1:28114" reject_ill_transactions = true diff --git a/src/cmd/status.ts b/src/cmd/status.ts index d247e492..3bd331e8 100644 --- a/src/cmd/status.ts +++ b/src/cmd/status.ts @@ -1,3 +1,6 @@ +import fs from 'fs'; +import path from 'path'; +import toml, { JsonMap } from '@iarna/toml'; import { readSettings } from '../cfg/setting'; import { CKBTui } from '../tools/ckb-tui'; import { Network } from '../type/base'; @@ -15,6 +18,28 @@ const NETWORK_SETTINGS_KEY: Record = { [Network.mainnet]: 'mainnet', }; +/** + * Best-effort lookup of the devnet node's TCP subscription endpoint from its + * ckb.toml. ckb-tui connects to it directly (the OffCKB proxy is HTTP-only) to + * stream new/rejected transactions and logs; when absent, those dashboards + * simply stay empty, so any failure here is non-fatal. + */ +function devnetTcpListenAddress(): string | undefined { + try { + const settings = readSettings(); + const ckbTomlPath = path.join(settings.devnet.configPath, 'ckb.toml'); + if (!fs.existsSync(ckbTomlPath)) return undefined; + const parsed = toml.parse(fs.readFileSync(ckbTomlPath, 'utf8')); + const rpc = parsed.rpc as JsonMap | undefined; + const address = rpc?.tcp_listen_address; + if (typeof address !== 'string' || address.trim().length === 0) return undefined; + // A wildcard bind is not a dialable address; the node runs on this host. + return address.trim().replace(/^0\.0\.0\.0:/, '127.0.0.1:'); + } catch { + return undefined; + } +} + 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. @@ -34,7 +59,14 @@ export async function status({ network }: StatusOptions) { `RPC proxy ${url} is not connected to a healthy ${network} node: ${readiness.error ?? 'health check failed'}`, ); } - const result = CKBTui.run(['-r', url]); + const args = ['-r', url]; + if (network === Network.devnet) { + const tcpAddress = devnetTcpListenAddress(); + if (tcpAddress) { + args.push('-t', tcpAddress); + } + } + const result = CKBTui.run(args); // Propagate ckb-tui exit code so scripts can detect TUI failure if (result.status !== 0) { throw new Error(`ckb-tui exited with code ${result.status ?? 'unknown'}`); diff --git a/src/tui/devnet-reference-templates.ts b/src/tui/devnet-reference-templates.ts index 1133ae57..fe04fbae 100644 --- a/src/tui/devnet-reference-templates.ts +++ b/src/tui/devnet-reference-templates.ts @@ -80,16 +80,19 @@ support_protocols = ["Ping", "Discovery", "Identify", "Feeler", "DisconnectMessa # # Allowing arbitrary machines to access the JSON-RPC port is dangerous and strongly discouraged. # Please strictly limit the access to only trusted machines. -listen_address = "0.0.0.0:8114" +listen_address = "127.0.0.1:8114" # Default is 10MiB = 10 * 1024 * 1024 max_request_body_size = 10485760 -# List of API modules: ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer"] -modules = ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer"] +# List of API modules: ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer", "RichIndexer", "Terminal"] +# "Terminal" powers ckb-tui's \`get_overview\` system metrics; without it those panels show N/A. +modules = ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer", "Terminal"] # By default RPC only binds to HTTP service, you can bind it to TCP and WebSocket. -# tcp_listen_address = "127.0.0.1:18114" +# The TCP service streams subscription topics (new_transaction, rejected_transaction, log, ...) +# that ckb-tui's mempool and logs dashboards rely on. +tcp_listen_address = "127.0.0.1:18114" # ws_listen_address = "127.0.0.1:28114" reject_ill_transactions = true diff --git a/tests/status.test.ts b/tests/status.test.ts index 5a532ab4..8786d9c2 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -5,14 +5,23 @@ jest.mock('../src/tools/ckb-tui', () => ({ CKBTui: { run: (...args: unknown[]) = jest.mock('../src/devnet/readiness', () => ({ checkNodeReadiness: (...args: unknown[]) => mockCheckNodeReadiness(...args), })); + +const mockSettings: { + devnet: Record; + testnet: Record; + mainnet: Record; +} = { + devnet: { rpcProxyPort: 28114 }, + testnet: { rpcProxyPort: 38114 }, + mainnet: { rpcProxyPort: 48114 }, +}; jest.mock('../src/cfg/setting', () => ({ - readSettings: () => ({ - devnet: { rpcProxyPort: 28114 }, - testnet: { rpcProxyPort: 38114 }, - mainnet: { rpcProxyPort: 48114 }, - }), + readSettings: () => mockSettings, })); +import fs from 'fs'; +import os from 'os'; +import path from 'path'; import { status } from '../src/cmd/status'; import { Network } from '../src/type/base'; @@ -22,6 +31,7 @@ describe('status command', () => { beforeEach(() => { jest.clearAllMocks(); + mockSettings.devnet = { rpcProxyPort: 28114 }; Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true }); Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true }); }); @@ -31,6 +41,13 @@ describe('status command', () => { Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: originalStdinTTY }); }); + function useDevnetConfig(ckbToml: string): string { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-status-test-')); + fs.writeFileSync(path.join(configDir, 'ckb.toml'), ckbToml); + mockSettings.devnet = { rpcProxyPort: 28114, configPath: configDir }; + return configDir; + } + it('launches ckb-tui only after a real JSON-RPC health check', async () => { mockCheckNodeReadiness.mockResolvedValue({ ready: true }); mockRun.mockReturnValue({ status: 0 }); @@ -55,4 +72,36 @@ describe('status command', () => { mockRun.mockReturnValue({ status: 7 }); await expect(status({ network: Network.devnet })).rejects.toThrow('ckb-tui exited with code 7'); }); + + it('passes the devnet node TCP subscription endpoint to ckb-tui', async () => { + useDevnetConfig('[rpc]\ntcp_listen_address = "127.0.0.1:18114"\n'); + mockCheckNodeReadiness.mockResolvedValue({ ready: true }); + mockRun.mockReturnValue({ status: 0 }); + await status({ network: Network.devnet }); + expect(mockRun).toHaveBeenCalledWith(['-r', 'http://127.0.0.1:28114', '-t', '127.0.0.1:18114']); + }); + + it('dials localhost when the node binds the TCP service to a wildcard address', async () => { + useDevnetConfig('[rpc]\ntcp_listen_address = "0.0.0.0:18114"\n'); + mockCheckNodeReadiness.mockResolvedValue({ ready: true }); + mockRun.mockReturnValue({ status: 0 }); + await status({ network: Network.devnet }); + expect(mockRun).toHaveBeenCalledWith(['-r', 'http://127.0.0.1:28114', '-t', '127.0.0.1:18114']); + }); + + it('omits -t when the devnet config has no TCP listener', async () => { + useDevnetConfig('[rpc]\nmodules = ["Net"]\n'); + mockCheckNodeReadiness.mockResolvedValue({ ready: true }); + mockRun.mockReturnValue({ status: 0 }); + await status({ network: Network.devnet }); + expect(mockRun).toHaveBeenCalledWith(['-r', 'http://127.0.0.1:28114']); + }); + + it('never passes -t for proxied public networks', async () => { + useDevnetConfig('[rpc]\ntcp_listen_address = "127.0.0.1:18114"\n'); + mockCheckNodeReadiness.mockResolvedValue({ ready: true }); + mockRun.mockReturnValue({ status: 0 }); + await status({ network: Network.testnet }); + expect(mockRun).toHaveBeenCalledWith(['-r', 'http://127.0.0.1:38114']); + }); }); From b4816d4c230d10169be830239af63bb4544540c0 Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Thu, 23 Jul 2026 12:01:06 +0800 Subject: [PATCH 4/5] build(deps): bump tar, brace-expansion, js-yaml, hono, fast-uri, body-parser for security advisories (#465) - tar ^7.5.3 -> ^7.5.19 (locked 7.5.21): fixes GHSA-23hp-3jrh-7fpw (critical), GHSA-8x88-c5mf-7j5w (high), GHSA-w8wr-v893-vjvp / GHSA-gvwx-54wh-qm9j (moderate) - brace-expansion -> 1.1.16 / 5.0.7 via overrides: fixes GHSA-3jxr-9vmj-r5cp (high) - js-yaml 4.x -> 4.3.0 via override: fixes GHSA-52cp-r559-cp3m (high) - hono -> 4.12.27 via override: fixes GHSA-xgm2-5f3f-mvvc, GHSA-hvrm-45r6-mjfj, GHSA-w62v-xxxg-mg59 (moderate, dev-only) - fast-uri -> 3.1.4 via override: fixes GHSA-v2hh-gcrm-f6hx, GHSA-4c8g-83qw-93j6 (high, dev-only) - body-parser 2.x -> 2.3.0 via override: fixes GHSA-v422-hmwv-36x6 (low, dev-only) Not fixed: elliptic GHSA-848j-6mx2-7j84 (no patched release published) and @hono/node-server GHSA-frvp-7c67-39w9 (fix requires breaking 1.x -> 2.x bump that violates @modelcontextprotocol/sdk's ^1.19.9 range; dev-only, Windows-only). Co-authored-by: Claude Fable 5 --- package.json | 2 +- pnpm-lock.yaml | 111 ++++++++++++++++++++++++++------------------ pnpm-workspace.yaml | 26 ++++++++--- 3 files changed, 85 insertions(+), 54 deletions(-) diff --git a/package.json b/package.json index 92f4982e..28a0367a 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "https-proxy-agent": "^7.0.5", "node-fetch": "2", "semver": "^7.6.0", - "tar": "^7.5.3", + "tar": "^7.5.19", "winston": "^3.17.0" }, "optionalDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9baf027..ac67fd19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,14 +6,17 @@ settings: overrides: form-data@>=4.0.0 <4.0.6: 4.0.6 - hono@<4.12.25: 4.12.25 + hono@<4.12.27: 4.12.27 qs@>=6.11.1 <=6.15.1: 6.15.2 ip-address@<=10.1.0: 10.1.1 js-yaml@<3.15.0: 3.15.0 - js-yaml@>=4.0.0 <=4.1.1: 4.2.0 + js-yaml@>=4.0.0 <4.3.0: 4.3.0 '@babel/core@<=7.29.0': 7.29.7 '@eslint/plugin-kit@<0.3.4': 0.3.4 - brace-expansion@>=5.0.0 <5.0.6: 5.0.6 + brace-expansion@<1.1.16: 1.1.16 + brace-expansion@>=5.0.0 <5.0.7: 5.0.7 + fast-uri@<3.1.4: 3.1.4 + body-parser@>=2.0.0 <2.3.0: 2.3.0 importers: @@ -59,8 +62,8 @@ importers: specifier: ^7.6.0 version: 7.7.3 tar: - specifier: ^7.5.3 - version: 7.5.16 + specifier: ^7.5.19 + version: 7.5.21 winston: specifier: ^3.17.0 version: 3.17.0 @@ -450,7 +453,7 @@ packages: resolution: {integrity: sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==} engines: {node: '>=18.14.1'} peerDependencies: - hono: 4.12.25 + hono: 4.12.27 '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -1232,15 +1235,15 @@ packages: bn.js@4.12.3: resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} - brace-expansion@1.1.13: - resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -1413,6 +1416,10 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1682,8 +1689,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -1866,8 +1873,8 @@ packages: hmac-drbg@1.0.1: resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} - hono@4.12.25: - resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} + hono@4.12.27: + resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} engines: {node: '>=16.9.0'} html-escaper@2.0.2: @@ -2175,8 +2182,8 @@ packages: resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} hasBin: true - js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true jsbi@3.1.3: @@ -2856,8 +2863,8 @@ packages: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} - tar@7.5.16: - resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==} + tar@7.5.21: + resolution: {integrity: sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==} engines: {node: '>=18'} term-size@2.2.1: @@ -2984,6 +2991,10 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript@5.8.2: resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} engines: {node: '>=14.17'} @@ -3477,7 +3488,7 @@ snapshots: '@changesets/parse@0.4.2': dependencies: '@changesets/types': 6.1.0 - js-yaml: 4.2.0 + js-yaml: 4.3.0 '@changesets/pre@2.0.2': dependencies: @@ -3592,7 +3603,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.2.0 + js-yaml: 4.3.0 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3607,9 +3618,9 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 - '@hono/node-server@1.19.13(hono@4.12.25)': + '@hono/node-server@1.19.13(hono@4.12.27)': dependencies: - hono: 4.12.25 + hono: 4.12.27 '@humanfs/core@0.19.1': {} @@ -4012,7 +4023,7 @@ snapshots: '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.13(hono@4.12.25) + '@hono/node-server': 1.19.13(hono@4.12.27) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -4022,7 +4033,7 @@ snapshots: eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 8.3.1(express@5.2.1) - hono: 4.12.25 + hono: 4.12.27 jose: 6.1.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -4111,24 +4122,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.28.6 - '@babel/types': 7.28.6 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.28.6 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.28.6 - '@babel/types': 7.28.6 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.28.6 + '@babel/types': 7.29.7 '@types/blessed@0.1.27': dependencies: @@ -4378,7 +4389,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 + fast-uri: 3.1.4 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -4497,26 +4508,26 @@ snapshots: bn.js@4.12.3: {} - body-parser@2.2.2: + body-parser@2.3.0: dependencies: bytes: 3.1.2 - content-type: 1.0.5 + content-type: 2.0.0 debug: 4.4.3 http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 qs: 6.15.2 raw-body: 3.0.2 - type-is: 2.0.1 + type-is: 2.1.0 transitivePeerDependencies: - supports-color - brace-expansion@1.1.13: + brace-expansion@1.1.16: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.6: + brace-expansion@5.0.7: dependencies: balanced-match: 4.0.4 @@ -4678,6 +4689,8 @@ snapshots: content-type@1.0.5: {} + content-type@2.0.0: {} + convert-source-map@2.0.0: {} cookie-signature@1.2.2: {} @@ -4945,7 +4958,7 @@ snapshots: express@5.2.1: dependencies: accepts: 2.0.0 - body-parser: 2.2.2 + body-parser: 2.3.0 content-disposition: 1.0.1 content-type: 1.0.5 cookie: 0.7.2 @@ -4991,7 +5004,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.2: {} + fast-uri@3.1.4: {} fastq@1.19.1: dependencies: @@ -5188,7 +5201,7 @@ snapshots: minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 - hono@4.12.25: {} + hono@4.12.27: {} html-escaper@2.0.2: {} @@ -5311,7 +5324,7 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.28.6 + '@babel/parser': 7.29.7 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 7.7.3 @@ -5664,7 +5677,7 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.2.0: + js-yaml@4.3.0: dependencies: argparse: 2.0.1 @@ -5817,11 +5830,11 @@ snapshots: minimatch@3.1.5: dependencies: - brace-expansion: 1.1.13 + brace-expansion: 1.1.16 minimatch@9.0.8: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.7 minimist@1.2.8: {} @@ -6274,7 +6287,7 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 - tar@7.5.16: + tar@7.5.21: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -6395,6 +6408,12 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript@5.8.2: {} uglify-js@3.19.3: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d7411c7a..ef8aa1f0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,21 +2,28 @@ overrides: # GHSA-7m2j-8qp9-m8jw: CRLF injection. Remove when no transitive dependency uses form-data >=4.0.0 <4.0.6. "form-data@>=4.0.0 <4.0.6": "4.0.6" # GHSA-88fw-hqm2-52qc: CORS middleware reflects any origin with credentials. Remove when hono >=4.12.25. - "hono@<4.12.25": "4.12.25" + # GHSA-xgm2-5f3f-mvvc / GHSA-hvrm-45r6-mjfj / GHSA-w62v-xxxg-mg59: repeated-header drop, JSX context leakage, JSX escaping bypass. Remove when hono >=4.12.27. + "hono@<4.12.27": "4.12.27" # GHSA-q8mj-m7cp-5q26: qs.stringify DoS. Remove when direct dependency upgrades qs >=6.15.2. "qs@>=6.11.1 <=6.15.1": "6.15.2" # GHSA-v2v4-37r5-5v8g: XSS in Address6 HTML-emitting methods. Remove when direct dependency upgrades ip-address >=10.1.1. "ip-address@<=10.1.0": "10.1.1" # GHSA-h67p-54hq-rp68: quadratic-complexity DoS in merge key handling. Remove when js-yaml 3.x >=3.15.0. "js-yaml@<3.15.0": "3.15.0" - # GHSA-h67p-54hq-rp68: quadratic-complexity DoS in merge key handling. Remove when js-yaml 4.x >=4.2.0. - "js-yaml@>=4.0.0 <=4.1.1": "4.2.0" + # GHSA-h67p-54hq-rp68 / GHSA-52cp-r559-cp3m: quadratic-complexity DoS in merge key handling. Remove when js-yaml 4.x >=4.3.0. + "js-yaml@>=4.0.0 <4.3.0": "4.3.0" # GHSA-4x5r-pxfx-6jf8: arbitrary file read via sourceMappingURL. Remove when @babel/core >=7.29.6. "@babel/core@<=7.29.0": "7.29.7" # GHSA-xffm-g5w8-qvg7: ReDoS in ConfigCommentParser. Remove when @eslint/plugin-kit >=0.3.4. "@eslint/plugin-kit@<0.3.4": "0.3.4" - # GHSA-jxxr-4gwj-5jf2: unbounded brace range expansion DoS. Remove when brace-expansion >=5.0.6. - "brace-expansion@>=5.0.0 <5.0.6": "5.0.6" + # GHSA-3jxr-9vmj-r5cp: exponential-time expansion of consecutive non-expanding {} groups. Remove when brace-expansion 1.x >=1.1.16. + "brace-expansion@<1.1.16": "1.1.16" + # GHSA-jxxr-4gwj-5jf2 / GHSA-3jxr-9vmj-r5cp: brace range / exponential-time expansion DoS. Remove when brace-expansion >=5.0.7. + "brace-expansion@>=5.0.0 <5.0.7": "5.0.7" + # GHSA-v2hh-gcrm-f6hx / GHSA-4c8g-83qw-93j6: host confusion via backslash authority / failed IDN canonicalization. Remove when fast-uri >=3.1.4. + "fast-uri@<3.1.4": "3.1.4" + # GHSA-v422-hmwv-36x6: invalid limit value silently disables size enforcement. Remove when body-parser 2.x >=2.3.0. + "body-parser@>=2.0.0 <2.3.0": "2.3.0" onlyBuiltDependencies: - secp256k1 @@ -26,7 +33,12 @@ minimumReleaseAgeExclude: - "@eslint/plugin-kit@0.3.4" - "ip-address@10.1.1" - "qs@6.15.2" - - "brace-expansion@5.0.6" + - "brace-expansion@1.1.16" + - "brace-expansion@5.0.7" - "@babel/core@7.29.7" - "js-yaml@3.15.0" - - "js-yaml@4.2.0" + - "js-yaml@4.3.0" + - "tar@7.5.21" + - "hono@4.12.27" + - "fast-uri@3.1.4" + - "body-parser@2.3.0" From 45b0e98126de4382685e4536488da2748741f879 Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Thu, 23 Jul 2026 21:20:24 +0800 Subject: [PATCH 5/5] fix: rename mainnet-fork override flag and apply leftover 0.4.9 review fixes (#466) * fix: rename mainnet-fork override flag and apply leftover 0.4.9 review fixes - Rename --allow-mainnet-replay-risk to --allow-external-key-on-mainnet-fork (#460) - Enforce the Mainnet-fork replay guard in transfer-all, udt issue/destroy, and deploy, threading the fork boundary into input selection (#462) - Validate --tx-hash before it is used in debug cache paths - Only read the fork boundary after the spawned process binds the RPC port - Reject symlinked entries when copying fork source chain data - Accept extended xUDT type args (owner hash + flags/extension) - Per-kind UDT scan budgets, deep-cloned settings fallbacks, accurate config-set errors, preserved devnet-config error, execFile process lookup, aligned ckb-tui download timeouts, EXDEV-safe install, README TOC entry Co-Authored-By: Claude Fable 5 * fix: address PR #466 review comments - Keep --allow-mainnet-replay-risk as a hidden deprecated alias folded into --allow-external-key-on-mainnet-fork (with a deprecation warning) so 0.4.9 scripts keep working under a patch release - Treat lsof probe failures with stderr output as indeterminate (null) instead of "not listening"; only an empty-stderr exit is a genuine no-match, so permission errors fall back to the weaker genesis signal - Reject a symlinked data root before enumerating source chain data - Stage cross-device ckb-tui installs inside binDir and publish with an atomic rename, so concurrent installs never see a truncated binary Co-Authored-By: Claude Fable 5 * fix: bound the lsof port probe with a timeout A hung lsof would block execFileSync (and with it daemon startup) indefinitely, and its empty-stderr timeout error would be misread as a genuine no-match. Cap the probe at 5s and classify ETIMEDOUT as indeterminate (null) so the genesis fallback proceeds. Co-Authored-By: Claude Fable 5 * test: make lsof probe tests platform-independent isProcessListeningOnPort short-circuits to null on win32, so the lsof outcome-mapping tests failed on the Windows CI runner (mock never called). Force a unix platform for the lsof-probing cases, cover the win32 short-circuit explicitly, and pin the probe timeout to exactly 5000 ms per review feedback. --------- Co-authored-by: Claude Fable 5 --- .changeset/tidy-mugs-repair.md | 12 +++ README.md | 3 +- src/cfg/setting.ts | 6 +- src/cli.ts | 133 ++++++++++++++++------------ src/cmd/config.ts | 32 ++++--- src/cmd/debug.ts | 7 ++ src/cmd/deploy.ts | 18 +++- src/cmd/devnet-config.ts | 10 ++- src/cmd/node.ts | 85 ++++++++++++++---- src/cmd/transfer-all.ts | 12 ++- src/cmd/transfer.ts | 8 +- src/cmd/udt.ts | 18 +++- src/deploy/index.ts | 10 ++- src/devnet/fork.ts | 24 ++++- src/sdk/ckb.ts | 41 +++++++-- src/tools/ckb-tui.ts | 38 ++++++-- src/util/fork-safety.ts | 23 ++++- src/util/validator.ts | 17 +++- tests/cli-mainnet-fork-flag.test.ts | 122 +++++++++++++++++++++++++ tests/debug-tx-file.test.ts | 11 +++ tests/devnet-fork.test.ts | 43 +++++++++ tests/fork-safety.test.ts | 24 ++++- tests/node-command.test.ts | 55 ++++++------ tests/node-listener.test.ts | 112 +++++++++++++++++++++++ tests/transfer-all.test.ts | 86 ++++++++++++++++++ tests/udt.test.ts | 42 ++++++++- tests/validator.test.ts | 14 +++ 27 files changed, 842 insertions(+), 164 deletions(-) create mode 100644 .changeset/tidy-mugs-repair.md create mode 100644 tests/cli-mainnet-fork-flag.test.ts create mode 100644 tests/node-listener.test.ts create mode 100644 tests/transfer-all.test.ts diff --git a/.changeset/tidy-mugs-repair.md b/.changeset/tidy-mugs-repair.md new file mode 100644 index 00000000..81b2ecf8 --- /dev/null +++ b/.changeset/tidy-mugs-repair.md @@ -0,0 +1,12 @@ +--- +'@offckb/cli': patch +--- + +Rename `--allow-mainnet-replay-risk` to `--allow-external-key-on-mainnet-fork` (#460) — the old flag remains as a hidden deprecated alias so existing scripts keep working — and apply the fixes left over from the 0.4.9 review (#462): + +- Enforce the Mainnet-fork replay guard (instead of warn-only) in `transfer-all`, `udt issue`, `udt destroy`, and `deploy`, and reject inputs created at or before the fork boundary in those transactions, mirroring `transfer`/`deposit`. +- Validate `--tx-hash` as a 0x-prefixed 32-byte hex string before it is used in debug cache paths. +- Read the fork boundary only from the spawned CKB process once it is the RPC listener, so a stale node sharing the port cannot clear the first-run flags. +- Refuse symlinked entries when copying source chain data for a fork. +- Accept xUDT type args longer than 32 bytes (owner lock hash plus flags/extension) while keeping SUDT at exactly 32 bytes. +- Give SUDT and xUDT balance scans independent `maxCells` budgets, return deep clones of the default settings from `readSettings` fallbacks, keep `config set` error messages accurate, preserve the original error in `devnet config`, use `execFile` for process lookups, align the ckb-tui download timeouts, handle cross-device installs, and add the missing Fork Mainnet/Testnet entry to the README table of contents. diff --git a/README.md b/README.md index b2e285f1..a4b4941b 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ There are BREAKING CHANGES between v0.3.x and v0.4.x, make sure to read the [mig - [4. Debug Your Contract {#debug-contract}](#4-debug-your-contract-debug-contract) - [5. Explore Built-in Scripts {#explore-scripts}](#5-explore-built-in-scripts-explore-scripts) - [6. Tweak Devnet Config {#tweak-devnet-config}](#6-tweak-devnet-config-tweak-devnet-config) + - [7. Fork Mainnet/Testnet Into Your Devnet {#fork-devnet}](#7-fork-mainnettestnet-into-your-devnet-fork-devnet) - [Config Setting](#config-setting) - [List All Settings](#list-all-settings) - [Set CKB version](#set-ckb-version) @@ -414,7 +415,7 @@ 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. +`offckb transfer` fails closed on a Mainnet fork: non-built-in keys require `--allow-external-key-on-mainnet-fork`, and inputs copied from Mainnet are rejected even with that override. (`--allow-mainnet-replay-risk` from 0.4.9 remains as a deprecated alias.) ## Config Setting diff --git a/src/cfg/setting.ts b/src/cfg/setting.ts index 113f17ab..52aaa512 100644 --- a/src/cfg/setting.ts +++ b/src/cfg/setting.ts @@ -111,11 +111,13 @@ export function readSettings(): Settings { // Deep-clone defaults before merging to prevent mutation of the shared default return deepMerge(deepClone(defaultSettings), parsed) as Settings; } else { - return defaultSettings; + // Callers mutate the returned settings in place; never hand out the + // shared module-level defaults. + return deepClone(defaultSettings); } } catch (error) { logger.error('Error reading settings:', error); - return defaultSettings; + return deepClone(defaultSettings); } } diff --git a/src/cli.ts b/src/cli.ts index a174a65b..82908323 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,6 +19,7 @@ import { printSystemScripts } from './cmd/system-scripts'; import { transferAll } from './cmd/transfer-all'; import { genSystemScriptsJsonFile } from './scripts/gen'; import { CKBDebugger } from './tools/ckb-debugger'; +import { resolveMainnetForkOverride } from './util/fork-safety'; import { logger } from './util/logger'; import { Network } from './type/base'; import { status } from './cmd/status'; @@ -43,6 +44,20 @@ function commandPath(command: Command): string { return names.join('.') || 'offckb'; } +// Registers the Mainnet-fork override flag plus the 0.4.9 name as a hidden +// deprecated alias; resolveMainnetForkOverride folds the alias into the new +// option before the command handler runs. +function mainnetForkOverrideOption(command: Command): Command { + return command + .option( + '--allow-external-key-on-mainnet-fork', + 'Allow a non-built-in key on a Mainnet fork (copied inputs remain blocked)', + ) + .addOption( + new Option('--allow-mainnet-replay-risk', 'Deprecated alias of --allow-external-key-on-mainnet-fork').hideHelp(), + ); +} + program.option('--json', 'Output logs in JSON format for agent/programmatic consumption'); program.hook('preAction', (_thisCommand, actionCommand) => { activeCommand = commandPath(actionCommand); @@ -83,17 +98,18 @@ program return await createScriptProject(projectName, options); }); -program - .command('deploy') - .description('Deploy contracts to different networks, only supports devnet and testnet') - .option('--network ', 'Specify the network to deploy to', 'devnet') - .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 (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)); +mainnetForkOverrideOption( + program + .command('deploy') + .description('Deploy contracts to different networks, only supports devnet and testnet') + .option('--network ', 'Specify the network to deploy to', 'devnet') + .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 (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(resolveMainnetForkOverride(options))); program .command('debug') @@ -158,30 +174,31 @@ program 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 (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'])) - .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); - }); +mainnetForkOverrideOption( + 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 (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'])) + .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) => { + await transfer(toAddress, amount, resolveMainnetForkOverride(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 (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) => { - await transferAll(toAddress, options); - }); +mainnetForkOverrideOption( + 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 (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) => { + await transferAll(toAddress, resolveMainnetForkOverride(options)); +}); program .command('balance [toAddress]') @@ -196,30 +213,32 @@ program const udtCommand = program.command('udt').description('UDT token commands'); -udtCommand - .command('issue ') - .description('Issue new UDT tokens, only devnet and testnet') - .option('--network ', 'Specify the network', 'devnet') - .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 (visible in shell history)') - .option('--privkey-file ', 'Read the private key from a local file') - .action(async (amount: string, options: UdtIssueOption) => { - await udtIssue(amount, options); - }); +mainnetForkOverrideOption( + udtCommand + .command('issue ') + .description('Issue new UDT tokens, only devnet and testnet') + .option('--network ', 'Specify the network', 'devnet') + .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 (visible in shell history)') + .option('--privkey-file ', 'Read the private key from a local file'), +).action(async (amount: string, options: UdtIssueOption) => { + await udtIssue(amount, resolveMainnetForkOverride(options)); +}); -udtCommand - .command('destroy ') - .description('Destroy UDT tokens, only devnet and testnet') - .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 (visible in shell history)') - .option('--privkey-file ', 'Read the private key from a local file') - .action(async (amount: string, options: UdtDestroyOption) => { - await udtDestroy(amount, options); - }); +mainnetForkOverrideOption( + udtCommand + .command('destroy ') + .description('Destroy UDT tokens, only devnet and testnet') + .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 (visible in shell history)') + .option('--privkey-file ', 'Read the private key from a local file'), +).action(async (amount: string, options: UdtDestroyOption) => { + await udtDestroy(amount, resolveMainnetForkOverride(options)); +}); program .command('debugger') diff --git a/src/cmd/config.ts b/src/cmd/config.ts index b5f849e3..ab1f5dca 100644 --- a/src/cmd/config.ts +++ b/src/cmd/config.ts @@ -48,31 +48,29 @@ export async function Config(action: ConfigAction, item: ConfigItem, value?: str case ConfigItem.proxy: { if (value == null) throw new Error('No proxyUrl!'); + // Only the parse belongs in the try: an I/O failure from + // readSettings/writeSettings must not be mislabeled as a bad URL. + let proxy; try { - const proxy = Request.parseProxyUrl(value); - const settings = readSettings(); - settings.proxy = proxy; - return writeSettings(settings); + proxy = Request.parseProxyUrl(value); } catch (error: unknown) { throw new Error(`invalid proxyURL: ${(error as Error).message}`); } + const settings = readSettings(); + settings.proxy = proxy; + return writeSettings(settings); } case ConfigItem.ckbVersion: { - const settings = readSettings(); - try { - if (isValidVersion(value)) { - const version = extractVersion(value!); - settings.bins.defaultCKBVersion = version; - return writeSettings(settings); - } else { - throw new Error( - `invalid version value, ${value}. Check available versions on https://github.com/nervosnetwork/ckb/tags`, - ); - } - } catch (error: unknown) { - throw new Error(`invalid version value: ${(error as Error).message}`); + if (!isValidVersion(value)) { + throw new Error( + `invalid version value, ${value}. Check available versions on https://github.com/nervosnetwork/ckb/tags`, + ); } + const settings = readSettings(); + const version = extractVersion(value!); + settings.bins.defaultCKBVersion = version; + return writeSettings(settings); } default: diff --git a/src/cmd/debug.ts b/src/cmd/debug.ts index 430326b5..8024d110 100644 --- a/src/cmd/debug.ts +++ b/src/cmd/debug.ts @@ -8,8 +8,10 @@ import { Network } from '../type/base'; import { encodeBinPathForTerminal } from '../util/encoding'; import { callJsonRpc } from '../util/json-rpc'; import { logger } from '../util/logger'; +import { validateTxHash } from '../util/validator'; export async function debugTransaction(txHash: string, network: Network) { + validateTxHash(txHash); const txFile = await buildTxFileOptionBy(txHash, network); const opts = buildTransactionDebugOptions(txHash, network); for (const opt of opts) { @@ -19,6 +21,7 @@ export async function debugTransaction(txHash: string, network: Network) { } export function buildTransactionDebugOptions(txHash: string, network: Network) { + validateTxHash(txHash); const txJsonFilePath = buildTransactionJsonFilePath(network, txHash); const txJson = JSON.parse(fs.readFileSync(txJsonFilePath, 'utf-8')); const cccTx = cccA.JsonRpcTransformers.transactionTo(txJson); @@ -57,6 +60,7 @@ export async function debugSingleScript( network: Network, bin?: string, ) { + validateTxHash(txHash); const txFile = await buildTxFileOptionBy(txHash, network); let opt = `--cell-index ${cellIndex} --cell-type ${cellType} --script-group-type ${scriptType}`; if (bin) { @@ -83,6 +87,9 @@ export function parseSingleScriptOption(value: string) { } export async function buildTxFileOptionBy(txHash: string, network: Network) { + // The hash is interpolated into cache file paths below; reject anything that + // is not a plain 32-byte hash before touching the filesystem. + validateTxHash(txHash); const settings = readSettings(); const outputFilePath = buildDebugFullTransactionFilePath(network, txHash); if (!fs.existsSync(outputFilePath)) { diff --git a/src/cmd/deploy.ts b/src/cmd/deploy.ts index 1347a44f..f388dec1 100644 --- a/src/cmd/deploy.ts +++ b/src/cmd/deploy.ts @@ -9,7 +9,7 @@ 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'; +import { validateMainnetForkSigning } from '../util/fork-safety'; export interface DeployOptions extends NetworkOption { target?: string; @@ -18,6 +18,7 @@ export interface DeployOptions extends NetworkOption { privkeyFile?: string | null; typeId?: boolean; yes?: boolean; + allowExternalKeyOnMainnetFork?: boolean; } export async function deploy( @@ -28,7 +29,11 @@ export async function deploy( // we use deployerAccount to deploy contract by default const privateKey = resolvePrivateKey(opt, deployerAccount.privkey); - warnIfMainnetForkSigning(network, privateKey); + const rejectInputsAtOrBeforeBlock = validateMainnetForkSigning( + network, + privateKey, + opt.allowExternalKeyOnMainnetFork, + ); await warnIfForkIndexerIsBehind(network); const ckb = new CKB({ network }); const enableTypeId = opt.typeId ?? false; @@ -73,7 +78,14 @@ export async function deploy( } } - const results = await deployBinaries(outputFolder, binPaths, privateKey, enableTypeId, ckb); + const results = await deployBinaries( + outputFolder, + binPaths, + privateKey, + enableTypeId, + ckb, + rejectInputsAtOrBeforeBlock, + ); logger.info(''); // record the deployed contract infos diff --git a/src/cmd/devnet-config.ts b/src/cmd/devnet-config.ts index c1cb6aa6..55ad95ac 100644 --- a/src/cmd/devnet-config.ts +++ b/src/cmd/devnet-config.ts @@ -70,10 +70,14 @@ export async function devnetConfig(options: DevnetConfigOptions = {}) { logger.info('No changes saved.'); } catch (error) { - let message = error instanceof Error ? error.message : String(error); if (error instanceof InitializationError) { - message += ' Tip: run `offckb node` once to initialize devnet config files first.'; + // Rethrow the same object so its name and stack stay intact. + error.message += ' Tip: run `offckb node` once to initialize devnet config files first.'; + throw error; } - throw new Error(message); + if (error instanceof Error) { + throw error; + } + throw new Error(String(error)); } } diff --git a/src/cmd/node.ts b/src/cmd/node.ts index a9f6b477..146ba7dc 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -1,4 +1,4 @@ -import { exec, spawn, ChildProcess } from 'child_process'; +import { execFile, execFileSync, spawn, ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import { initChainIfNeeded } from '../node/init-chain'; @@ -190,12 +190,50 @@ function resolveDaemonPaths() { return { logDir, logFile, pidFile }; } +// Best-effort check that the spawned process is the one listening on the RPC +// port. Returns null when the check cannot be performed (Windows, no lsof, an +// lsof inspection error, or a hung lsof that hits the timeout) so callers can +// fall back to weaker signals. +// lsof exits 1 both for "no match" and for permission/inspection errors; only +// an empty stderr is a genuine no-match, anything else is indeterminate. A +// timed-out probe is killed with an empty stderr too, so ETIMEDOUT must be +// ruled out first to avoid misreading it as a genuine no-match. +export function isProcessListeningOnPort(pid: number, port: number): boolean | null { + if (process.platform === 'win32') return null; + try { + execFileSync('lsof', ['-a', '-p', String(pid), '-iTCP:' + port, '-sTCP:LISTEN'], { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + }); + return true; + } catch (error) { + const err = error as NodeJS.ErrnoException & { stderr?: Buffer | string }; + if (err.code === 'ENOENT' || err.code === 'ETIMEDOUT') return null; + const stderr = err.stderr?.toString().trim() ?? ''; + return stderr === '' ? false : null; + } +} + +function rpcPortOf(rpcUrl: string): number | null { + try { + const url = new URL(rpcUrl); + if (url.port) return Number(url.port); + return url.protocol === 'https:' ? 443 : 80; + } catch { + return null; + } +} + // Poll the devnet RPC until the spawned node answers with the fork's genesis // hash, then mark the first run as done so subsequent `offckb node` runs boot -// normally. Two guards against clearing the flag on the wrong signal: +// normally. Guards against clearing the flag on the wrong signal: // - the poll aborts when the spawned ckb process exits (e.g. failed boot), // - an answering node is only trusted when its genesis matches the fork -// state — an unrelated node occupying the port must not clear the flag. +// state — an unrelated node occupying the port must not clear the flag, +// - when it can be determined, the spawned process must be the RPC listener: +// the fork keeps the source chain's genesis hash, so a stale source or +// fork node sharing the port would otherwise pass the genesis check and +// supply a wrong fork boundary. async function clearForkFirstRunWhenNodeUp( ckbProcess: ChildProcess, rpcUrl: string, @@ -221,6 +259,16 @@ async function clearForkFirstRunWhenNodeUp( ); return; } + const rpcPort = rpcPortOf(rpcUrl); + const listening = + ckbProcess.pid != null && rpcPort != null ? isProcessListeningOnPort(ckbProcess.pid, rpcPort) : null; + if (listening === false) { + // Something else is answering at the RPC URL while our process has not + // bound the port (yet). Do not read the fork boundary from it. + logger.debug(`Waiting for the spawned CKB process to bind the RPC port ${rpcPort} ..`); + await new Promise((resolve) => setTimeout(resolve, 1000)); + continue; + } // 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(); @@ -382,24 +430,25 @@ function waitForProcessExit(pid: number, timeoutMs: number): Promise { function getProcessCommandLine(pid: number): Promise { return new Promise((resolve) => { - if (process.platform === 'win32') { - exec(`wmic process where ProcessId=${pid} get CommandLine /format:list`, (error, stdout) => { - if (error) { - resolve(null); - return; - } + // Argument arrays, never an interpolated shell string: even though pid is + // validated as a positive integer on every path here, execFile keeps that + // true after any future refactor. + const [cmd, args]: [string, string[]] = + process.platform === 'win32' + ? ['wmic', ['process', 'where', `ProcessId=${pid}`, 'get', 'CommandLine', '/format:list']] + : ['ps', ['-p', String(pid), '-o', 'args=']]; + execFile(cmd, args, (error, stdout) => { + if (error) { + resolve(null); + return; + } + if (process.platform === 'win32') { const match = stdout.match(/CommandLine=(.+)/); resolve(match ? match[1].trim() : null); - }); - } else { - exec(`ps -p ${pid} -o args=`, (error, stdout) => { - if (error) { - resolve(null); - return; - } + } else { resolve(stdout.trim()); - }); - } + } + }); }); } diff --git a/src/cmd/transfer-all.ts b/src/cmd/transfer-all.ts index c6aefb81..2377933d 100644 --- a/src/cmd/transfer-all.ts +++ b/src/cmd/transfer-all.ts @@ -5,11 +5,12 @@ 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'; +import { validateMainnetForkSigning } from '../util/fork-safety'; export interface TransferAllOptions extends NetworkOption { privkey?: string | null; privkeyFile?: string | null; + allowExternalKeyOnMainnetFork?: boolean; } export async function transferAll(toAddress: string, opt: TransferAllOptions = { network: Network.devnet }) { @@ -17,13 +18,20 @@ export async function transferAll(toAddress: string, opt: TransferAllOptions = { validateNetworkOpt(network); const privateKey = resolvePrivateKey(opt); - warnIfMainnetForkSigning(network, privateKey); + // transfer-all sweeps the whole balance, which makes it the most likely + // command to pick up copied pre-fork Mainnet cells — enforce, not just warn. + const rejectInputsAtOrBeforeBlock = validateMainnetForkSigning( + network, + privateKey, + opt.allowExternalKeyOnMainnetFork, + ); await warnIfForkIndexerIsBehind(network); const ckb = new CKB({ network }); const txHash = await ckb.transferAll({ toAddress, privateKey, + rejectInputsAtOrBeforeBlock, }); if (network === 'testnet') { logger.info(`Successfully transfer, check ${buildTestnetTxLink(txHash)} for details.`); diff --git a/src/cmd/transfer.ts b/src/cmd/transfer.ts index 0be49112..e60b1afe 100644 --- a/src/cmd/transfer.ts +++ b/src/cmd/transfer.ts @@ -12,7 +12,7 @@ export interface TransferOptions extends NetworkOption { privkeyFile?: string | null; udtKind?: UdtKind; udtTypeArgs?: string; - allowMainnetReplayRisk?: boolean; + allowExternalKeyOnMainnetFork?: boolean; } export async function transfer(toAddress: string, amount: string, opt: TransferOptions = { network: Network.devnet }) { @@ -32,7 +32,11 @@ export async function transfer(toAddress: string, amount: string, opt: TransferO } const privateKey = resolvePrivateKey(opt); - const rejectInputsAtOrBeforeBlock = validateMainnetForkSigning(network, privateKey, opt.allowMainnetReplayRisk); + const rejectInputsAtOrBeforeBlock = validateMainnetForkSigning( + network, + privateKey, + opt.allowExternalKeyOnMainnetFork, + ); await warnIfForkIndexerIsBehind(network); const ckb = new CKB({ network }); diff --git a/src/cmd/udt.ts b/src/cmd/udt.ts index 517ea5f3..802ca9ea 100644 --- a/src/cmd/udt.ts +++ b/src/cmd/udt.ts @@ -5,7 +5,7 @@ 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 UdtIssueOption extends NetworkOption { udtKind: UdtKind; @@ -13,6 +13,7 @@ export interface UdtIssueOption extends NetworkOption { to?: string; privkey?: string; privkeyFile?: string; + allowExternalKeyOnMainnetFork?: boolean; } export interface UdtDestroyOption extends NetworkOption { @@ -20,6 +21,7 @@ export interface UdtDestroyOption extends NetworkOption { typeArgs: string; privkey?: string; privkeyFile?: string; + allowExternalKeyOnMainnetFork?: boolean; } export async function udtIssue(amount: string, opt: UdtIssueOption = { network: Network.devnet, udtKind: 'sudt' }) { @@ -30,7 +32,11 @@ export async function udtIssue(amount: string, opt: UdtIssueOption = { network: const typeArgs = opt.typeArgs ? validateUdtTypeArgs(opt.udtKind, opt.typeArgs) : undefined; const privateKey = resolvePrivateKey(opt); - warnIfMainnetForkSigning(network, privateKey); + const rejectInputsAtOrBeforeBlock = validateMainnetForkSigning( + network, + privateKey, + opt.allowExternalKeyOnMainnetFork, + ); await warnIfForkIndexerIsBehind(network); const ckb = new CKB({ network }); @@ -40,6 +46,7 @@ export async function udtIssue(amount: string, opt: UdtIssueOption = { network: amount, typeArgs, toAddress: opt.to, + rejectInputsAtOrBeforeBlock, }); logTxSuccess(network, result.txHash, 'issued UDT'); @@ -70,7 +77,11 @@ export async function udtDestroy( const typeArgs = validateUdtTypeArgs(opt.udtKind, opt.typeArgs); const privateKey = resolvePrivateKey(opt); - warnIfMainnetForkSigning(network, privateKey); + const rejectInputsAtOrBeforeBlock = validateMainnetForkSigning( + network, + privateKey, + opt.allowExternalKeyOnMainnetFork, + ); await warnIfForkIndexerIsBehind(network); const ckb = new CKB({ network }); @@ -79,6 +90,7 @@ export async function udtDestroy( kind: opt.udtKind, amount, typeArgs, + rejectInputsAtOrBeforeBlock, }); logTxSuccess(network, txHash, 'destroyed UDT'); diff --git a/src/deploy/index.ts b/src/deploy/index.ts index 609c98d8..1be75b3b 100644 --- a/src/deploy/index.ts +++ b/src/deploy/index.ts @@ -81,13 +81,14 @@ export async function deployBinaries( privateKey: HexString, enableTypeId: boolean, ckb: CKB, + rejectInputsAtOrBeforeBlock?: bigint, ) { if (binPaths.length === 0) { logger.info('No binary to deploy.'); } const results: DeployedInterfaceType[] = []; for (const bin of binPaths) { - const result = await deployBinary(outputFolder, bin, privateKey, enableTypeId, ckb); + const result = await deployBinary(outputFolder, bin, privateKey, enableTypeId, ckb, rejectInputsAtOrBeforeBlock); results.push(result); } return results; @@ -99,6 +100,7 @@ export async function deployBinary( privateKey: HexString, enableTypeId: boolean, ckb: CKB, + rejectInputsAtOrBeforeBlock?: bigint, ): Promise<{ deploymentRecipe: DeploymentRecipe; deploymentOptions: DeploymentOptions; @@ -107,10 +109,10 @@ export async function deployBinary( const contractName = path.basename(binPath); const result = !enableTypeId - ? await ckb.deployScript(bin, privateKey) + ? await ckb.deployScript(bin, privateKey, rejectInputsAtOrBeforeBlock) : Migration.isDeployedWithTypeId(outputFolder, contractName, ckb.network) - ? await ckb.upgradeTypeIdScript(outputFolder, contractName, bin, privateKey) - : await ckb.deployNewTypeIDScript(bin, privateKey); + ? await ckb.upgradeTypeIdScript(outputFolder, contractName, bin, privateKey, rejectInputsAtOrBeforeBlock) + : await ckb.deployNewTypeIDScript(bin, privateKey, rejectInputsAtOrBeforeBlock); logger.info(`contract ${contractName} deployed, tx hash:`, result.txHash); logger.info('wait for tx confirmed on-chain...'); diff --git a/src/devnet/fork.ts b/src/devnet/fork.ts index 1200bea9..19aaeaff 100644 --- a/src/devnet/fork.ts +++ b/src/devnet/fork.ts @@ -264,16 +264,38 @@ export function copySourceData(sourceDir: string, configPath: string): void { // place, and linked files would corrupt the source chain. const excludedTopLevelEntries = new Set(['network', 'logs', 'tmp']); fs.mkdirSync(targetData, { recursive: true }); + // A symlinked data root would bypass the per-entry checks below: + // readdirSync follows it and its ordinary children would pass assertNoSymlink. + assertNoSymlink(sourceData); // 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 }); + const sourceEntry = path.join(sourceData, entry); + // fs.cpSync resolves symlinks by default; a symlinked entry (especially + // data/db) would silently copy data from outside the source directory. + assertNoSymlink(sourceEntry); + fs.cpSync(sourceEntry, path.join(targetData, entry), { + recursive: true, + filter: (src) => { + assertNoSymlink(src); + return true; + }, + }); } logger.info('Excluded source network peers and transient logs/tmp data from the fork.'); } +function assertNoSymlink(entryPath: string): void { + if (fs.lstatSync(entryPath).isSymbolicLink()) { + throw new Error( + `Refusing to copy ${entryPath}: symlinked entries are not allowed in the source chain data. ` + + 'Replace the symlink with the real directory and retry.', + ); + } +} + export function isolateForkCkbConfig(config: Record): Record { const network = { ...((config.network as Record) ?? {}) }; network.bootnodes = []; diff --git a/src/sdk/ckb.ts b/src/sdk/ckb.ts index 08218a5d..f9f70d5e 100644 --- a/src/sdk/ckb.ts +++ b/src/sdk/ckb.ts @@ -43,7 +43,7 @@ export interface TransferOption { rejectInputsAtOrBeforeBlock?: bigint; } -export type TransferAllOption = Pick; +export type TransferAllOption = Pick; export interface UdtTransferOption { privateKey: HexString; @@ -60,6 +60,7 @@ export interface UdtIssueOption { amount: HexNumber; typeArgs?: HexString; toAddress?: string; + rejectInputsAtOrBeforeBlock?: bigint; } export interface UdtIssueResult { @@ -73,6 +74,7 @@ export interface UdtDestroyOption { kind: UdtKind; typeArgs: HexString; amount: HexNumber; + rejectInputsAtOrBeforeBlock?: bigint; } export interface UdtBalanceInfo { @@ -203,7 +205,7 @@ export class CKB { return txHash; } - async transferAll({ privateKey, toAddress }: TransferAllOption): Promise { + async transferAll({ privateKey, toAddress, rejectInputsAtOrBeforeBlock }: TransferAllOption): Promise { const signer = this.buildSigner(privateKey); const to = await ccc.Address.fromString(toAddress, this.client); const balanceInCKB = await this.balance((await signer.getRecommendedAddressObj()).toString()); @@ -219,6 +221,7 @@ export class CKB { ], }); await tx.completeInputsByCapacity(signer); + await this.assertInputsCreatedAfter(tx, rejectInputsAtOrBeforeBlock); const txHash = await signer.sendTransaction(tx); return txHash; } @@ -277,9 +280,10 @@ export class CKB { { kind: UdtKind; codeHash: HexString; hashType: string; args: HexString; balance: bigint } >(); - let scanned = 0; - + // Each kind gets its own scan budget: if the SUDT scan alone reached + // maxCells, a shared counter would silently drop every XUDT balance. const scan = async (scriptInfo: UdtScriptInfo, kind: UdtKind) => { + let scanned = 0; for await (const cell of this.client.findCells( { script: { @@ -435,7 +439,14 @@ export class CKB { } } - async udtIssue({ privateKey, kind, amount, typeArgs, toAddress }: UdtIssueOption): Promise { + async udtIssue({ + privateKey, + kind, + amount, + typeArgs, + toAddress, + rejectInputsAtOrBeforeBlock, + }: UdtIssueOption): Promise { const signer = this.buildSigner(privateKey); const signerAddress = await signer.getAddressObjSecp256k1(); const to = toAddress ? await ccc.Address.fromString(toAddress, this.client) : signerAddress; @@ -476,12 +487,13 @@ 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, typeArgs: resolvedTypeArgs, receiver: to.toString() }; } async udtDestroy( - { privateKey, kind, typeArgs, amount }: UdtDestroyOption, + { privateKey, kind, typeArgs, amount, rejectInputsAtOrBeforeBlock }: UdtDestroyOption, { maxInputCells = DEFAULT_UDT_DESTROY_MAX_INPUT_CELLS }: { maxInputCells?: number } = {}, ): Promise { const signer = this.buildSigner(privateKey); @@ -537,11 +549,16 @@ 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; } - async deployScript(scriptBinBytes: Uint8Array, privateKey: string): Promise { + async deployScript( + scriptBinBytes: Uint8Array, + privateKey: string, + rejectInputsAtOrBeforeBlock?: bigint, + ): Promise { const signer = this.buildSigner(privateKey); const signerSecp256k1Address = await signer.getAddressObjSecp256k1(); const tx = ccc.Transaction.from({ @@ -554,11 +571,16 @@ 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, tx, scriptOutputCellIndex: 0, isTypeId: false }; } - async deployNewTypeIDScript(scriptBinBytes: Uint8Array, privateKey: string): Promise { + async deployNewTypeIDScript( + scriptBinBytes: Uint8Array, + privateKey: string, + rejectInputsAtOrBeforeBlock?: bigint, + ): Promise { const signer = this.buildSigner(privateKey); const signerSecp256k1Address = await signer.getAddressObjSecp256k1(); const typeIdTx = ccc.Transaction.from({ @@ -576,6 +598,7 @@ export class CKB { } typeIdTx.outputs[0].type.args = ccc.hashTypeId(typeIdTx.inputs[0], 0); await typeIdTx.completeFeeBy(signer, this.feeRate); + await this.assertInputsCreatedAfter(typeIdTx, rejectInputsAtOrBeforeBlock); const txHash = await signer.sendTransaction(typeIdTx); return { txHash, tx: typeIdTx, scriptOutputCellIndex: 0, isTypeId: true, typeId: typeIdTx.outputs[0].type }; } @@ -585,6 +608,7 @@ export class CKB { scriptName: string, newScriptBinBytes: Uint8Array, privateKey: HexString, + rejectInputsAtOrBeforeBlock?: bigint, ): Promise { const deploymentReceipt = Migration.find(baseFolder, scriptName, this.network); if (deploymentReceipt == null) throw new Error("no migration file, can't be updated."); @@ -635,6 +659,7 @@ export class CKB { } typeIdTx.outputs[0].type.args = typeIdArgs as `0x{string}`; await typeIdTx.completeFeeBy(signer, this.feeRate); + await this.assertInputsCreatedAfter(typeIdTx, rejectInputsAtOrBeforeBlock); const txHash = await signer.sendTransaction(typeIdTx); return { txHash, tx: typeIdTx, scriptOutputCellIndex: 0, isTypeId: true, typeId: typeIdTx.outputs[0].type }; } diff --git a/src/tools/ckb-tui.ts b/src/tools/ckb-tui.ts index 086719fa..d65602e6 100644 --- a/src/tools/ckb-tui.ts +++ b/src/tools/ckb-tui.ts @@ -149,12 +149,17 @@ export class CKBTui { const archivePath = path.join(tempDir, assetName); try { - // 1. Download + // 1. Download. Keep curl's own limit aligned with the outer spawnSync + // timeout so the two never disagree about who gives up first. logger.info(`Downloading ckb-tui from ${downloadUrl}...`); - const curlResult = spawnSync('curl', ['-fsSL', '--max-time', '300', '-o', archivePath, downloadUrl], { - stdio: 'inherit', - timeout: DOWNLOAD_TIMEOUT_MS, - }); + const curlResult = spawnSync( + 'curl', + ['-fsSL', '--max-time', String(DOWNLOAD_TIMEOUT_MS / 1000), '-o', archivePath, downloadUrl], + { + stdio: 'inherit', + timeout: DOWNLOAD_TIMEOUT_MS, + }, + ); if (curlResult.error) { throw new Error(`Failed to download ckb-tui: ${curlResult.error.message}`); @@ -179,8 +184,27 @@ export class CKBTui { throw new Error(`ckb-tui binary ("${binaryName}") was not found after extraction.`); } - // 5. Atomically move to the final location - fs.renameSync(extractedBinary, this.binaryPath); + // 5. Move to the final location. renameSync is atomic but throws EXDEV + // when the temp dir and the data path live on different filesystems + // (common in containers). In that case stage the copy inside binDir and + // publish it with a rename, so a concurrent ensureInstalled() never sees + // a partially copied binary at the final path. + try { + fs.renameSync(extractedBinary, this.binaryPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EXDEV') { + const stagingPath = path.join(binDir, `.${binaryName}.staging-${process.pid}`); + try { + fs.copyFileSync(extractedBinary, stagingPath); + fs.renameSync(stagingPath, this.binaryPath); + fs.unlinkSync(extractedBinary); + } finally { + fs.rmSync(stagingPath, { force: true }); + } + } else { + throw error; + } + } // 6. Make executable on Unix if (process.platform !== 'win32') { diff --git a/src/util/fork-safety.ts b/src/util/fork-safety.ts index b5508dd5..964a6ab7 100644 --- a/src/util/fork-safety.ts +++ b/src/util/fork-safety.ts @@ -5,6 +5,23 @@ import { ForkState, readForkState } from '../devnet/fork'; import { Network } from '../type/base'; import { logger } from './logger'; +export interface MainnetForkOverrideOptions { + allowExternalKeyOnMainnetFork?: boolean; + allowMainnetReplayRisk?: boolean; +} + +/** + * Map the deprecated --allow-mainnet-replay-risk flag (0.4.9) onto its + * replacement so scripts written against the old name keep working. + */ +export function resolveMainnetForkOverride(options: T): T { + if (options.allowMainnetReplayRisk) { + logger.warn('`--allow-mainnet-replay-risk` is deprecated; use `--allow-external-key-on-mainnet-fork` instead.'); + options.allowExternalKeyOnMainnetFork = true; + } + return options; +} + const BUILT_IN_DEV_KEYS = new Set( [...accountConfig.map((account) => account.privkey), ckbDevnetMinerAccount.privkey].map((key) => key.toLowerCase()), ); @@ -23,16 +40,16 @@ export function warnIfMainnetForkSigning(network: Network, privateKey: string): export function validateMainnetForkSigning( network: Network, privateKey: string, - allowMainnetReplayRisk = false, + allowExternalKeyOnMainnetFork = false, ): bigint | undefined { const fork = readMainnetForkState(network); if (!fork) return undefined; logMainnetForkSigningWarning(privateKey); - if (!BUILT_IN_DEV_KEYS.has(privateKey.trim().toLowerCase()) && !allowMainnetReplayRisk) { + if (!BUILT_IN_DEV_KEYS.has(privateKey.trim().toLowerCase()) && !allowExternalKeyOnMainnetFork) { 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.', + 'Use --allow-external-key-on-mainnet-fork only after verifying that no copied Mainnet input will be selected.', ); } if (fork.forkBlockNumber == null) { diff --git a/src/util/validator.ts b/src/util/validator.ts index 5f7b4620..98b32f7d 100644 --- a/src/util/validator.ts +++ b/src/util/validator.ts @@ -142,6 +142,7 @@ export function validateUdtAmount(amount: string): bigint { } const HEX_REGEX = /^0x[0-9a-fA-F]*$/; +const TX_HASH_REGEX = /^0x[0-9a-fA-F]{64}$/; export function validateHexString(value: string, name: string): HexString { if (!value || !HEX_REGEX.test(value)) { @@ -150,14 +151,26 @@ export function validateHexString(value: string, name: string): HexString { return value as HexString; } +export function validateTxHash(txHash: string): HexString { + if (!TX_HASH_REGEX.test(txHash)) { + throw new Error(`invalid transaction hash "${txHash}", must be a 0x-prefixed 32-byte hex string`); + } + return txHash as HexString; +} + export function validateUdtTypeArgs(kind: UdtKind, typeArgs: string): HexString { const hex = validateHexString(typeArgs, 'type args'); + if ((hex.length - 2) % 2 !== 0) { + throw new Error(`invalid ${kind === 'sudt' ? 'SUDT' : 'xUDT'} type args: hex must encode whole bytes`); + } const byteLength = (hex.length - 2) / 2; 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}`); + // xUDT args are the 32-byte owner lock hash plus optional flags and + // extension data, so 32 bytes is the minimum, not the exact, length. + if (kind === 'xudt' && byteLength < 32) { + throw new Error(`invalid xUDT type args length: expected at least 32 bytes, got ${byteLength}`); } return hex; } diff --git a/tests/cli-mainnet-fork-flag.test.ts b/tests/cli-mainnet-fork-flag.test.ts new file mode 100644 index 00000000..6a72bc4b --- /dev/null +++ b/tests/cli-mainnet-fork-flag.test.ts @@ -0,0 +1,122 @@ +const mockDeploy = jest.fn(); +const mockTransfer = jest.fn(); +const mockTransferAll = jest.fn(); +const mockUdtIssue = jest.fn(); +const mockUdtDestroy = jest.fn(); + +jest.mock('../src/cmd/node', () => ({ startNode: jest.fn(), stopNode: jest.fn() })); +jest.mock('../src/cmd/accounts', () => ({ accounts: jest.fn() })); +jest.mock('../src/cmd/clean', () => ({ clean: jest.fn() })); +jest.mock('../src/cmd/deposit', () => ({ deposit: jest.fn() })); +jest.mock('../src/cmd/deploy', () => ({ deploy: (...args: unknown[]) => mockDeploy(...args) })); +jest.mock('../src/cmd/transfer', () => ({ transfer: (...args: unknown[]) => mockTransfer(...args) })); +jest.mock('../src/cmd/balance', () => ({ balanceOf: jest.fn() })); +jest.mock('../src/cmd/udt', () => ({ + udtIssue: (...args: unknown[]) => mockUdtIssue(...args), + udtDestroy: (...args: unknown[]) => mockUdtDestroy(...args), +})); +jest.mock('../src/cmd/create', () => ({ createScriptProject: jest.fn() })); +jest.mock('../src/cmd/config', () => ({ Config: jest.fn() })); +jest.mock('../src/cmd/devnet-config', () => ({ devnetConfig: jest.fn() })); +jest.mock('../src/cmd/devnet-fork', () => ({ devnetFork: jest.fn() })); +jest.mock('../src/cmd/devnet-info', () => ({ devnetInfo: jest.fn() })); +jest.mock('../src/cmd/debug', () => ({ + debugSingleScript: jest.fn(), + debugTransaction: jest.fn(), + parseSingleScriptOption: jest.fn(), +})); +jest.mock('../src/cmd/system-scripts', () => ({ printSystemScripts: jest.fn() })); +jest.mock('../src/cmd/transfer-all', () => ({ transferAll: (...args: unknown[]) => mockTransferAll(...args) })); +jest.mock('../src/cmd/status', () => ({ status: jest.fn() })); +jest.mock('../src/scripts/gen', () => ({ genSystemScriptsJsonFile: jest.fn() })); +jest.mock('../src/tools/ckb-debugger', () => ({ CKBDebugger: { runWithArgs: jest.fn() } })); +jest.mock('../src/util/logger', () => ({ + logger: { + success: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + result: jest.fn(), + failure: jest.fn(), + setJsonMode: jest.fn(), + isJsonMode: jest.fn(() => false), + hasResult: jest.fn(() => false), + }, +})); + +// src/cli.ts builds its commander program at module scope and commander keeps +// parsed option values between parseAsync calls, so each test gets a fresh +// module registry to avoid option state leaking across runs. +function loadCli() { + jest.resetModules(); + const cli = require('../src/cli') as typeof import('../src/cli'); + const { logger } = require('../src/util/logger') as typeof import('../src/util/logger'); + return { runCli: cli.runCli, logger }; +} + +describe('deprecated --allow-mainnet-replay-risk CLI alias', () => { + beforeEach(() => { + jest.clearAllMocks(); + process.exitCode = undefined; + }); + + afterEach(() => { + process.exitCode = undefined; + }); + + it('maps the deprecated flag onto --allow-external-key-on-mainnet-fork', async () => { + const { runCli, logger } = loadCli(); + await runCli(['node', 'offckb', 'transfer', '0xrecipient', '100', '--allow-mainnet-replay-risk']); + + expect(mockTransfer).toHaveBeenCalledWith( + '0xrecipient', + '100', + expect.objectContaining({ allowExternalKeyOnMainnetFork: true }), + ); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('--allow-external-key-on-mainnet-fork')); + }); + + it('keeps the new flag working without a deprecation warning', async () => { + const { runCli, logger } = loadCli(); + await runCli(['node', 'offckb', 'transfer', '0xrecipient', '100', '--allow-external-key-on-mainnet-fork']); + + expect(mockTransfer).toHaveBeenCalledWith( + '0xrecipient', + '100', + expect.objectContaining({ allowExternalKeyOnMainnetFork: true }), + ); + expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('deprecated')); + }); + + it('accepts the deprecated alias on every guarded command', async () => { + const { runCli } = loadCli(); + + await runCli(['node', 'offckb', 'deploy', '--allow-mainnet-replay-risk']); + expect(mockDeploy).toHaveBeenCalledWith(expect.objectContaining({ allowExternalKeyOnMainnetFork: true })); + + await runCli(['node', 'offckb', 'transfer-all', '0xrecipient', '--allow-mainnet-replay-risk']); + expect(mockTransferAll).toHaveBeenCalledWith( + '0xrecipient', + expect.objectContaining({ allowExternalKeyOnMainnetFork: true }), + ); + + await runCli(['node', 'offckb', 'udt', 'issue', '100', '--allow-mainnet-replay-risk']); + expect(mockUdtIssue).toHaveBeenCalledWith('100', expect.objectContaining({ allowExternalKeyOnMainnetFork: true })); + + await runCli([ + 'node', + 'offckb', + 'udt', + 'destroy', + '100', + '--type-args', + '0x' + '00'.repeat(32), + '--allow-mainnet-replay-risk', + ]); + expect(mockUdtDestroy).toHaveBeenCalledWith( + '100', + expect.objectContaining({ allowExternalKeyOnMainnetFork: true }), + ); + }); +}); diff --git a/tests/debug-tx-file.test.ts b/tests/debug-tx-file.test.ts index d1622e9a..1567581e 100644 --- a/tests/debug-tx-file.test.ts +++ b/tests/debug-tx-file.test.ts @@ -120,4 +120,15 @@ describe('buildTxFileOptionBy', () => { `Failed to fetch transaction ${TX_HASH} from http://127.0.0.1:8114: connect ECONNREFUSED`, ); }); + + it('rejects a malformed tx hash before touching any cache path', async () => { + for (const badHash of ['0x../escape', 'not-a-hash', '0x' + 'ab'.repeat(31), '0x' + 'ab'.repeat(33)]) { + await expect(buildTxFileOptionBy(badHash, Network.devnet)).rejects.toThrow('invalid transaction hash'); + } + + expect(mockExistsSync).not.toHaveBeenCalled(); + expect(mockCallJsonRpc).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + expect(mockDumpTransaction).not.toHaveBeenCalled(); + }); }); diff --git a/tests/devnet-fork.test.ts b/tests/devnet-fork.test.ts index 03feb6e7..a2c3f87b 100644 --- a/tests/devnet-fork.test.ts +++ b/tests/devnet-fork.test.ts @@ -179,6 +179,49 @@ describe('fork data isolation and migration preflight', () => { expect(fs.existsSync(path.join(target, 'data', 'tmp'))).toBe(false); }); + it('rejects symlinked top-level entries in the source chain data', () => { + if (process.platform === 'win32') return; + const source = path.join(root, 'source'); + const target = path.join(root, 'target'); + const outside = path.join(root, 'outside'); + fs.mkdirSync(path.join(source, 'data'), { recursive: true }); + fs.mkdirSync(outside, { recursive: true }); + fs.writeFileSync(path.join(outside, 'fixture'), 'secret'); + fs.symlinkSync(outside, path.join(source, 'data', 'db'), 'dir'); + + expect(() => copySourceData(source, target)).toThrow('symlinked entries are not allowed'); + expect(fs.existsSync(path.join(target, 'data', 'db', 'fixture'))).toBe(false); + }); + + it('rejects symlinks nested inside copied directories', () => { + if (process.platform === 'win32') return; + const source = path.join(root, 'source'); + const target = path.join(root, 'target'); + const outside = path.join(root, 'outside'); + fs.mkdirSync(path.join(source, 'data', 'db'), { recursive: true }); + fs.writeFileSync(path.join(source, 'data', 'db', 'fixture'), 'db'); + fs.mkdirSync(outside, { recursive: true }); + fs.writeFileSync(path.join(outside, 'fixture'), 'secret'); + fs.symlinkSync(path.join(outside, 'fixture'), path.join(source, 'data', 'db', 'linked')); + + expect(() => copySourceData(source, target)).toThrow('symlinked entries are not allowed'); + expect(fs.existsSync(path.join(target, 'data', 'db', 'linked'))).toBe(false); + }); + + it('rejects a symlinked data directory at the source root', () => { + if (process.platform === 'win32') return; + const source = path.join(root, 'source'); + const target = path.join(root, 'target'); + const outside = path.join(root, 'outside'); + fs.mkdirSync(source, { recursive: true }); + fs.mkdirSync(outside, { recursive: true }); + fs.writeFileSync(path.join(outside, 'fixture'), 'secret'); + fs.symlinkSync(outside, path.join(source, 'data'), 'dir'); + + expect(() => copySourceData(source, target)).toThrow('symlinked entries are not allowed'); + expect(fs.existsSync(path.join(target, 'data', 'fixture'))).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 }, diff --git a/tests/fork-safety.test.ts b/tests/fork-safety.test.ts index 87d93b86..88861191 100644 --- a/tests/fork-safety.test.ts +++ b/tests/fork-safety.test.ts @@ -7,7 +7,11 @@ jest.mock('../src/devnet/fork', () => ({ readForkState: () => mockFork })); jest.mock('../src/util/logger', () => ({ logger: { warn: jest.fn() } })); import accountConfig from '../account/account.json'; -import { validateMainnetForkSigning, warnIfMainnetForkSigning } from '../src/util/fork-safety'; +import { + resolveMainnetForkOverride, + validateMainnetForkSigning, + warnIfMainnetForkSigning, +} from '../src/util/fork-safety'; import { logger } from '../src/util/logger'; import { Network } from '../src/type/base'; @@ -41,7 +45,7 @@ describe('Mainnet fork signing warning', () => { 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', + '--allow-external-key-on-mainnet-fork', ); }); @@ -64,3 +68,19 @@ describe('Mainnet fork signing warning', () => { ); }); }); + +describe('deprecated --allow-mainnet-replay-risk alias', () => { + beforeEach(() => jest.clearAllMocks()); + + it('folds the deprecated flag into the new option with a warning', () => { + const options = resolveMainnetForkOverride({ allowMainnetReplayRisk: true }); + expect(options.allowExternalKeyOnMainnetFork).toBe(true); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('--allow-external-key-on-mainnet-fork')); + }); + + it('leaves options untouched when the deprecated flag is absent', () => { + const options = resolveMainnetForkOverride({ allowExternalKeyOnMainnetFork: true }); + expect(options.allowExternalKeyOnMainnetFork).toBe(true); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/node-command.test.ts b/tests/node-command.test.ts index c7807200..c3e189ba 100644 --- a/tests/node-command.test.ts +++ b/tests/node-command.test.ts @@ -3,7 +3,7 @@ import { Network } from '../src/type/base'; import * as path from 'path'; const mockSpawn = jest.fn(); -const mockExec = jest.fn(); +const mockExecFile = jest.fn(); const mockOpenSync = jest.fn(); const mockWriteFileSync = jest.fn(); const mockMkdirSync = jest.fn(); @@ -17,7 +17,7 @@ const mockWaitForNodeReady = jest.fn(); jest.mock('child_process', () => ({ ...jest.requireActual('child_process'), spawn: (...args: unknown[]) => mockSpawn(...args), - exec: (...args: unknown[]) => mockExec(...args), + execFile: (...args: unknown[]) => mockExecFile(...args), })); jest.mock('fs', () => ({ @@ -80,22 +80,23 @@ import { logger } from '../src/util/logger'; const dataPath = '/tmp/offckb-devnet-data'; const logDir = path.join(dataPath, 'logs'); const pidFile = path.join(logDir, 'daemon.pid'); -const logFile = path.join(logDir, 'daemon.log'); function mockDaemonCommandLine(scriptPath: string) { - mockExec.mockImplementation((cmd: string, callback: (err: Error | null, stdout?: string) => void) => { - if (cmd.startsWith('ps ')) { - callback(null, `/usr/bin/node ${scriptPath} node`); - return undefined as unknown as ReturnType; - } - if (cmd.startsWith('wmic ')) { - // WMIC returns key/value pairs, e.g. "CommandLine=..." - callback(null, `CommandLine=/usr/bin/node ${scriptPath} node`); - return undefined as unknown as ReturnType; - } - callback(null, ''); - return undefined as unknown as ReturnType; - }); + mockExecFile.mockImplementation( + (file: string, _args: string[], callback: (err: Error | null, stdout?: string) => void) => { + if (file === 'ps') { + callback(null, `/usr/bin/node ${scriptPath} node`); + return undefined as unknown as ReturnType; + } + if (file === 'wmic') { + // WMIC returns key/value pairs, e.g. "CommandLine=..." + callback(null, `CommandLine=/usr/bin/node ${scriptPath} node`); + return undefined as unknown as ReturnType; + } + callback(null, ''); + return undefined as unknown as ReturnType; + }, + ); } describe('node command daemon mode', () => { @@ -195,10 +196,12 @@ describe('node command daemon mode', () => { 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; - }); + mockExecFile.mockImplementation( + (_file: string, _args: 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 }); @@ -407,7 +410,7 @@ describe('node command stop', () => { jest.useFakeTimers(); jest.clearAllMocks(); processAlive = true; - mockExec.mockReset(); + mockExecFile.mockReset(); mockStatSync.mockReturnValue({ isFile: () => true }); mockReadFileSync.mockReturnValue(JSON.stringify({ pid: 12345, scriptPath, startedAt: new Date().toISOString() })); mockDaemonCommandLine(scriptPath); @@ -504,10 +507,12 @@ describe('node command stop', () => { }); it('refuses to kill a process that does not look like the daemon', async () => { - mockExec.mockImplementation((cmd: string, callback: (err: Error | null, stdout?: string) => void) => { - callback(null, '/usr/bin/some-other-process'); - return undefined as unknown as ReturnType; - }); + mockExecFile.mockImplementation( + (_file: string, _args: string[], callback: (err: Error | null, stdout?: string) => void) => { + callback(null, '/usr/bin/some-other-process'); + return undefined as unknown as ReturnType; + }, + ); await expect(stopNode()).rejects.toThrow('does not appear to be the offckb daemon'); diff --git a/tests/node-listener.test.ts b/tests/node-listener.test.ts new file mode 100644 index 00000000..1173e26b --- /dev/null +++ b/tests/node-listener.test.ts @@ -0,0 +1,112 @@ +const mockExecFileSync = jest.fn(); + +jest.mock('child_process', () => ({ + ...jest.requireActual('child_process'), + execFileSync: (...args: unknown[]) => mockExecFileSync(...args), +})); +jest.mock('../src/node/install', () => ({ installCKBBinary: jest.fn() })); +jest.mock('../src/node/init-chain', () => ({ initChainIfNeeded: jest.fn() })); +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(), markForkFirstRunComplete: jest.fn() })); +jest.mock('../src/util/json-rpc', () => ({ callJsonRpc: jest.fn() })); +jest.mock('../src/devnet/readiness', () => ({ checkNodeReadiness: jest.fn(), waitForNodeReady: jest.fn() })); +jest.mock('../src/tools/rpc-proxy', () => ({ createRPCProxy: jest.fn() })); +jest.mock('../src/util/logger', () => ({ + logger: { + success: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + result: jest.fn(), + }, +})); + +import { isProcessListeningOnPort } from '../src/cmd/node'; + +function lsofError(stderr: string, code?: string): Error & { stderr: Buffer; code?: string } { + const error = new Error('lsof failed') as Error & { stderr: Buffer; code?: string }; + error.stderr = Buffer.from(stderr); + error.code = code; + return error; +} + +describe('isProcessListeningOnPort', () => { + const realPlatform = process.platform; + + // The implementation short-circuits to null on win32 without invoking lsof; + // force a unix platform so the lsof-probing behavior is exercised on every + // CI OS, including the Windows runners. + beforeAll(() => Object.defineProperty(process, 'platform', { value: 'linux' })); + afterAll(() => Object.defineProperty(process, 'platform', { value: realPlatform })); + beforeEach(() => jest.clearAllMocks()); + + it('returns true when lsof finds the process listening', () => { + mockExecFileSync.mockReturnValue(Buffer.from('p1234')); + expect(isProcessListeningOnPort(1234, 8114)).toBe(true); + expect(mockExecFileSync).toHaveBeenCalledWith( + 'lsof', + ['-a', '-p', '1234', '-iTCP:8114', '-sTCP:LISTEN'], + expect.objectContaining({ timeout: 5000 }), + ); + }); + + it('returns null on Windows without probing lsof', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + expect(isProcessListeningOnPort(1234, 8114)).toBeNull(); + expect(mockExecFileSync).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(process, 'platform', { value: 'linux' }); + } + }); + + it('returns false when lsof reports no match (empty stderr)', () => { + mockExecFileSync.mockImplementation(() => { + throw lsofError(''); + }); + expect(isProcessListeningOnPort(1234, 8114)).toBe(false); + }); + + it('returns null when the lsof inspection itself failed (stderr output)', () => { + mockExecFileSync.mockImplementation(() => { + throw lsofError('lsof: permission denied\n'); + }); + expect(isProcessListeningOnPort(1234, 8114)).toBeNull(); + }); + + it('returns null when lsof is not installed', () => { + mockExecFileSync.mockImplementation(() => { + throw lsofError('spawn lsof ENOENT', 'ENOENT'); + }); + expect(isProcessListeningOnPort(1234, 8114)).toBeNull(); + }); + + it('returns null when lsof hangs and hits the probe timeout', () => { + mockExecFileSync.mockImplementation(() => { + throw lsofError('', 'ETIMEDOUT'); + }); + expect(isProcessListeningOnPort(1234, 8114)).toBeNull(); + }); + + it('bounds the lsof probe with a timeout', () => { + mockExecFileSync.mockReturnValue(Buffer.from('p1234')); + isProcessListeningOnPort(1234, 8114); + expect(mockExecFileSync).toHaveBeenCalledWith( + 'lsof', + ['-a', '-p', '1234', '-iTCP:8114', '-sTCP:LISTEN'], + expect.objectContaining({ timeout: 5000 }), + ); + }); +}); diff --git a/tests/transfer-all.test.ts b/tests/transfer-all.test.ts new file mode 100644 index 00000000..64bed227 --- /dev/null +++ b/tests/transfer-all.test.ts @@ -0,0 +1,86 @@ +import { Network } from '../src/type/base'; +import { transferAll } from '../src/cmd/transfer-all'; +import { CKB } from '../src/sdk/ckb'; + +const mockValidateMainnetForkSigning = jest.fn().mockReturnValue(undefined); + +jest.mock('../src/sdk/ckb', () => { + return { + CKB: jest.fn().mockImplementation(() => ({ + transferAll: jest.fn().mockResolvedValue('0xtxhash'), + })), + }; +}); + +jest.mock('../src/util/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + success: jest.fn(), + debug: jest.fn(), + result: jest.fn(), + }, +})); + +jest.mock('../src/devnet/readiness', () => ({ + warnIfForkIndexerIsBehind: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../src/util/fork-safety', () => ({ + validateMainnetForkSigning: (...args: unknown[]) => mockValidateMainnetForkSigning(...args), +})); + +describe('transfer-all command', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockValidateMainnetForkSigning.mockReturnValue(undefined); + }); + + it('sweeps the balance with the fork replay guard enforced', async () => { + const privateKey = '0x1234567812345678123456781234567812345678123456781234567812345678'; + + await transferAll('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', { + network: Network.devnet, + privkey: privateKey, + }); + + const ckbInstance = (CKB as jest.Mock).mock.results[0].value; + expect(mockValidateMainnetForkSigning).toHaveBeenCalledWith(Network.devnet, privateKey, undefined); + expect(ckbInstance.transferAll).toHaveBeenCalledWith( + expect.objectContaining({ rejectInputsAtOrBeforeBlock: undefined }), + ); + }); + + it('passes the Mainnet fork boundary to input selection checks', async () => { + mockValidateMainnetForkSigning.mockReturnValue(100n); + const privateKey = '0x1234567812345678123456781234567812345678123456781234567812345678'; + + await transferAll('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', { + network: Network.devnet, + privkey: privateKey, + allowExternalKeyOnMainnetFork: true, + }); + + const ckbInstance = (CKB as jest.Mock).mock.results[0].value; + expect(mockValidateMainnetForkSigning).toHaveBeenCalledWith(Network.devnet, privateKey, true); + expect(ckbInstance.transferAll).toHaveBeenCalledWith( + expect.objectContaining({ rejectInputsAtOrBeforeBlock: 100n }), + ); + }); + + it('fails closed when the replay guard rejects the key', async () => { + mockValidateMainnetForkSigning.mockImplementation(() => { + throw new Error('Refusing to sign with a non-built-in private key on a Mainnet fork.'); + }); + + await expect( + transferAll('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', { + network: Network.devnet, + privkey: '0x1234567812345678123456781234567812345678123456781234567812345678', + }), + ).rejects.toThrow('Refusing to sign'); + + expect(CKB).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/udt.test.ts b/tests/udt.test.ts index 03f8f567..3acbc218 100644 --- a/tests/udt.test.ts +++ b/tests/udt.test.ts @@ -124,14 +124,12 @@ describe('transfer command', () => { await transfer('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', '100', { network: Network.devnet, privkey: privateKey, - allowMainnetReplayRisk: true, + allowExternalKeyOnMainnetFork: 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 }), - ); + expect(ckbInstance.transfer).toHaveBeenCalledWith(expect.objectContaining({ rejectInputsAtOrBeforeBlock: 100n })); }); it('should transfer UDT when --udt-type-args is provided', async () => { @@ -185,6 +183,7 @@ describe('transfer command', () => { describe('udt command', () => { beforeEach(() => { jest.clearAllMocks(); + mockValidateMainnetForkSigning.mockReturnValue(undefined); }); describe('udtIssue', () => { @@ -209,6 +208,22 @@ describe('udt command', () => { expect(ckbInstance.udtIssue).toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith('Successfully issued UDT, txHash:', '0xissuehash'); }); + + it('passes the Mainnet fork boundary to input selection checks', async () => { + mockValidateMainnetForkSigning.mockReturnValue(100n); + const privateKey = '0x1234567812345678123456781234567812345678123456781234567812345678'; + + await udtIssue('100', { + network: Network.devnet, + udtKind: 'sudt', + privkey: privateKey, + allowExternalKeyOnMainnetFork: true, + }); + + const ckbInstance = (CKB as jest.Mock).mock.results[0].value; + expect(mockValidateMainnetForkSigning).toHaveBeenCalledWith(Network.devnet, privateKey, true); + expect(ckbInstance.udtIssue).toHaveBeenCalledWith(expect.objectContaining({ rejectInputsAtOrBeforeBlock: 100n })); + }); }); describe('udtDestroy', () => { @@ -235,5 +250,24 @@ describe('udt command', () => { expect(ckbInstance.udtDestroy).toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith('Successfully destroyed UDT, txHash:', '0xdestroyhash'); }); + + it('passes the Mainnet fork boundary to input selection checks', async () => { + mockValidateMainnetForkSigning.mockReturnValue(100n); + const privateKey = '0x1234567812345678123456781234567812345678123456781234567812345678'; + + await udtDestroy('100', { + network: Network.devnet, + udtKind: 'sudt', + typeArgs: mockTypeArgs, + privkey: privateKey, + allowExternalKeyOnMainnetFork: true, + }); + + const ckbInstance = (CKB as jest.Mock).mock.results[0].value; + expect(mockValidateMainnetForkSigning).toHaveBeenCalledWith(Network.devnet, privateKey, true); + expect(ckbInstance.udtDestroy).toHaveBeenCalledWith( + expect.objectContaining({ rejectInputsAtOrBeforeBlock: 100n }), + ); + }); }); }); diff --git a/tests/validator.test.ts b/tests/validator.test.ts index 9006bf27..f2c1af79 100644 --- a/tests/validator.test.ts +++ b/tests/validator.test.ts @@ -111,6 +111,20 @@ describe('UDT validation helpers', () => { expect(validateUdtTypeArgs('xudt', args)).toBe(args); }); + it('should accept xUDT type args with flags and extension data', () => { + // owner lock hash (32 bytes) + 4-byte flags + const withFlags = '0x' + '12'.repeat(36); + expect(validateUdtTypeArgs('xudt', withFlags)).toBe(withFlags); + // owner lock hash + flags + extension data + const withExtension = '0x' + '12'.repeat(64); + expect(validateUdtTypeArgs('xudt', withExtension)).toBe(withExtension); + }); + + it('should reject type args that do not encode whole bytes', () => { + expect(() => validateUdtTypeArgs('xudt', '0x' + '1'.repeat(65))).toThrow('whole bytes'); + expect(() => validateUdtTypeArgs('sudt', '0x' + '1'.repeat(63))).toThrow('whole bytes'); + }); + it('should reject invalid hex', () => { expect(() => validateUdtTypeArgs('sudt', 'not-hex')).toThrow('invalid type args'); expect(() => validateUdtTypeArgs('sudt', '')).toThrow('invalid type args');