From e6d9a7cf5cc15c4560d3ec975eaaac6e7d37aad3 Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Thu, 23 Jul 2026 05:59:31 +0000 Subject: [PATCH 1/4] fix: rename mainnet-fork override flag and apply leftover 0.4.9 review fixes - Rename --allow-mainnet-replay-risk to --allow-external-key-on-mainnet-fork (#460) - Enforce the Mainnet-fork replay guard in transfer-all, udt issue/destroy, and deploy, threading the fork boundary into input selection (#462) - Validate --tx-hash before it is used in debug cache paths - Only read the fork boundary after the spawned process binds the RPC port - Reject symlinked entries when copying fork source chain data - Accept extended xUDT type args (owner hash + flags/extension) - Per-kind UDT scan budgets, deep-cloned settings fallbacks, accurate config-set errors, preserved devnet-config error, execFile process lookup, aligned ckb-tui download timeouts, EXDEV-safe install, README TOC entry Co-Authored-By: Claude Fable 5 --- .changeset/tidy-mugs-repair.md | 12 +++++ README.md | 3 +- src/cfg/setting.ts | 6 ++- src/cli.ts | 21 ++++++++- src/cmd/config.ts | 32 ++++++------- src/cmd/debug.ts | 7 +++ src/cmd/deploy.ts | 18 +++++-- src/cmd/devnet-config.ts | 10 ++-- src/cmd/node.ts | 77 +++++++++++++++++++++++------- src/cmd/transfer-all.ts | 12 ++++- src/cmd/transfer.ts | 8 +++- src/cmd/udt.ts | 18 +++++-- src/deploy/index.ts | 10 ++-- src/devnet/fork.ts | 21 ++++++++- src/sdk/ckb.ts | 41 ++++++++++++---- src/tools/ckb-tui.ts | 30 +++++++++--- src/util/fork-safety.ts | 6 +-- src/util/validator.ts | 17 ++++++- tests/debug-tx-file.test.ts | 11 +++++ tests/devnet-fork.test.ts | 29 ++++++++++++ tests/fork-safety.test.ts | 2 +- tests/node-command.test.ts | 55 ++++++++++++---------- tests/transfer-all.test.ts | 86 ++++++++++++++++++++++++++++++++++ tests/udt.test.ts | 42 +++++++++++++++-- tests/validator.test.ts | 14 ++++++ 25 files changed, 481 insertions(+), 107 deletions(-) create mode 100644 .changeset/tidy-mugs-repair.md create mode 100644 tests/transfer-all.test.ts diff --git a/.changeset/tidy-mugs-repair.md b/.changeset/tidy-mugs-repair.md new file mode 100644 index 00000000..2b246884 --- /dev/null +++ b/.changeset/tidy-mugs-repair.md @@ -0,0 +1,12 @@ +--- +'@offckb/cli': patch +--- + +Rename `--allow-mainnet-replay-risk` to `--allow-external-key-on-mainnet-fork` (#460) and apply the fixes left over from the 0.4.9 review (#462): + +- Enforce the Mainnet-fork replay guard (instead of warn-only) in `transfer-all`, `udt issue`, `udt destroy`, and `deploy`, and reject inputs created at or before the fork boundary in those transactions, mirroring `transfer`/`deposit`. +- Validate `--tx-hash` as a 0x-prefixed 32-byte hex string before it is used in debug cache paths. +- Read the fork boundary only from the spawned CKB process once it is the RPC listener, so a stale node sharing the port cannot clear the first-run flags. +- Refuse symlinked entries when copying source chain data for a fork. +- Accept xUDT type args longer than 32 bytes (owner lock hash plus flags/extension) while keeping SUDT at exactly 32 bytes. +- Give SUDT and xUDT balance scans independent `maxCells` budgets, return deep clones of the default settings from `readSettings` fallbacks, keep `config set` error messages accurate, preserve the original error in `devnet config`, use `execFile` for process lookups, align the ckb-tui download timeouts, handle cross-device installs, and add the missing Fork Mainnet/Testnet entry to the README table of contents. diff --git a/README.md b/README.md index b2e285f1..3494a3ee 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ There are BREAKING CHANGES between v0.3.x and v0.4.x, make sure to read the [mig - [4. Debug Your Contract {#debug-contract}](#4-debug-your-contract-debug-contract) - [5. Explore Built-in Scripts {#explore-scripts}](#5-explore-built-in-scripts-explore-scripts) - [6. Tweak Devnet Config {#tweak-devnet-config}](#6-tweak-devnet-config-tweak-devnet-config) + - [7. Fork Mainnet/Testnet Into Your Devnet {#fork-devnet}](#7-fork-mainnettestnet-into-your-devnet-fork-devnet) - [Config Setting](#config-setting) - [List All Settings](#list-all-settings) - [Set CKB version](#set-ckb-version) @@ -414,7 +415,7 @@ On a forked devnet, `offckb system-scripts`, transfers, deploys and `offckb debu > [!CAUTION] > CKB transactions carry no chain id, so a transaction built on a mainnet fork that spends copied mainnet cells is also valid on mainnet (CKB provides no replay protection). offckb's own flows only use dev keys and fork-mined cells, which cannot replay. Never sign transactions with real mainnet keys against a fork unless you intend to broadcast them yourself. -`offckb transfer` fails closed on a Mainnet fork: non-built-in keys require `--allow-mainnet-replay-risk`, and inputs copied from Mainnet are rejected even with that override. +`offckb transfer` fails closed on a Mainnet fork: non-built-in keys require `--allow-external-key-on-mainnet-fork`, and inputs copied from Mainnet are rejected even with that override. ## Config Setting diff --git a/src/cfg/setting.ts b/src/cfg/setting.ts index 113f17ab..52aaa512 100644 --- a/src/cfg/setting.ts +++ b/src/cfg/setting.ts @@ -111,11 +111,13 @@ export function readSettings(): Settings { // Deep-clone defaults before merging to prevent mutation of the shared default return deepMerge(deepClone(defaultSettings), parsed) as Settings; } else { - return defaultSettings; + // Callers mutate the returned settings in place; never hand out the + // shared module-level defaults. + return deepClone(defaultSettings); } } catch (error) { logger.error('Error reading settings:', error); - return defaultSettings; + return deepClone(defaultSettings); } } diff --git a/src/cli.ts b/src/cli.ts index a174a65b..575bdc63 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -92,6 +92,10 @@ program .option('-t, --type-id', 'Specify if use upgradable type id to deploy the script') .option('--privkey ', 'Specify the private key to deploy scripts (visible in shell history)') .option('--privkey-file ', 'Read the private key from a local file') + .option( + '--allow-external-key-on-mainnet-fork', + 'Allow a non-built-in key on a Mainnet fork (copied inputs remain blocked)', + ) .option('-y, --yes', 'Skip confirmation prompt and deploy immediately') .action((options: DeployOptions) => deploy(options)); @@ -166,7 +170,10 @@ program .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( + '--allow-external-key-on-mainnet-fork', + 'Allow a non-built-in key on a Mainnet fork (copied inputs remain blocked)', + ) .option('-r, --proxy-rpc', 'Use Proxy RPC to connect to blockchain') .action(async (toAddress: string, amount: string, options: TransferOptions) => { await transfer(toAddress, amount, options); @@ -178,6 +185,10 @@ program .option('--network ', 'Specify the network to transfer to', 'devnet') .option('--privkey ', 'Specify the private key (visible in shell history)') .option('--privkey-file ', 'Read the private key from a local file') + .option( + '--allow-external-key-on-mainnet-fork', + '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, options: TransferOptions) => { await transferAll(toAddress, options); @@ -205,6 +216,10 @@ udtCommand .option('--to ', 'Specify the receiver address (defaults to signer)') .option('--privkey ', 'Specify the private key to issue UDT (visible in shell history)') .option('--privkey-file ', 'Read the private key from a local file') + .option( + '--allow-external-key-on-mainnet-fork', + 'Allow a non-built-in key on a Mainnet fork (copied inputs remain blocked)', + ) .action(async (amount: string, options: UdtIssueOption) => { await udtIssue(amount, options); }); @@ -217,6 +232,10 @@ udtCommand .requiredOption('--type-args ', 'Specify the UDT type script args') .option('--privkey ', 'Specify the private key to destroy UDT (visible in shell history)') .option('--privkey-file ', 'Read the private key from a local file') + .option( + '--allow-external-key-on-mainnet-fork', + 'Allow a non-built-in key on a Mainnet fork (copied inputs remain blocked)', + ) .action(async (amount: string, options: UdtDestroyOption) => { await udtDestroy(amount, options); }); diff --git a/src/cmd/config.ts b/src/cmd/config.ts index b5f849e3..ab1f5dca 100644 --- a/src/cmd/config.ts +++ b/src/cmd/config.ts @@ -48,31 +48,29 @@ export async function Config(action: ConfigAction, item: ConfigItem, value?: str case ConfigItem.proxy: { if (value == null) throw new Error('No proxyUrl!'); + // Only the parse belongs in the try: an I/O failure from + // readSettings/writeSettings must not be mislabeled as a bad URL. + let proxy; try { - const proxy = Request.parseProxyUrl(value); - const settings = readSettings(); - settings.proxy = proxy; - return writeSettings(settings); + proxy = Request.parseProxyUrl(value); } catch (error: unknown) { throw new Error(`invalid proxyURL: ${(error as Error).message}`); } + const settings = readSettings(); + settings.proxy = proxy; + return writeSettings(settings); } case ConfigItem.ckbVersion: { - const settings = readSettings(); - try { - if (isValidVersion(value)) { - const version = extractVersion(value!); - settings.bins.defaultCKBVersion = version; - return writeSettings(settings); - } else { - throw new Error( - `invalid version value, ${value}. Check available versions on https://github.com/nervosnetwork/ckb/tags`, - ); - } - } catch (error: unknown) { - throw new Error(`invalid version value: ${(error as Error).message}`); + if (!isValidVersion(value)) { + throw new Error( + `invalid version value, ${value}. Check available versions on https://github.com/nervosnetwork/ckb/tags`, + ); } + const settings = readSettings(); + const version = extractVersion(value!); + settings.bins.defaultCKBVersion = version; + return writeSettings(settings); } default: diff --git a/src/cmd/debug.ts b/src/cmd/debug.ts index 430326b5..8024d110 100644 --- a/src/cmd/debug.ts +++ b/src/cmd/debug.ts @@ -8,8 +8,10 @@ import { Network } from '../type/base'; import { encodeBinPathForTerminal } from '../util/encoding'; import { callJsonRpc } from '../util/json-rpc'; import { logger } from '../util/logger'; +import { validateTxHash } from '../util/validator'; export async function debugTransaction(txHash: string, network: Network) { + validateTxHash(txHash); const txFile = await buildTxFileOptionBy(txHash, network); const opts = buildTransactionDebugOptions(txHash, network); for (const opt of opts) { @@ -19,6 +21,7 @@ export async function debugTransaction(txHash: string, network: Network) { } export function buildTransactionDebugOptions(txHash: string, network: Network) { + validateTxHash(txHash); const txJsonFilePath = buildTransactionJsonFilePath(network, txHash); const txJson = JSON.parse(fs.readFileSync(txJsonFilePath, 'utf-8')); const cccTx = cccA.JsonRpcTransformers.transactionTo(txJson); @@ -57,6 +60,7 @@ export async function debugSingleScript( network: Network, bin?: string, ) { + validateTxHash(txHash); const txFile = await buildTxFileOptionBy(txHash, network); let opt = `--cell-index ${cellIndex} --cell-type ${cellType} --script-group-type ${scriptType}`; if (bin) { @@ -83,6 +87,9 @@ export function parseSingleScriptOption(value: string) { } export async function buildTxFileOptionBy(txHash: string, network: Network) { + // The hash is interpolated into cache file paths below; reject anything that + // is not a plain 32-byte hash before touching the filesystem. + validateTxHash(txHash); const settings = readSettings(); const outputFilePath = buildDebugFullTransactionFilePath(network, txHash); if (!fs.existsSync(outputFilePath)) { diff --git a/src/cmd/deploy.ts b/src/cmd/deploy.ts index 1347a44f..f388dec1 100644 --- a/src/cmd/deploy.ts +++ b/src/cmd/deploy.ts @@ -9,7 +9,7 @@ import { confirm } from '@inquirer/prompts'; import { logger } from '../util/logger'; import { resolvePrivateKey } from '../util/private-key'; import { warnIfForkIndexerIsBehind } from '../devnet/readiness'; -import { warnIfMainnetForkSigning } from '../util/fork-safety'; +import { validateMainnetForkSigning } from '../util/fork-safety'; export interface DeployOptions extends NetworkOption { target?: string; @@ -18,6 +18,7 @@ export interface DeployOptions extends NetworkOption { privkeyFile?: string | null; typeId?: boolean; yes?: boolean; + allowExternalKeyOnMainnetFork?: boolean; } export async function deploy( @@ -28,7 +29,11 @@ export async function deploy( // we use deployerAccount to deploy contract by default const privateKey = resolvePrivateKey(opt, deployerAccount.privkey); - warnIfMainnetForkSigning(network, privateKey); + const rejectInputsAtOrBeforeBlock = validateMainnetForkSigning( + network, + privateKey, + opt.allowExternalKeyOnMainnetFork, + ); await warnIfForkIndexerIsBehind(network); const ckb = new CKB({ network }); const enableTypeId = opt.typeId ?? false; @@ -73,7 +78,14 @@ export async function deploy( } } - const results = await deployBinaries(outputFolder, binPaths, privateKey, enableTypeId, ckb); + const results = await deployBinaries( + outputFolder, + binPaths, + privateKey, + enableTypeId, + ckb, + rejectInputsAtOrBeforeBlock, + ); logger.info(''); // record the deployed contract infos diff --git a/src/cmd/devnet-config.ts b/src/cmd/devnet-config.ts index c1cb6aa6..55ad95ac 100644 --- a/src/cmd/devnet-config.ts +++ b/src/cmd/devnet-config.ts @@ -70,10 +70,14 @@ export async function devnetConfig(options: DevnetConfigOptions = {}) { logger.info('No changes saved.'); } catch (error) { - let message = error instanceof Error ? error.message : String(error); if (error instanceof InitializationError) { - message += ' Tip: run `offckb node` once to initialize devnet config files first.'; + // Rethrow the same object so its name and stack stay intact. + error.message += ' Tip: run `offckb node` once to initialize devnet config files first.'; + throw error; } - throw new Error(message); + if (error instanceof Error) { + throw error; + } + throw new Error(String(error)); } } diff --git a/src/cmd/node.ts b/src/cmd/node.ts index a9f6b477..ece3dd7d 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -1,4 +1,4 @@ -import { exec, spawn, ChildProcess } from 'child_process'; +import { execFile, execFileSync, spawn, ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import { initChainIfNeeded } from '../node/init-chain'; @@ -190,12 +190,42 @@ function resolveDaemonPaths() { return { logDir, logFile, pidFile }; } +// Best-effort check that the spawned process is the one listening on the RPC +// port. Returns null when the check cannot be performed (Windows, no lsof) so +// callers can fall back to weaker signals. +function isProcessListeningOnPort(pid: number, port: number): boolean | null { + if (process.platform === 'win32') return null; + try { + execFileSync('lsof', ['-a', '-p', String(pid), '-iTCP:' + port, '-sTCP:LISTEN'], { + stdio: ['ignore', 'pipe', 'ignore'], + }); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + return false; + } +} + +function rpcPortOf(rpcUrl: string): number | null { + try { + const url = new URL(rpcUrl); + if (url.port) return Number(url.port); + return url.protocol === 'https:' ? 443 : 80; + } catch { + return null; + } +} + // Poll the devnet RPC until the spawned node answers with the fork's genesis // hash, then mark the first run as done so subsequent `offckb node` runs boot -// normally. Two guards against clearing the flag on the wrong signal: +// normally. Guards against clearing the flag on the wrong signal: // - the poll aborts when the spawned ckb process exits (e.g. failed boot), // - an answering node is only trusted when its genesis matches the fork -// state — an unrelated node occupying the port must not clear the flag. +// state — an unrelated node occupying the port must not clear the flag, +// - when it can be determined, the spawned process must be the RPC listener: +// the fork keeps the source chain's genesis hash, so a stale source or +// fork node sharing the port would otherwise pass the genesis check and +// supply a wrong fork boundary. async function clearForkFirstRunWhenNodeUp( ckbProcess: ChildProcess, rpcUrl: string, @@ -221,6 +251,16 @@ async function clearForkFirstRunWhenNodeUp( ); return; } + const rpcPort = rpcPortOf(rpcUrl); + const listening = + ckbProcess.pid != null && rpcPort != null ? isProcessListeningOnPort(ckbProcess.pid, rpcPort) : null; + if (listening === false) { + // Something else is answering at the RPC URL while our process has not + // bound the port (yet). Do not read the fork boundary from it. + logger.debug(`Waiting for the spawned CKB process to bind the RPC port ${rpcPort} ..`); + await new Promise((resolve) => setTimeout(resolve, 1000)); + continue; + } // The miner has not started yet, so this tip is the exact boundary // between copied public-chain state and cells mined on the local fork. const forkBlockNumber = BigInt(String(await callJsonRpc(rpcUrl, 'get_tip_block_number', [], 5000))).toString(); @@ -382,24 +422,25 @@ function waitForProcessExit(pid: number, timeoutMs: number): Promise { function getProcessCommandLine(pid: number): Promise { return new Promise((resolve) => { - if (process.platform === 'win32') { - exec(`wmic process where ProcessId=${pid} get CommandLine /format:list`, (error, stdout) => { - if (error) { - resolve(null); - return; - } + // Argument arrays, never an interpolated shell string: even though pid is + // validated as a positive integer on every path here, execFile keeps that + // true after any future refactor. + const [cmd, args]: [string, string[]] = + process.platform === 'win32' + ? ['wmic', ['process', 'where', `ProcessId=${pid}`, 'get', 'CommandLine', '/format:list']] + : ['ps', ['-p', String(pid), '-o', 'args=']]; + execFile(cmd, args, (error, stdout) => { + if (error) { + resolve(null); + return; + } + if (process.platform === 'win32') { const match = stdout.match(/CommandLine=(.+)/); resolve(match ? match[1].trim() : null); - }); - } else { - exec(`ps -p ${pid} -o args=`, (error, stdout) => { - if (error) { - resolve(null); - return; - } + } else { resolve(stdout.trim()); - }); - } + } + }); }); } diff --git a/src/cmd/transfer-all.ts b/src/cmd/transfer-all.ts index c6aefb81..2377933d 100644 --- a/src/cmd/transfer-all.ts +++ b/src/cmd/transfer-all.ts @@ -5,11 +5,12 @@ import { validateNetworkOpt } from '../util/validator'; import { logger } from '../util/logger'; import { resolvePrivateKey } from '../util/private-key'; import { warnIfForkIndexerIsBehind } from '../devnet/readiness'; -import { warnIfMainnetForkSigning } from '../util/fork-safety'; +import { validateMainnetForkSigning } from '../util/fork-safety'; export interface TransferAllOptions extends NetworkOption { privkey?: string | null; privkeyFile?: string | null; + allowExternalKeyOnMainnetFork?: boolean; } export async function transferAll(toAddress: string, opt: TransferAllOptions = { network: Network.devnet }) { @@ -17,13 +18,20 @@ export async function transferAll(toAddress: string, opt: TransferAllOptions = { validateNetworkOpt(network); const privateKey = resolvePrivateKey(opt); - warnIfMainnetForkSigning(network, privateKey); + // transfer-all sweeps the whole balance, which makes it the most likely + // command to pick up copied pre-fork Mainnet cells — enforce, not just warn. + const rejectInputsAtOrBeforeBlock = validateMainnetForkSigning( + network, + privateKey, + opt.allowExternalKeyOnMainnetFork, + ); await warnIfForkIndexerIsBehind(network); const ckb = new CKB({ network }); const txHash = await ckb.transferAll({ toAddress, privateKey, + rejectInputsAtOrBeforeBlock, }); if (network === 'testnet') { logger.info(`Successfully transfer, check ${buildTestnetTxLink(txHash)} for details.`); diff --git a/src/cmd/transfer.ts b/src/cmd/transfer.ts index 0be49112..e60b1afe 100644 --- a/src/cmd/transfer.ts +++ b/src/cmd/transfer.ts @@ -12,7 +12,7 @@ export interface TransferOptions extends NetworkOption { privkeyFile?: string | null; udtKind?: UdtKind; udtTypeArgs?: string; - allowMainnetReplayRisk?: boolean; + allowExternalKeyOnMainnetFork?: boolean; } export async function transfer(toAddress: string, amount: string, opt: TransferOptions = { network: Network.devnet }) { @@ -32,7 +32,11 @@ export async function transfer(toAddress: string, amount: string, opt: TransferO } const privateKey = resolvePrivateKey(opt); - const rejectInputsAtOrBeforeBlock = validateMainnetForkSigning(network, privateKey, opt.allowMainnetReplayRisk); + const rejectInputsAtOrBeforeBlock = validateMainnetForkSigning( + network, + privateKey, + opt.allowExternalKeyOnMainnetFork, + ); await warnIfForkIndexerIsBehind(network); const ckb = new CKB({ network }); diff --git a/src/cmd/udt.ts b/src/cmd/udt.ts index 517ea5f3..802ca9ea 100644 --- a/src/cmd/udt.ts +++ b/src/cmd/udt.ts @@ -5,7 +5,7 @@ import { validateNetworkOpt, validateUdtAmount, validateUdtKind, validateUdtType import { resolvePrivateKey } from '../util/private-key'; import { logger } from '../util/logger'; import { warnIfForkIndexerIsBehind } from '../devnet/readiness'; -import { warnIfMainnetForkSigning } from '../util/fork-safety'; +import { validateMainnetForkSigning } from '../util/fork-safety'; export interface UdtIssueOption extends NetworkOption { udtKind: UdtKind; @@ -13,6 +13,7 @@ export interface UdtIssueOption extends NetworkOption { to?: string; privkey?: string; privkeyFile?: string; + allowExternalKeyOnMainnetFork?: boolean; } export interface UdtDestroyOption extends NetworkOption { @@ -20,6 +21,7 @@ export interface UdtDestroyOption extends NetworkOption { typeArgs: string; privkey?: string; privkeyFile?: string; + allowExternalKeyOnMainnetFork?: boolean; } export async function udtIssue(amount: string, opt: UdtIssueOption = { network: Network.devnet, udtKind: 'sudt' }) { @@ -30,7 +32,11 @@ export async function udtIssue(amount: string, opt: UdtIssueOption = { network: const typeArgs = opt.typeArgs ? validateUdtTypeArgs(opt.udtKind, opt.typeArgs) : undefined; const privateKey = resolvePrivateKey(opt); - warnIfMainnetForkSigning(network, privateKey); + const rejectInputsAtOrBeforeBlock = validateMainnetForkSigning( + network, + privateKey, + opt.allowExternalKeyOnMainnetFork, + ); await warnIfForkIndexerIsBehind(network); const ckb = new CKB({ network }); @@ -40,6 +46,7 @@ export async function udtIssue(amount: string, opt: UdtIssueOption = { network: amount, typeArgs, toAddress: opt.to, + rejectInputsAtOrBeforeBlock, }); logTxSuccess(network, result.txHash, 'issued UDT'); @@ -70,7 +77,11 @@ export async function udtDestroy( const typeArgs = validateUdtTypeArgs(opt.udtKind, opt.typeArgs); const privateKey = resolvePrivateKey(opt); - warnIfMainnetForkSigning(network, privateKey); + const rejectInputsAtOrBeforeBlock = validateMainnetForkSigning( + network, + privateKey, + opt.allowExternalKeyOnMainnetFork, + ); await warnIfForkIndexerIsBehind(network); const ckb = new CKB({ network }); @@ -79,6 +90,7 @@ export async function udtDestroy( kind: opt.udtKind, amount, typeArgs, + rejectInputsAtOrBeforeBlock, }); logTxSuccess(network, txHash, 'destroyed UDT'); diff --git a/src/deploy/index.ts b/src/deploy/index.ts index 609c98d8..1be75b3b 100644 --- a/src/deploy/index.ts +++ b/src/deploy/index.ts @@ -81,13 +81,14 @@ export async function deployBinaries( privateKey: HexString, enableTypeId: boolean, ckb: CKB, + rejectInputsAtOrBeforeBlock?: bigint, ) { if (binPaths.length === 0) { logger.info('No binary to deploy.'); } const results: DeployedInterfaceType[] = []; for (const bin of binPaths) { - const result = await deployBinary(outputFolder, bin, privateKey, enableTypeId, ckb); + const result = await deployBinary(outputFolder, bin, privateKey, enableTypeId, ckb, rejectInputsAtOrBeforeBlock); results.push(result); } return results; @@ -99,6 +100,7 @@ export async function deployBinary( privateKey: HexString, enableTypeId: boolean, ckb: CKB, + rejectInputsAtOrBeforeBlock?: bigint, ): Promise<{ deploymentRecipe: DeploymentRecipe; deploymentOptions: DeploymentOptions; @@ -107,10 +109,10 @@ export async function deployBinary( const contractName = path.basename(binPath); const result = !enableTypeId - ? await ckb.deployScript(bin, privateKey) + ? await ckb.deployScript(bin, privateKey, rejectInputsAtOrBeforeBlock) : Migration.isDeployedWithTypeId(outputFolder, contractName, ckb.network) - ? await ckb.upgradeTypeIdScript(outputFolder, contractName, bin, privateKey) - : await ckb.deployNewTypeIDScript(bin, privateKey); + ? await ckb.upgradeTypeIdScript(outputFolder, contractName, bin, privateKey, rejectInputsAtOrBeforeBlock) + : await ckb.deployNewTypeIDScript(bin, privateKey, rejectInputsAtOrBeforeBlock); logger.info(`contract ${contractName} deployed, tx hash:`, result.txHash); logger.info('wait for tx confirmed on-chain...'); diff --git a/src/devnet/fork.ts b/src/devnet/fork.ts index 1200bea9..ba3eaa2e 100644 --- a/src/devnet/fork.ts +++ b/src/devnet/fork.ts @@ -269,11 +269,30 @@ export function copySourceData(sourceDir: string, configPath: string): void { // comparisons. for (const entry of fs.readdirSync(sourceData)) { if (excludedTopLevelEntries.has(entry)) continue; - fs.cpSync(path.join(sourceData, entry), path.join(targetData, entry), { recursive: true }); + const sourceEntry = path.join(sourceData, entry); + // fs.cpSync resolves symlinks by default; a symlinked entry (especially + // data/db) would silently copy data from outside the source directory. + assertNoSymlink(sourceEntry); + fs.cpSync(sourceEntry, path.join(targetData, entry), { + recursive: true, + filter: (src) => { + assertNoSymlink(src); + return true; + }, + }); } logger.info('Excluded source network peers and transient logs/tmp data from the fork.'); } +function assertNoSymlink(entryPath: string): void { + if (fs.lstatSync(entryPath).isSymbolicLink()) { + throw new Error( + `Refusing to copy ${entryPath}: symlinked entries are not allowed in the source chain data. ` + + 'Replace the symlink with the real directory and retry.', + ); + } +} + export function isolateForkCkbConfig(config: Record): Record { const network = { ...((config.network as Record) ?? {}) }; network.bootnodes = []; diff --git a/src/sdk/ckb.ts b/src/sdk/ckb.ts index 08218a5d..f9f70d5e 100644 --- a/src/sdk/ckb.ts +++ b/src/sdk/ckb.ts @@ -43,7 +43,7 @@ export interface TransferOption { rejectInputsAtOrBeforeBlock?: bigint; } -export type TransferAllOption = Pick; +export type TransferAllOption = Pick; export interface UdtTransferOption { privateKey: HexString; @@ -60,6 +60,7 @@ export interface UdtIssueOption { amount: HexNumber; typeArgs?: HexString; toAddress?: string; + rejectInputsAtOrBeforeBlock?: bigint; } export interface UdtIssueResult { @@ -73,6 +74,7 @@ export interface UdtDestroyOption { kind: UdtKind; typeArgs: HexString; amount: HexNumber; + rejectInputsAtOrBeforeBlock?: bigint; } export interface UdtBalanceInfo { @@ -203,7 +205,7 @@ export class CKB { return txHash; } - async transferAll({ privateKey, toAddress }: TransferAllOption): Promise { + async transferAll({ privateKey, toAddress, rejectInputsAtOrBeforeBlock }: TransferAllOption): Promise { const signer = this.buildSigner(privateKey); const to = await ccc.Address.fromString(toAddress, this.client); const balanceInCKB = await this.balance((await signer.getRecommendedAddressObj()).toString()); @@ -219,6 +221,7 @@ export class CKB { ], }); await tx.completeInputsByCapacity(signer); + await this.assertInputsCreatedAfter(tx, rejectInputsAtOrBeforeBlock); const txHash = await signer.sendTransaction(tx); return txHash; } @@ -277,9 +280,10 @@ export class CKB { { kind: UdtKind; codeHash: HexString; hashType: string; args: HexString; balance: bigint } >(); - let scanned = 0; - + // Each kind gets its own scan budget: if the SUDT scan alone reached + // maxCells, a shared counter would silently drop every XUDT balance. const scan = async (scriptInfo: UdtScriptInfo, kind: UdtKind) => { + let scanned = 0; for await (const cell of this.client.findCells( { script: { @@ -435,7 +439,14 @@ export class CKB { } } - async udtIssue({ privateKey, kind, amount, typeArgs, toAddress }: UdtIssueOption): Promise { + async udtIssue({ + privateKey, + kind, + amount, + typeArgs, + toAddress, + rejectInputsAtOrBeforeBlock, + }: UdtIssueOption): Promise { const signer = this.buildSigner(privateKey); const signerAddress = await signer.getAddressObjSecp256k1(); const to = toAddress ? await ccc.Address.fromString(toAddress, this.client) : signerAddress; @@ -476,12 +487,13 @@ export class CKB { await tx.completeInputsByCapacity(signer); await tx.completeFeeBy(signer, this.feeRate); + await this.assertInputsCreatedAfter(tx, rejectInputsAtOrBeforeBlock); const txHash = await signer.sendTransaction(tx); return { txHash, typeArgs: resolvedTypeArgs, receiver: to.toString() }; } async udtDestroy( - { privateKey, kind, typeArgs, amount }: UdtDestroyOption, + { privateKey, kind, typeArgs, amount, rejectInputsAtOrBeforeBlock }: UdtDestroyOption, { maxInputCells = DEFAULT_UDT_DESTROY_MAX_INPUT_CELLS }: { maxInputCells?: number } = {}, ): Promise { const signer = this.buildSigner(privateKey); @@ -537,11 +549,16 @@ export class CKB { await tx.completeInputsByCapacity(signer); await tx.completeFeeBy(signer, this.feeRate); + await this.assertInputsCreatedAfter(tx, rejectInputsAtOrBeforeBlock); const txHash = await signer.sendTransaction(tx); return txHash; } - async deployScript(scriptBinBytes: Uint8Array, privateKey: string): Promise { + async deployScript( + scriptBinBytes: Uint8Array, + privateKey: string, + rejectInputsAtOrBeforeBlock?: bigint, + ): Promise { const signer = this.buildSigner(privateKey); const signerSecp256k1Address = await signer.getAddressObjSecp256k1(); const tx = ccc.Transaction.from({ @@ -554,11 +571,16 @@ export class CKB { }); await tx.completeInputsByCapacity(signer); await tx.completeFeeBy(signer, this.feeRate); + await this.assertInputsCreatedAfter(tx, rejectInputsAtOrBeforeBlock); const txHash = await signer.sendTransaction(tx); return { txHash, tx, scriptOutputCellIndex: 0, isTypeId: false }; } - async deployNewTypeIDScript(scriptBinBytes: Uint8Array, privateKey: string): Promise { + async deployNewTypeIDScript( + scriptBinBytes: Uint8Array, + privateKey: string, + rejectInputsAtOrBeforeBlock?: bigint, + ): Promise { const signer = this.buildSigner(privateKey); const signerSecp256k1Address = await signer.getAddressObjSecp256k1(); const typeIdTx = ccc.Transaction.from({ @@ -576,6 +598,7 @@ export class CKB { } typeIdTx.outputs[0].type.args = ccc.hashTypeId(typeIdTx.inputs[0], 0); await typeIdTx.completeFeeBy(signer, this.feeRate); + await this.assertInputsCreatedAfter(typeIdTx, rejectInputsAtOrBeforeBlock); const txHash = await signer.sendTransaction(typeIdTx); return { txHash, tx: typeIdTx, scriptOutputCellIndex: 0, isTypeId: true, typeId: typeIdTx.outputs[0].type }; } @@ -585,6 +608,7 @@ export class CKB { scriptName: string, newScriptBinBytes: Uint8Array, privateKey: HexString, + rejectInputsAtOrBeforeBlock?: bigint, ): Promise { const deploymentReceipt = Migration.find(baseFolder, scriptName, this.network); if (deploymentReceipt == null) throw new Error("no migration file, can't be updated."); @@ -635,6 +659,7 @@ export class CKB { } typeIdTx.outputs[0].type.args = typeIdArgs as `0x{string}`; await typeIdTx.completeFeeBy(signer, this.feeRate); + await this.assertInputsCreatedAfter(typeIdTx, rejectInputsAtOrBeforeBlock); const txHash = await signer.sendTransaction(typeIdTx); return { txHash, tx: typeIdTx, scriptOutputCellIndex: 0, isTypeId: true, typeId: typeIdTx.outputs[0].type }; } diff --git a/src/tools/ckb-tui.ts b/src/tools/ckb-tui.ts index 086719fa..027a67d9 100644 --- a/src/tools/ckb-tui.ts +++ b/src/tools/ckb-tui.ts @@ -149,12 +149,17 @@ export class CKBTui { const archivePath = path.join(tempDir, assetName); try { - // 1. Download + // 1. Download. Keep curl's own limit aligned with the outer spawnSync + // timeout so the two never disagree about who gives up first. logger.info(`Downloading ckb-tui from ${downloadUrl}...`); - const curlResult = spawnSync('curl', ['-fsSL', '--max-time', '300', '-o', archivePath, downloadUrl], { - stdio: 'inherit', - timeout: DOWNLOAD_TIMEOUT_MS, - }); + const curlResult = spawnSync( + 'curl', + ['-fsSL', '--max-time', String(DOWNLOAD_TIMEOUT_MS / 1000), '-o', archivePath, downloadUrl], + { + stdio: 'inherit', + timeout: DOWNLOAD_TIMEOUT_MS, + }, + ); if (curlResult.error) { throw new Error(`Failed to download ckb-tui: ${curlResult.error.message}`); @@ -179,8 +184,19 @@ export class CKBTui { throw new Error(`ckb-tui binary ("${binaryName}") was not found after extraction.`); } - // 5. Atomically move to the final location - fs.renameSync(extractedBinary, this.binaryPath); + // 5. Move to the final location. renameSync is atomic but throws EXDEV + // when the temp dir and the data path live on different filesystems + // (common in containers), so fall back to copy+unlink there. + try { + fs.renameSync(extractedBinary, this.binaryPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EXDEV') { + fs.copyFileSync(extractedBinary, this.binaryPath); + fs.unlinkSync(extractedBinary); + } else { + throw error; + } + } // 6. Make executable on Unix if (process.platform !== 'win32') { diff --git a/src/util/fork-safety.ts b/src/util/fork-safety.ts index b5508dd5..8e8eaa9f 100644 --- a/src/util/fork-safety.ts +++ b/src/util/fork-safety.ts @@ -23,16 +23,16 @@ export function warnIfMainnetForkSigning(network: Network, privateKey: string): export function validateMainnetForkSigning( network: Network, privateKey: string, - allowMainnetReplayRisk = false, + allowExternalKeyOnMainnetFork = false, ): bigint | undefined { const fork = readMainnetForkState(network); if (!fork) return undefined; logMainnetForkSigningWarning(privateKey); - if (!BUILT_IN_DEV_KEYS.has(privateKey.trim().toLowerCase()) && !allowMainnetReplayRisk) { + if (!BUILT_IN_DEV_KEYS.has(privateKey.trim().toLowerCase()) && !allowExternalKeyOnMainnetFork) { throw new Error( 'Refusing to sign with a non-built-in private key on a Mainnet fork. ' + - 'Use --allow-mainnet-replay-risk only after verifying that no copied Mainnet input will be selected.', + 'Use --allow-external-key-on-mainnet-fork only after verifying that no copied Mainnet input will be selected.', ); } if (fork.forkBlockNumber == null) { diff --git a/src/util/validator.ts b/src/util/validator.ts index 5f7b4620..98b32f7d 100644 --- a/src/util/validator.ts +++ b/src/util/validator.ts @@ -142,6 +142,7 @@ export function validateUdtAmount(amount: string): bigint { } const HEX_REGEX = /^0x[0-9a-fA-F]*$/; +const TX_HASH_REGEX = /^0x[0-9a-fA-F]{64}$/; export function validateHexString(value: string, name: string): HexString { if (!value || !HEX_REGEX.test(value)) { @@ -150,14 +151,26 @@ export function validateHexString(value: string, name: string): HexString { return value as HexString; } +export function validateTxHash(txHash: string): HexString { + if (!TX_HASH_REGEX.test(txHash)) { + throw new Error(`invalid transaction hash "${txHash}", must be a 0x-prefixed 32-byte hex string`); + } + return txHash as HexString; +} + export function validateUdtTypeArgs(kind: UdtKind, typeArgs: string): HexString { const hex = validateHexString(typeArgs, 'type args'); + if ((hex.length - 2) % 2 !== 0) { + throw new Error(`invalid ${kind === 'sudt' ? 'SUDT' : 'xUDT'} type args: hex must encode whole bytes`); + } const byteLength = (hex.length - 2) / 2; if (kind === 'sudt' && byteLength !== 32) { throw new Error(`invalid SUDT type args length: expected 32 bytes, got ${byteLength}`); } - if (kind === 'xudt' && byteLength !== 32) { - throw new Error(`invalid xUDT type args length: expected 32 bytes, got ${byteLength}`); + // xUDT args are the 32-byte owner lock hash plus optional flags and + // extension data, so 32 bytes is the minimum, not the exact, length. + if (kind === 'xudt' && byteLength < 32) { + throw new Error(`invalid xUDT type args length: expected at least 32 bytes, got ${byteLength}`); } return hex; } diff --git a/tests/debug-tx-file.test.ts b/tests/debug-tx-file.test.ts index d1622e9a..1567581e 100644 --- a/tests/debug-tx-file.test.ts +++ b/tests/debug-tx-file.test.ts @@ -120,4 +120,15 @@ describe('buildTxFileOptionBy', () => { `Failed to fetch transaction ${TX_HASH} from http://127.0.0.1:8114: connect ECONNREFUSED`, ); }); + + it('rejects a malformed tx hash before touching any cache path', async () => { + for (const badHash of ['0x../escape', 'not-a-hash', '0x' + 'ab'.repeat(31), '0x' + 'ab'.repeat(33)]) { + await expect(buildTxFileOptionBy(badHash, Network.devnet)).rejects.toThrow('invalid transaction hash'); + } + + expect(mockExistsSync).not.toHaveBeenCalled(); + expect(mockCallJsonRpc).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + expect(mockDumpTransaction).not.toHaveBeenCalled(); + }); }); diff --git a/tests/devnet-fork.test.ts b/tests/devnet-fork.test.ts index 03feb6e7..a865622d 100644 --- a/tests/devnet-fork.test.ts +++ b/tests/devnet-fork.test.ts @@ -179,6 +179,35 @@ describe('fork data isolation and migration preflight', () => { expect(fs.existsSync(path.join(target, 'data', 'tmp'))).toBe(false); }); + it('rejects symlinked top-level entries in the source chain data', () => { + if (process.platform === 'win32') return; + const source = path.join(root, 'source'); + const target = path.join(root, 'target'); + const outside = path.join(root, 'outside'); + fs.mkdirSync(path.join(source, 'data'), { recursive: true }); + fs.mkdirSync(outside, { recursive: true }); + fs.writeFileSync(path.join(outside, 'fixture'), 'secret'); + fs.symlinkSync(outside, path.join(source, 'data', 'db'), 'dir'); + + expect(() => copySourceData(source, target)).toThrow('symlinked entries are not allowed'); + expect(fs.existsSync(path.join(target, 'data', 'db', 'fixture'))).toBe(false); + }); + + it('rejects symlinks nested inside copied directories', () => { + if (process.platform === 'win32') return; + const source = path.join(root, 'source'); + const target = path.join(root, 'target'); + const outside = path.join(root, 'outside'); + fs.mkdirSync(path.join(source, 'data', 'db'), { recursive: true }); + fs.writeFileSync(path.join(source, 'data', 'db', 'fixture'), 'db'); + fs.mkdirSync(outside, { recursive: true }); + fs.writeFileSync(path.join(outside, 'fixture'), 'secret'); + fs.symlinkSync(path.join(outside, 'fixture'), path.join(source, 'data', 'db', 'linked')); + + expect(() => copySourceData(source, target)).toThrow('symlinked entries are not allowed'); + expect(fs.existsSync(path.join(target, 'data', 'db', 'linked'))).toBe(false); + }); + it('forces forked nodes into an outbound-isolated network config', () => { const config = isolateForkCkbConfig({ network: { bootnodes: ['mainnet-peer'], max_outbound_peers: 8, discovery_local_address: true }, diff --git a/tests/fork-safety.test.ts b/tests/fork-safety.test.ts index 87d93b86..664a5e58 100644 --- a/tests/fork-safety.test.ts +++ b/tests/fork-safety.test.ts @@ -41,7 +41,7 @@ describe('Mainnet fork signing warning', () => { it('requires an explicit override for an external key', () => { mockFork = { source: 'mainnet', forkBlockNumber: '100' }; expect(() => validateMainnetForkSigning(Network.devnet, '0x' + '11'.repeat(32))).toThrow( - '--allow-mainnet-replay-risk', + '--allow-external-key-on-mainnet-fork', ); }); diff --git a/tests/node-command.test.ts b/tests/node-command.test.ts index c7807200..c3e189ba 100644 --- a/tests/node-command.test.ts +++ b/tests/node-command.test.ts @@ -3,7 +3,7 @@ import { Network } from '../src/type/base'; import * as path from 'path'; const mockSpawn = jest.fn(); -const mockExec = jest.fn(); +const mockExecFile = jest.fn(); const mockOpenSync = jest.fn(); const mockWriteFileSync = jest.fn(); const mockMkdirSync = jest.fn(); @@ -17,7 +17,7 @@ const mockWaitForNodeReady = jest.fn(); jest.mock('child_process', () => ({ ...jest.requireActual('child_process'), spawn: (...args: unknown[]) => mockSpawn(...args), - exec: (...args: unknown[]) => mockExec(...args), + execFile: (...args: unknown[]) => mockExecFile(...args), })); jest.mock('fs', () => ({ @@ -80,22 +80,23 @@ import { logger } from '../src/util/logger'; const dataPath = '/tmp/offckb-devnet-data'; const logDir = path.join(dataPath, 'logs'); const pidFile = path.join(logDir, 'daemon.pid'); -const logFile = path.join(logDir, 'daemon.log'); function mockDaemonCommandLine(scriptPath: string) { - mockExec.mockImplementation((cmd: string, callback: (err: Error | null, stdout?: string) => void) => { - if (cmd.startsWith('ps ')) { - callback(null, `/usr/bin/node ${scriptPath} node`); - return undefined as unknown as ReturnType; - } - if (cmd.startsWith('wmic ')) { - // WMIC returns key/value pairs, e.g. "CommandLine=..." - callback(null, `CommandLine=/usr/bin/node ${scriptPath} node`); - return undefined as unknown as ReturnType; - } - callback(null, ''); - return undefined as unknown as ReturnType; - }); + mockExecFile.mockImplementation( + (file: string, _args: string[], callback: (err: Error | null, stdout?: string) => void) => { + if (file === 'ps') { + callback(null, `/usr/bin/node ${scriptPath} node`); + return undefined as unknown as ReturnType; + } + if (file === 'wmic') { + // WMIC returns key/value pairs, e.g. "CommandLine=..." + callback(null, `CommandLine=/usr/bin/node ${scriptPath} node`); + return undefined as unknown as ReturnType; + } + callback(null, ''); + return undefined as unknown as ReturnType; + }, + ); } describe('node command daemon mode', () => { @@ -195,10 +196,12 @@ describe('node command daemon mode', () => { mockReadFileSync.mockReturnValue( JSON.stringify({ pid: 9999, scriptPath: '/path/to/offckb', startedAt: new Date().toISOString() }), ); - mockExec.mockImplementation((_cmd: string, callback: (err: Error | null, stdout?: string) => void) => { - callback(null, '/usr/bin/some-unrelated-process'); - return undefined as unknown as ReturnType; - }); + mockExecFile.mockImplementation( + (_file: string, _args: string[], callback: (err: Error | null, stdout?: string) => void) => { + callback(null, '/usr/bin/some-unrelated-process'); + return undefined as unknown as ReturnType; + }, + ); await startNode({ network: Network.devnet, daemon: true }); @@ -407,7 +410,7 @@ describe('node command stop', () => { jest.useFakeTimers(); jest.clearAllMocks(); processAlive = true; - mockExec.mockReset(); + mockExecFile.mockReset(); mockStatSync.mockReturnValue({ isFile: () => true }); mockReadFileSync.mockReturnValue(JSON.stringify({ pid: 12345, scriptPath, startedAt: new Date().toISOString() })); mockDaemonCommandLine(scriptPath); @@ -504,10 +507,12 @@ describe('node command stop', () => { }); it('refuses to kill a process that does not look like the daemon', async () => { - mockExec.mockImplementation((cmd: string, callback: (err: Error | null, stdout?: string) => void) => { - callback(null, '/usr/bin/some-other-process'); - return undefined as unknown as ReturnType; - }); + mockExecFile.mockImplementation( + (_file: string, _args: string[], callback: (err: Error | null, stdout?: string) => void) => { + callback(null, '/usr/bin/some-other-process'); + return undefined as unknown as ReturnType; + }, + ); await expect(stopNode()).rejects.toThrow('does not appear to be the offckb daemon'); diff --git a/tests/transfer-all.test.ts b/tests/transfer-all.test.ts new file mode 100644 index 00000000..64bed227 --- /dev/null +++ b/tests/transfer-all.test.ts @@ -0,0 +1,86 @@ +import { Network } from '../src/type/base'; +import { transferAll } from '../src/cmd/transfer-all'; +import { CKB } from '../src/sdk/ckb'; + +const mockValidateMainnetForkSigning = jest.fn().mockReturnValue(undefined); + +jest.mock('../src/sdk/ckb', () => { + return { + CKB: jest.fn().mockImplementation(() => ({ + transferAll: jest.fn().mockResolvedValue('0xtxhash'), + })), + }; +}); + +jest.mock('../src/util/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + success: jest.fn(), + debug: jest.fn(), + result: jest.fn(), + }, +})); + +jest.mock('../src/devnet/readiness', () => ({ + warnIfForkIndexerIsBehind: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../src/util/fork-safety', () => ({ + validateMainnetForkSigning: (...args: unknown[]) => mockValidateMainnetForkSigning(...args), +})); + +describe('transfer-all command', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockValidateMainnetForkSigning.mockReturnValue(undefined); + }); + + it('sweeps the balance with the fork replay guard enforced', async () => { + const privateKey = '0x1234567812345678123456781234567812345678123456781234567812345678'; + + await transferAll('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', { + network: Network.devnet, + privkey: privateKey, + }); + + const ckbInstance = (CKB as jest.Mock).mock.results[0].value; + expect(mockValidateMainnetForkSigning).toHaveBeenCalledWith(Network.devnet, privateKey, undefined); + expect(ckbInstance.transferAll).toHaveBeenCalledWith( + expect.objectContaining({ rejectInputsAtOrBeforeBlock: undefined }), + ); + }); + + it('passes the Mainnet fork boundary to input selection checks', async () => { + mockValidateMainnetForkSigning.mockReturnValue(100n); + const privateKey = '0x1234567812345678123456781234567812345678123456781234567812345678'; + + await transferAll('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', { + network: Network.devnet, + privkey: privateKey, + allowExternalKeyOnMainnetFork: true, + }); + + const ckbInstance = (CKB as jest.Mock).mock.results[0].value; + expect(mockValidateMainnetForkSigning).toHaveBeenCalledWith(Network.devnet, privateKey, true); + expect(ckbInstance.transferAll).toHaveBeenCalledWith( + expect.objectContaining({ rejectInputsAtOrBeforeBlock: 100n }), + ); + }); + + it('fails closed when the replay guard rejects the key', async () => { + mockValidateMainnetForkSigning.mockImplementation(() => { + throw new Error('Refusing to sign with a non-built-in private key on a Mainnet fork.'); + }); + + await expect( + transferAll('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', { + network: Network.devnet, + privkey: '0x1234567812345678123456781234567812345678123456781234567812345678', + }), + ).rejects.toThrow('Refusing to sign'); + + expect(CKB).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/udt.test.ts b/tests/udt.test.ts index 03f8f567..3acbc218 100644 --- a/tests/udt.test.ts +++ b/tests/udt.test.ts @@ -124,14 +124,12 @@ describe('transfer command', () => { await transfer('ckt1q9gry5zgmceslalm9x6s5xgnqe9cjn6y0q3c9', '100', { network: Network.devnet, privkey: privateKey, - allowMainnetReplayRisk: true, + allowExternalKeyOnMainnetFork: true, }); const ckbInstance = (CKB as jest.Mock).mock.results[0].value; expect(mockValidateMainnetForkSigning).toHaveBeenCalledWith(Network.devnet, privateKey, true); - expect(ckbInstance.transfer).toHaveBeenCalledWith( - expect.objectContaining({ rejectInputsAtOrBeforeBlock: 100n }), - ); + expect(ckbInstance.transfer).toHaveBeenCalledWith(expect.objectContaining({ rejectInputsAtOrBeforeBlock: 100n })); }); it('should transfer UDT when --udt-type-args is provided', async () => { @@ -185,6 +183,7 @@ describe('transfer command', () => { describe('udt command', () => { beforeEach(() => { jest.clearAllMocks(); + mockValidateMainnetForkSigning.mockReturnValue(undefined); }); describe('udtIssue', () => { @@ -209,6 +208,22 @@ describe('udt command', () => { expect(ckbInstance.udtIssue).toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith('Successfully issued UDT, txHash:', '0xissuehash'); }); + + it('passes the Mainnet fork boundary to input selection checks', async () => { + mockValidateMainnetForkSigning.mockReturnValue(100n); + const privateKey = '0x1234567812345678123456781234567812345678123456781234567812345678'; + + await udtIssue('100', { + network: Network.devnet, + udtKind: 'sudt', + privkey: privateKey, + allowExternalKeyOnMainnetFork: true, + }); + + const ckbInstance = (CKB as jest.Mock).mock.results[0].value; + expect(mockValidateMainnetForkSigning).toHaveBeenCalledWith(Network.devnet, privateKey, true); + expect(ckbInstance.udtIssue).toHaveBeenCalledWith(expect.objectContaining({ rejectInputsAtOrBeforeBlock: 100n })); + }); }); describe('udtDestroy', () => { @@ -235,5 +250,24 @@ describe('udt command', () => { expect(ckbInstance.udtDestroy).toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith('Successfully destroyed UDT, txHash:', '0xdestroyhash'); }); + + it('passes the Mainnet fork boundary to input selection checks', async () => { + mockValidateMainnetForkSigning.mockReturnValue(100n); + const privateKey = '0x1234567812345678123456781234567812345678123456781234567812345678'; + + await udtDestroy('100', { + network: Network.devnet, + udtKind: 'sudt', + typeArgs: mockTypeArgs, + privkey: privateKey, + allowExternalKeyOnMainnetFork: true, + }); + + const ckbInstance = (CKB as jest.Mock).mock.results[0].value; + expect(mockValidateMainnetForkSigning).toHaveBeenCalledWith(Network.devnet, privateKey, true); + expect(ckbInstance.udtDestroy).toHaveBeenCalledWith( + expect.objectContaining({ rejectInputsAtOrBeforeBlock: 100n }), + ); + }); }); }); diff --git a/tests/validator.test.ts b/tests/validator.test.ts index 9006bf27..f2c1af79 100644 --- a/tests/validator.test.ts +++ b/tests/validator.test.ts @@ -111,6 +111,20 @@ describe('UDT validation helpers', () => { expect(validateUdtTypeArgs('xudt', args)).toBe(args); }); + it('should accept xUDT type args with flags and extension data', () => { + // owner lock hash (32 bytes) + 4-byte flags + const withFlags = '0x' + '12'.repeat(36); + expect(validateUdtTypeArgs('xudt', withFlags)).toBe(withFlags); + // owner lock hash + flags + extension data + const withExtension = '0x' + '12'.repeat(64); + expect(validateUdtTypeArgs('xudt', withExtension)).toBe(withExtension); + }); + + it('should reject type args that do not encode whole bytes', () => { + expect(() => validateUdtTypeArgs('xudt', '0x' + '1'.repeat(65))).toThrow('whole bytes'); + expect(() => validateUdtTypeArgs('sudt', '0x' + '1'.repeat(63))).toThrow('whole bytes'); + }); + it('should reject invalid hex', () => { expect(() => validateUdtTypeArgs('sudt', 'not-hex')).toThrow('invalid type args'); expect(() => validateUdtTypeArgs('sudt', '')).toThrow('invalid type args'); From a4bab8f6aac8c2b9a29c918a64f3275779c7b592 Mon Sep 17 00:00:00 2001 From: claude-bear Date: Thu, 23 Jul 2026 07:44:35 +0000 Subject: [PATCH 2/4] fix: address PR #466 review comments - Keep --allow-mainnet-replay-risk as a hidden deprecated alias folded into --allow-external-key-on-mainnet-fork (with a deprecation warning) so 0.4.9 scripts keep working under a patch release - Treat lsof probe failures with stderr output as indeterminate (null) instead of "not listening"; only an empty-stderr exit is a genuine no-match, so permission errors fall back to the weaker genesis signal - Reject a symlinked data root before enumerating source chain data - Stage cross-device ckb-tui installs inside binDir and publish with an atomic rename, so concurrent installs never see a truncated binary Co-Authored-By: Claude Fable 5 --- .changeset/tidy-mugs-repair.md | 2 +- README.md | 2 +- src/cli.ts | 152 ++++++++++++++-------------- src/cmd/node.ts | 16 +-- src/devnet/fork.ts | 3 + src/tools/ckb-tui.ts | 14 ++- src/util/fork-safety.ts | 17 ++++ tests/cli-mainnet-fork-flag.test.ts | 122 ++++++++++++++++++++++ tests/devnet-fork.test.ts | 14 +++ tests/fork-safety.test.ts | 22 +++- tests/node-listener.test.ts | 73 +++++++++++++ 11 files changed, 349 insertions(+), 88 deletions(-) create mode 100644 tests/cli-mainnet-fork-flag.test.ts create mode 100644 tests/node-listener.test.ts diff --git a/.changeset/tidy-mugs-repair.md b/.changeset/tidy-mugs-repair.md index 2b246884..81b2ecf8 100644 --- a/.changeset/tidy-mugs-repair.md +++ b/.changeset/tidy-mugs-repair.md @@ -2,7 +2,7 @@ '@offckb/cli': patch --- -Rename `--allow-mainnet-replay-risk` to `--allow-external-key-on-mainnet-fork` (#460) and apply the fixes left over from the 0.4.9 review (#462): +Rename `--allow-mainnet-replay-risk` to `--allow-external-key-on-mainnet-fork` (#460) — the old flag remains as a hidden deprecated alias so existing scripts keep working — and apply the fixes left over from the 0.4.9 review (#462): - Enforce the Mainnet-fork replay guard (instead of warn-only) in `transfer-all`, `udt issue`, `udt destroy`, and `deploy`, and reject inputs created at or before the fork boundary in those transactions, mirroring `transfer`/`deposit`. - Validate `--tx-hash` as a 0x-prefixed 32-byte hex string before it is used in debug cache paths. diff --git a/README.md b/README.md index 3494a3ee..a4b4941b 100644 --- a/README.md +++ b/README.md @@ -415,7 +415,7 @@ On a forked devnet, `offckb system-scripts`, transfers, deploys and `offckb debu > [!CAUTION] > CKB transactions carry no chain id, so a transaction built on a mainnet fork that spends copied mainnet cells is also valid on mainnet (CKB provides no replay protection). offckb's own flows only use dev keys and fork-mined cells, which cannot replay. Never sign transactions with real mainnet keys against a fork unless you intend to broadcast them yourself. -`offckb transfer` fails closed on a Mainnet fork: non-built-in keys require `--allow-external-key-on-mainnet-fork`, and inputs copied from Mainnet are rejected even with that override. +`offckb transfer` fails closed on a Mainnet fork: non-built-in keys require `--allow-external-key-on-mainnet-fork`, and inputs copied from Mainnet are rejected even with that override. (`--allow-mainnet-replay-risk` from 0.4.9 remains as a deprecated alias.) ## Config Setting diff --git a/src/cli.ts b/src/cli.ts index 575bdc63..82908323 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,6 +19,7 @@ import { printSystemScripts } from './cmd/system-scripts'; import { transferAll } from './cmd/transfer-all'; import { genSystemScriptsJsonFile } from './scripts/gen'; import { CKBDebugger } from './tools/ckb-debugger'; +import { resolveMainnetForkOverride } from './util/fork-safety'; import { logger } from './util/logger'; import { Network } from './type/base'; import { status } from './cmd/status'; @@ -43,6 +44,20 @@ function commandPath(command: Command): string { return names.join('.') || 'offckb'; } +// Registers the Mainnet-fork override flag plus the 0.4.9 name as a hidden +// deprecated alias; resolveMainnetForkOverride folds the alias into the new +// option before the command handler runs. +function mainnetForkOverrideOption(command: Command): Command { + return command + .option( + '--allow-external-key-on-mainnet-fork', + 'Allow a non-built-in key on a Mainnet fork (copied inputs remain blocked)', + ) + .addOption( + new Option('--allow-mainnet-replay-risk', 'Deprecated alias of --allow-external-key-on-mainnet-fork').hideHelp(), + ); +} + program.option('--json', 'Output logs in JSON format for agent/programmatic consumption'); program.hook('preAction', (_thisCommand, actionCommand) => { activeCommand = commandPath(actionCommand); @@ -83,21 +98,18 @@ program return await createScriptProject(projectName, options); }); -program - .command('deploy') - .description('Deploy contracts to different networks, only supports devnet and testnet') - .option('--network ', 'Specify the network to deploy to', 'devnet') - .option('--target ', 'Specify the script binaries file/folder path to deploy', './') - .option('-o, --output ', 'Specify the output folder path for the deployment record files', './deployment') - .option('-t, --type-id', 'Specify if use upgradable type id to deploy the script') - .option('--privkey ', 'Specify the private key to deploy scripts (visible in shell history)') - .option('--privkey-file ', 'Read the private key from a local file') - .option( - '--allow-external-key-on-mainnet-fork', - 'Allow a non-built-in key on a Mainnet fork (copied inputs remain blocked)', - ) - .option('-y, --yes', 'Skip confirmation prompt and deploy immediately') - .action((options: DeployOptions) => deploy(options)); +mainnetForkOverrideOption( + program + .command('deploy') + .description('Deploy contracts to different networks, only supports devnet and testnet') + .option('--network ', 'Specify the network to deploy to', 'devnet') + .option('--target ', 'Specify the script binaries file/folder path to deploy', './') + .option('-o, --output ', 'Specify the output folder path for the deployment record files', './deployment') + .option('-t, --type-id', 'Specify if use upgradable type id to deploy the script') + .option('--privkey ', 'Specify the private key to deploy scripts (visible in shell history)') + .option('--privkey-file ', 'Read the private key from a local file') + .option('-y, --yes', 'Skip confirmation prompt and deploy immediately'), +).action((options: DeployOptions) => deploy(resolveMainnetForkOverride(options))); program .command('debug') @@ -162,37 +174,31 @@ program await deposit(toAddress, amountInCKB, options); }); -program - .command('transfer [toAddress] [amount]') - .description('Transfer CKB or UDT tokens to address, only devnet and testnet') - .option('--network ', 'Specify the network to transfer to', 'devnet') - .option('--privkey ', 'Specify the private key to transfer (visible in shell history)') - .option('--privkey-file ', 'Read the private key from a local file') - .addOption(new Option('--udt-kind ', 'Specify the UDT kind').choices(['sudt', 'xudt'])) - .option('--udt-type-args ', 'Specify the UDT type script args to transfer UDT') - .option( - '--allow-external-key-on-mainnet-fork', - 'Allow a non-built-in key on a Mainnet fork (copied inputs remain blocked)', - ) - .option('-r, --proxy-rpc', 'Use Proxy RPC to connect to blockchain') - .action(async (toAddress: string, amount: string, options: TransferOptions) => { - await transfer(toAddress, amount, options); - }); +mainnetForkOverrideOption( + program + .command('transfer [toAddress] [amount]') + .description('Transfer CKB or UDT tokens to address, only devnet and testnet') + .option('--network ', 'Specify the network to transfer to', 'devnet') + .option('--privkey ', 'Specify the private key to transfer (visible in shell history)') + .option('--privkey-file ', 'Read the private key from a local file') + .addOption(new Option('--udt-kind ', 'Specify the UDT kind').choices(['sudt', 'xudt'])) + .option('--udt-type-args ', 'Specify the UDT type script args to transfer UDT') + .option('-r, --proxy-rpc', 'Use Proxy RPC to connect to blockchain'), +).action(async (toAddress: string, amount: string, options: TransferOptions) => { + await transfer(toAddress, amount, resolveMainnetForkOverride(options)); +}); -program - .command('transfer-all [toAddress]') - .description('Transfer All CKB tokens to address, only devnet and testnet') - .option('--network ', 'Specify the network to transfer to', 'devnet') - .option('--privkey ', 'Specify the private key (visible in shell history)') - .option('--privkey-file ', 'Read the private key from a local file') - .option( - '--allow-external-key-on-mainnet-fork', - '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, options: TransferOptions) => { - await transferAll(toAddress, options); - }); +mainnetForkOverrideOption( + program + .command('transfer-all [toAddress]') + .description('Transfer All CKB tokens to address, only devnet and testnet') + .option('--network ', 'Specify the network to transfer to', 'devnet') + .option('--privkey ', 'Specify the private key (visible in shell history)') + .option('--privkey-file ', 'Read the private key from a local file') + .option('-r, --proxy-rpc', 'Use Proxy RPC to connect to blockchain'), +).action(async (toAddress: string, options: TransferOptions) => { + await transferAll(toAddress, resolveMainnetForkOverride(options)); +}); program .command('balance [toAddress]') @@ -207,38 +213,32 @@ program const udtCommand = program.command('udt').description('UDT token commands'); -udtCommand - .command('issue ') - .description('Issue new UDT tokens, only devnet and testnet') - .option('--network ', 'Specify the network', 'devnet') - .addOption(new Option('--udt-kind ', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt')) - .option('--type-args ', 'Specify the UDT type script args (xudt only; defaults to signer lock hash)') - .option('--to ', 'Specify the receiver address (defaults to signer)') - .option('--privkey ', 'Specify the private key to issue UDT (visible in shell history)') - .option('--privkey-file ', 'Read the private key from a local file') - .option( - '--allow-external-key-on-mainnet-fork', - 'Allow a non-built-in key on a Mainnet fork (copied inputs remain blocked)', - ) - .action(async (amount: string, options: UdtIssueOption) => { - await udtIssue(amount, options); - }); +mainnetForkOverrideOption( + udtCommand + .command('issue ') + .description('Issue new UDT tokens, only devnet and testnet') + .option('--network ', 'Specify the network', 'devnet') + .addOption(new Option('--udt-kind ', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt')) + .option('--type-args ', 'Specify the UDT type script args (xudt only; defaults to signer lock hash)') + .option('--to ', 'Specify the receiver address (defaults to signer)') + .option('--privkey ', 'Specify the private key to issue UDT (visible in shell history)') + .option('--privkey-file ', 'Read the private key from a local file'), +).action(async (amount: string, options: UdtIssueOption) => { + await udtIssue(amount, resolveMainnetForkOverride(options)); +}); -udtCommand - .command('destroy ') - .description('Destroy UDT tokens, only devnet and testnet') - .option('--network ', 'Specify the network', 'devnet') - .addOption(new Option('--udt-kind ', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt')) - .requiredOption('--type-args ', 'Specify the UDT type script args') - .option('--privkey ', 'Specify the private key to destroy UDT (visible in shell history)') - .option('--privkey-file ', 'Read the private key from a local file') - .option( - '--allow-external-key-on-mainnet-fork', - 'Allow a non-built-in key on a Mainnet fork (copied inputs remain blocked)', - ) - .action(async (amount: string, options: UdtDestroyOption) => { - await udtDestroy(amount, options); - }); +mainnetForkOverrideOption( + udtCommand + .command('destroy ') + .description('Destroy UDT tokens, only devnet and testnet') + .option('--network ', 'Specify the network', 'devnet') + .addOption(new Option('--udt-kind ', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt')) + .requiredOption('--type-args ', 'Specify the UDT type script args') + .option('--privkey ', 'Specify the private key to destroy UDT (visible in shell history)') + .option('--privkey-file ', 'Read the private key from a local file'), +).action(async (amount: string, options: UdtDestroyOption) => { + await udtDestroy(amount, resolveMainnetForkOverride(options)); +}); program .command('debugger') diff --git a/src/cmd/node.ts b/src/cmd/node.ts index ece3dd7d..ee502bfc 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -191,18 +191,22 @@ function resolveDaemonPaths() { } // Best-effort check that the spawned process is the one listening on the RPC -// port. Returns null when the check cannot be performed (Windows, no lsof) so -// callers can fall back to weaker signals. -function isProcessListeningOnPort(pid: number, port: number): boolean | null { +// port. Returns null when the check cannot be performed (Windows, no lsof, or +// an lsof inspection error) so callers can fall back to weaker signals. +// lsof exits 1 both for "no match" and for permission/inspection errors; only +// an empty stderr is a genuine no-match, anything else is indeterminate. +export function isProcessListeningOnPort(pid: number, port: number): boolean | null { if (process.platform === 'win32') return null; try { execFileSync('lsof', ['-a', '-p', String(pid), '-iTCP:' + port, '-sTCP:LISTEN'], { - stdio: ['ignore', 'pipe', 'ignore'], + stdio: ['ignore', 'pipe', 'pipe'], }); return true; } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; - return false; + const err = error as NodeJS.ErrnoException & { stderr?: Buffer | string }; + if (err.code === 'ENOENT') return null; + const stderr = err.stderr?.toString().trim() ?? ''; + return stderr === '' ? false : null; } } diff --git a/src/devnet/fork.ts b/src/devnet/fork.ts index ba3eaa2e..19aaeaff 100644 --- a/src/devnet/fork.ts +++ b/src/devnet/fork.ts @@ -264,6 +264,9 @@ export function copySourceData(sourceDir: string, configPath: string): void { // place, and linked files would corrupt the source chain. const excludedTopLevelEntries = new Set(['network', 'logs', 'tmp']); fs.mkdirSync(targetData, { recursive: true }); + // A symlinked data root would bypass the per-entry checks below: + // readdirSync follows it and its ordinary children would pass assertNoSymlink. + assertNoSymlink(sourceData); // Enumerate top-level entries instead of relying on fs.cp's filter paths, // which may use Windows extended-length prefixes and bypass relative-path // comparisons. diff --git a/src/tools/ckb-tui.ts b/src/tools/ckb-tui.ts index 027a67d9..d65602e6 100644 --- a/src/tools/ckb-tui.ts +++ b/src/tools/ckb-tui.ts @@ -186,13 +186,21 @@ export class CKBTui { // 5. Move to the final location. renameSync is atomic but throws EXDEV // when the temp dir and the data path live on different filesystems - // (common in containers), so fall back to copy+unlink there. + // (common in containers). In that case stage the copy inside binDir and + // publish it with a rename, so a concurrent ensureInstalled() never sees + // a partially copied binary at the final path. try { fs.renameSync(extractedBinary, this.binaryPath); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'EXDEV') { - fs.copyFileSync(extractedBinary, this.binaryPath); - fs.unlinkSync(extractedBinary); + const stagingPath = path.join(binDir, `.${binaryName}.staging-${process.pid}`); + try { + fs.copyFileSync(extractedBinary, stagingPath); + fs.renameSync(stagingPath, this.binaryPath); + fs.unlinkSync(extractedBinary); + } finally { + fs.rmSync(stagingPath, { force: true }); + } } else { throw error; } diff --git a/src/util/fork-safety.ts b/src/util/fork-safety.ts index 8e8eaa9f..964a6ab7 100644 --- a/src/util/fork-safety.ts +++ b/src/util/fork-safety.ts @@ -5,6 +5,23 @@ import { ForkState, readForkState } from '../devnet/fork'; import { Network } from '../type/base'; import { logger } from './logger'; +export interface MainnetForkOverrideOptions { + allowExternalKeyOnMainnetFork?: boolean; + allowMainnetReplayRisk?: boolean; +} + +/** + * Map the deprecated --allow-mainnet-replay-risk flag (0.4.9) onto its + * replacement so scripts written against the old name keep working. + */ +export function resolveMainnetForkOverride(options: T): T { + if (options.allowMainnetReplayRisk) { + logger.warn('`--allow-mainnet-replay-risk` is deprecated; use `--allow-external-key-on-mainnet-fork` instead.'); + options.allowExternalKeyOnMainnetFork = true; + } + return options; +} + const BUILT_IN_DEV_KEYS = new Set( [...accountConfig.map((account) => account.privkey), ckbDevnetMinerAccount.privkey].map((key) => key.toLowerCase()), ); diff --git a/tests/cli-mainnet-fork-flag.test.ts b/tests/cli-mainnet-fork-flag.test.ts new file mode 100644 index 00000000..6a72bc4b --- /dev/null +++ b/tests/cli-mainnet-fork-flag.test.ts @@ -0,0 +1,122 @@ +const mockDeploy = jest.fn(); +const mockTransfer = jest.fn(); +const mockTransferAll = jest.fn(); +const mockUdtIssue = jest.fn(); +const mockUdtDestroy = jest.fn(); + +jest.mock('../src/cmd/node', () => ({ startNode: jest.fn(), stopNode: jest.fn() })); +jest.mock('../src/cmd/accounts', () => ({ accounts: jest.fn() })); +jest.mock('../src/cmd/clean', () => ({ clean: jest.fn() })); +jest.mock('../src/cmd/deposit', () => ({ deposit: jest.fn() })); +jest.mock('../src/cmd/deploy', () => ({ deploy: (...args: unknown[]) => mockDeploy(...args) })); +jest.mock('../src/cmd/transfer', () => ({ transfer: (...args: unknown[]) => mockTransfer(...args) })); +jest.mock('../src/cmd/balance', () => ({ balanceOf: jest.fn() })); +jest.mock('../src/cmd/udt', () => ({ + udtIssue: (...args: unknown[]) => mockUdtIssue(...args), + udtDestroy: (...args: unknown[]) => mockUdtDestroy(...args), +})); +jest.mock('../src/cmd/create', () => ({ createScriptProject: jest.fn() })); +jest.mock('../src/cmd/config', () => ({ Config: jest.fn() })); +jest.mock('../src/cmd/devnet-config', () => ({ devnetConfig: jest.fn() })); +jest.mock('../src/cmd/devnet-fork', () => ({ devnetFork: jest.fn() })); +jest.mock('../src/cmd/devnet-info', () => ({ devnetInfo: jest.fn() })); +jest.mock('../src/cmd/debug', () => ({ + debugSingleScript: jest.fn(), + debugTransaction: jest.fn(), + parseSingleScriptOption: jest.fn(), +})); +jest.mock('../src/cmd/system-scripts', () => ({ printSystemScripts: jest.fn() })); +jest.mock('../src/cmd/transfer-all', () => ({ transferAll: (...args: unknown[]) => mockTransferAll(...args) })); +jest.mock('../src/cmd/status', () => ({ status: jest.fn() })); +jest.mock('../src/scripts/gen', () => ({ genSystemScriptsJsonFile: jest.fn() })); +jest.mock('../src/tools/ckb-debugger', () => ({ CKBDebugger: { runWithArgs: jest.fn() } })); +jest.mock('../src/util/logger', () => ({ + logger: { + success: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + result: jest.fn(), + failure: jest.fn(), + setJsonMode: jest.fn(), + isJsonMode: jest.fn(() => false), + hasResult: jest.fn(() => false), + }, +})); + +// src/cli.ts builds its commander program at module scope and commander keeps +// parsed option values between parseAsync calls, so each test gets a fresh +// module registry to avoid option state leaking across runs. +function loadCli() { + jest.resetModules(); + const cli = require('../src/cli') as typeof import('../src/cli'); + const { logger } = require('../src/util/logger') as typeof import('../src/util/logger'); + return { runCli: cli.runCli, logger }; +} + +describe('deprecated --allow-mainnet-replay-risk CLI alias', () => { + beforeEach(() => { + jest.clearAllMocks(); + process.exitCode = undefined; + }); + + afterEach(() => { + process.exitCode = undefined; + }); + + it('maps the deprecated flag onto --allow-external-key-on-mainnet-fork', async () => { + const { runCli, logger } = loadCli(); + await runCli(['node', 'offckb', 'transfer', '0xrecipient', '100', '--allow-mainnet-replay-risk']); + + expect(mockTransfer).toHaveBeenCalledWith( + '0xrecipient', + '100', + expect.objectContaining({ allowExternalKeyOnMainnetFork: true }), + ); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('--allow-external-key-on-mainnet-fork')); + }); + + it('keeps the new flag working without a deprecation warning', async () => { + const { runCli, logger } = loadCli(); + await runCli(['node', 'offckb', 'transfer', '0xrecipient', '100', '--allow-external-key-on-mainnet-fork']); + + expect(mockTransfer).toHaveBeenCalledWith( + '0xrecipient', + '100', + expect.objectContaining({ allowExternalKeyOnMainnetFork: true }), + ); + expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('deprecated')); + }); + + it('accepts the deprecated alias on every guarded command', async () => { + const { runCli } = loadCli(); + + await runCli(['node', 'offckb', 'deploy', '--allow-mainnet-replay-risk']); + expect(mockDeploy).toHaveBeenCalledWith(expect.objectContaining({ allowExternalKeyOnMainnetFork: true })); + + await runCli(['node', 'offckb', 'transfer-all', '0xrecipient', '--allow-mainnet-replay-risk']); + expect(mockTransferAll).toHaveBeenCalledWith( + '0xrecipient', + expect.objectContaining({ allowExternalKeyOnMainnetFork: true }), + ); + + await runCli(['node', 'offckb', 'udt', 'issue', '100', '--allow-mainnet-replay-risk']); + expect(mockUdtIssue).toHaveBeenCalledWith('100', expect.objectContaining({ allowExternalKeyOnMainnetFork: true })); + + await runCli([ + 'node', + 'offckb', + 'udt', + 'destroy', + '100', + '--type-args', + '0x' + '00'.repeat(32), + '--allow-mainnet-replay-risk', + ]); + expect(mockUdtDestroy).toHaveBeenCalledWith( + '100', + expect.objectContaining({ allowExternalKeyOnMainnetFork: true }), + ); + }); +}); diff --git a/tests/devnet-fork.test.ts b/tests/devnet-fork.test.ts index a865622d..a2c3f87b 100644 --- a/tests/devnet-fork.test.ts +++ b/tests/devnet-fork.test.ts @@ -208,6 +208,20 @@ describe('fork data isolation and migration preflight', () => { expect(fs.existsSync(path.join(target, 'data', 'db', 'linked'))).toBe(false); }); + it('rejects a symlinked data directory at the source root', () => { + if (process.platform === 'win32') return; + const source = path.join(root, 'source'); + const target = path.join(root, 'target'); + const outside = path.join(root, 'outside'); + fs.mkdirSync(source, { recursive: true }); + fs.mkdirSync(outside, { recursive: true }); + fs.writeFileSync(path.join(outside, 'fixture'), 'secret'); + fs.symlinkSync(outside, path.join(source, 'data'), 'dir'); + + expect(() => copySourceData(source, target)).toThrow('symlinked entries are not allowed'); + expect(fs.existsSync(path.join(target, 'data', 'fixture'))).toBe(false); + }); + it('forces forked nodes into an outbound-isolated network config', () => { const config = isolateForkCkbConfig({ network: { bootnodes: ['mainnet-peer'], max_outbound_peers: 8, discovery_local_address: true }, diff --git a/tests/fork-safety.test.ts b/tests/fork-safety.test.ts index 664a5e58..88861191 100644 --- a/tests/fork-safety.test.ts +++ b/tests/fork-safety.test.ts @@ -7,7 +7,11 @@ jest.mock('../src/devnet/fork', () => ({ readForkState: () => mockFork })); jest.mock('../src/util/logger', () => ({ logger: { warn: jest.fn() } })); import accountConfig from '../account/account.json'; -import { validateMainnetForkSigning, warnIfMainnetForkSigning } from '../src/util/fork-safety'; +import { + resolveMainnetForkOverride, + validateMainnetForkSigning, + warnIfMainnetForkSigning, +} from '../src/util/fork-safety'; import { logger } from '../src/util/logger'; import { Network } from '../src/type/base'; @@ -64,3 +68,19 @@ describe('Mainnet fork signing warning', () => { ); }); }); + +describe('deprecated --allow-mainnet-replay-risk alias', () => { + beforeEach(() => jest.clearAllMocks()); + + it('folds the deprecated flag into the new option with a warning', () => { + const options = resolveMainnetForkOverride({ allowMainnetReplayRisk: true }); + expect(options.allowExternalKeyOnMainnetFork).toBe(true); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('--allow-external-key-on-mainnet-fork')); + }); + + it('leaves options untouched when the deprecated flag is absent', () => { + const options = resolveMainnetForkOverride({ allowExternalKeyOnMainnetFork: true }); + expect(options.allowExternalKeyOnMainnetFork).toBe(true); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/node-listener.test.ts b/tests/node-listener.test.ts new file mode 100644 index 00000000..a20d1031 --- /dev/null +++ b/tests/node-listener.test.ts @@ -0,0 +1,73 @@ +const mockExecFileSync = jest.fn(); + +jest.mock('child_process', () => ({ + ...jest.requireActual('child_process'), + execFileSync: (...args: unknown[]) => mockExecFileSync(...args), +})); +jest.mock('../src/node/install', () => ({ installCKBBinary: jest.fn() })); +jest.mock('../src/node/init-chain', () => ({ initChainIfNeeded: jest.fn() })); +jest.mock('../src/cfg/setting', () => ({ + readSettings: () => ({ + bins: { defaultCKBVersion: '0.207.0' }, + devnet: { + configPath: '/tmp/offckb-devnet', + dataPath: '/tmp/offckb-devnet/data', + rpcUrl: 'http://127.0.0.1:8114', + rpcProxyPort: 28114, + }, + }), + getCKBBinaryPath: () => '/tmp/ckb', +})); +jest.mock('../src/devnet/fork', () => ({ readForkState: jest.fn(), markForkFirstRunComplete: jest.fn() })); +jest.mock('../src/util/json-rpc', () => ({ callJsonRpc: jest.fn() })); +jest.mock('../src/devnet/readiness', () => ({ checkNodeReadiness: jest.fn(), waitForNodeReady: jest.fn() })); +jest.mock('../src/tools/rpc-proxy', () => ({ createRPCProxy: jest.fn() })); +jest.mock('../src/util/logger', () => ({ + logger: { + success: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + result: jest.fn(), + }, +})); + +import { isProcessListeningOnPort } from '../src/cmd/node'; + +function lsofError(stderr: string, code?: string): Error & { stderr: Buffer; code?: string } { + const error = new Error('lsof failed') as Error & { stderr: Buffer; code?: string }; + error.stderr = Buffer.from(stderr); + error.code = code; + return error; +} + +describe('isProcessListeningOnPort', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns true when lsof finds the process listening', () => { + mockExecFileSync.mockReturnValue(Buffer.from('p1234')); + expect(isProcessListeningOnPort(1234, 8114)).toBe(true); + }); + + it('returns false when lsof reports no match (empty stderr)', () => { + mockExecFileSync.mockImplementation(() => { + throw lsofError(''); + }); + expect(isProcessListeningOnPort(1234, 8114)).toBe(false); + }); + + it('returns null when the lsof inspection itself failed (stderr output)', () => { + mockExecFileSync.mockImplementation(() => { + throw lsofError('lsof: permission denied\n'); + }); + expect(isProcessListeningOnPort(1234, 8114)).toBeNull(); + }); + + it('returns null when lsof is not installed', () => { + mockExecFileSync.mockImplementation(() => { + throw lsofError('spawn lsof ENOENT', 'ENOENT'); + }); + expect(isProcessListeningOnPort(1234, 8114)).toBeNull(); + }); +}); From cc3928b9423dada9fe8503cd8c984dd5a8b90f81 Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Thu, 23 Jul 2026 08:18:30 +0000 Subject: [PATCH 3/4] fix: bound the lsof port probe with a timeout A hung lsof would block execFileSync (and with it daemon startup) indefinitely, and its empty-stderr timeout error would be misread as a genuine no-match. Cap the probe at 5s and classify ETIMEDOUT as indeterminate (null) so the genesis fallback proceeds. Co-Authored-By: Claude Fable 5 --- src/cmd/node.ts | 12 ++++++++---- tests/node-listener.test.ts | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/cmd/node.ts b/src/cmd/node.ts index ee502bfc..146ba7dc 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -191,20 +191,24 @@ function resolveDaemonPaths() { } // Best-effort check that the spawned process is the one listening on the RPC -// port. Returns null when the check cannot be performed (Windows, no lsof, or -// an lsof inspection error) so callers can fall back to weaker signals. +// port. Returns null when the check cannot be performed (Windows, no lsof, an +// lsof inspection error, or a hung lsof that hits the timeout) so callers can +// fall back to weaker signals. // lsof exits 1 both for "no match" and for permission/inspection errors; only -// an empty stderr is a genuine no-match, anything else is indeterminate. +// an empty stderr is a genuine no-match, anything else is indeterminate. A +// timed-out probe is killed with an empty stderr too, so ETIMEDOUT must be +// ruled out first to avoid misreading it as a genuine no-match. export function isProcessListeningOnPort(pid: number, port: number): boolean | null { if (process.platform === 'win32') return null; try { execFileSync('lsof', ['-a', '-p', String(pid), '-iTCP:' + port, '-sTCP:LISTEN'], { stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, }); return true; } catch (error) { const err = error as NodeJS.ErrnoException & { stderr?: Buffer | string }; - if (err.code === 'ENOENT') return null; + if (err.code === 'ENOENT' || err.code === 'ETIMEDOUT') return null; const stderr = err.stderr?.toString().trim() ?? ''; return stderr === '' ? false : null; } diff --git a/tests/node-listener.test.ts b/tests/node-listener.test.ts index a20d1031..d18fb169 100644 --- a/tests/node-listener.test.ts +++ b/tests/node-listener.test.ts @@ -70,4 +70,21 @@ describe('isProcessListeningOnPort', () => { }); expect(isProcessListeningOnPort(1234, 8114)).toBeNull(); }); + + it('returns null when lsof hangs and hits the probe timeout', () => { + mockExecFileSync.mockImplementation(() => { + throw lsofError('', 'ETIMEDOUT'); + }); + expect(isProcessListeningOnPort(1234, 8114)).toBeNull(); + }); + + it('bounds the lsof probe with a timeout', () => { + mockExecFileSync.mockReturnValue(Buffer.from('p1234')); + isProcessListeningOnPort(1234, 8114); + expect(mockExecFileSync).toHaveBeenCalledWith( + 'lsof', + ['-a', '-p', '1234', '-iTCP:8114', '-sTCP:LISTEN'], + expect.objectContaining({ timeout: expect.any(Number) }), + ); + }); }); From 20389968551f238f2af6e54a51f93c1220514b33 Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Thu, 23 Jul 2026 12:54:44 +0000 Subject: [PATCH 4/4] test: make lsof probe tests platform-independent isProcessListeningOnPort short-circuits to null on win32, so the lsof outcome-mapping tests failed on the Windows CI runner (mock never called). Force a unix platform for the lsof-probing cases, cover the win32 short-circuit explicitly, and pin the probe timeout to exactly 5000 ms per review feedback. --- tests/node-listener.test.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/node-listener.test.ts b/tests/node-listener.test.ts index d18fb169..1173e26b 100644 --- a/tests/node-listener.test.ts +++ b/tests/node-listener.test.ts @@ -43,11 +43,33 @@ function lsofError(stderr: string, code?: string): Error & { stderr: Buffer; cod } describe('isProcessListeningOnPort', () => { + const realPlatform = process.platform; + + // The implementation short-circuits to null on win32 without invoking lsof; + // force a unix platform so the lsof-probing behavior is exercised on every + // CI OS, including the Windows runners. + beforeAll(() => Object.defineProperty(process, 'platform', { value: 'linux' })); + afterAll(() => Object.defineProperty(process, 'platform', { value: realPlatform })); beforeEach(() => jest.clearAllMocks()); it('returns true when lsof finds the process listening', () => { mockExecFileSync.mockReturnValue(Buffer.from('p1234')); expect(isProcessListeningOnPort(1234, 8114)).toBe(true); + expect(mockExecFileSync).toHaveBeenCalledWith( + 'lsof', + ['-a', '-p', '1234', '-iTCP:8114', '-sTCP:LISTEN'], + expect.objectContaining({ timeout: 5000 }), + ); + }); + + it('returns null on Windows without probing lsof', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + expect(isProcessListeningOnPort(1234, 8114)).toBeNull(); + expect(mockExecFileSync).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(process, 'platform', { value: 'linux' }); + } }); it('returns false when lsof reports no match (empty stderr)', () => { @@ -84,7 +106,7 @@ describe('isProcessListeningOnPort', () => { expect(mockExecFileSync).toHaveBeenCalledWith( 'lsof', ['-a', '-p', '1234', '-iTCP:8114', '-sTCP:LISTEN'], - expect.objectContaining({ timeout: expect.any(Number) }), + expect.objectContaining({ timeout: 5000 }), ); }); });