diff --git a/.changeset/brave-falcons-dance.md b/.changeset/brave-falcons-dance.md deleted file mode 100644 index 4f1723b..0000000 --- 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/devnet-fork.md b/.changeset/devnet-fork.md deleted file mode 100644 index 9530c58..0000000 --- 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 767e077..0000000 --- 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 899a71d..0000000 --- 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 293777b..0000000 --- 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 b528831..24d5dcc 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/README.md b/README.md index 6b48506..b2e285f 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/package.json b/package.json index f708c0f..92f4982 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", diff --git a/src/cli.ts b/src/cli.ts index 73a84ec..a174a65 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 195e915..1eafab2 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 a036a09..e1a65b3 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 729ae54..a7c5738 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 a5feae8..b5f849e 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 88dcf89..0aa2b32 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 59422dc..430326b 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 c51ba65..1347a44 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 3fafe5c..9e2396d 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 8ccfbfe..c1cb6aa 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 11fa7b2..715b1d1 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 0000000..7b50683 --- /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 914e48e..a9f6b47 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 881effe..d247e49 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 ee37c5b..c6aefb8 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 1518eb3..0be4911 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 4d7ea23..517ea5f 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 26e8836..1200bea 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 0000000..c3e9103 --- /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 a98d721..29a3a66 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 e681d9f..5dcd10a 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 336f46c..08218a5 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 8b3ba78..086719f 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 0000000..b5508dd --- /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 b68d96e..8454496 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 05ec4b3..8a6955e 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 0000000..e7ba412 --- /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 fa0021e..5f7b462 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 0000000..94956fc --- /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 0000000..e17bbfd --- /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 0000000..d1c9b80 --- /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 507b764..86ad3c2 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 0ca738b..03feb6e 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 0000000..23973e4 --- /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 0000000..87d93b8 --- /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 0000000..f9205c7 --- /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 a8e5528..5c3deef 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 b5c99aa..c780720 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 0000000..f6b8f10 --- /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 0000000..3e0cef9 --- /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 0000000..f96fced --- /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 0000000..361eed5 --- /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 3c13247..ddac4c3 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 0000000..5a532ab --- /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 0005012..03f8f56 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 f216139..9006bf2 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'); }); });