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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/tidy-mugs-repair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@offckb/cli': patch
---

Rename `--allow-mainnet-replay-risk` to `--allow-external-key-on-mainnet-fork` (#460) — the old flag remains as a hidden deprecated alias so existing scripts keep working — and apply the fixes left over from the 0.4.9 review (#462):

- Enforce the Mainnet-fork replay guard (instead of warn-only) in `transfer-all`, `udt issue`, `udt destroy`, and `deploy`, and reject inputs created at or before the fork boundary in those transactions, mirroring `transfer`/`deposit`.
- Validate `--tx-hash` as a 0x-prefixed 32-byte hex string before it is used in debug cache paths.
- Read the fork boundary only from the spawned CKB process once it is the RPC listener, so a stale node sharing the port cannot clear the first-run flags.
- Refuse symlinked entries when copying source chain data for a fork.
- Accept xUDT type args longer than 32 bytes (owner lock hash plus flags/extension) while keeping SUDT at exactly 32 bytes.
- Give SUDT and xUDT balance scans independent `maxCells` budgets, return deep clones of the default settings from `readSettings` fallbacks, keep `config set` error messages accurate, preserve the original error in `devnet config`, use `execFile` for process lookups, align the ckb-tui download timeouts, handle cross-device installs, and add the missing Fork Mainnet/Testnet entry to the README table of contents.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -414,7 +415,7 @@ On a forked devnet, `offckb system-scripts`, transfers, deploys and `offckb debu
> [!CAUTION]
> CKB transactions carry no chain id, so a transaction built on a mainnet fork that spends copied mainnet cells is also valid on mainnet (CKB provides no replay protection). offckb's own flows only use dev keys and fork-mined cells, which cannot replay. Never sign transactions with real mainnet keys against a fork unless you intend to broadcast them yourself.

`offckb transfer` fails closed on a Mainnet fork: non-built-in keys require `--allow-mainnet-replay-risk`, and inputs copied from Mainnet are rejected even with that override.
`offckb transfer` fails closed on a Mainnet fork: non-built-in keys require `--allow-external-key-on-mainnet-fork`, and inputs copied from Mainnet are rejected even with that override. (`--allow-mainnet-replay-risk` from 0.4.9 remains as a deprecated alias.)

## Config Setting

Expand Down
6 changes: 4 additions & 2 deletions src/cfg/setting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
133 changes: 76 additions & 57 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -83,17 +98,18 @@ program
return await createScriptProject(projectName, options);
});

program
.command('deploy')
.description('Deploy contracts to different networks, only supports devnet and testnet')
.option('--network <network>', 'Specify the network to deploy to', 'devnet')
.option('--target <target>', 'Specify the script binaries file/folder path to deploy', './')
.option('-o, --output <output>', 'Specify the output folder path for the deployment record files', './deployment')
.option('-t, --type-id', 'Specify if use upgradable type id to deploy the script')
.option('--privkey <privkey>', 'Specify the private key to deploy scripts (visible in shell history)')
.option('--privkey-file <path>', 'Read the private key from a local file')
.option('-y, --yes', 'Skip confirmation prompt and deploy immediately')
.action((options: DeployOptions) => deploy(options));
mainnetForkOverrideOption(
program
.command('deploy')
.description('Deploy contracts to different networks, only supports devnet and testnet')
.option('--network <network>', 'Specify the network to deploy to', 'devnet')
.option('--target <target>', 'Specify the script binaries file/folder path to deploy', './')
.option('-o, --output <output>', 'Specify the output folder path for the deployment record files', './deployment')
.option('-t, --type-id', 'Specify if use upgradable type id to deploy the script')
.option('--privkey <privkey>', 'Specify the private key to deploy scripts (visible in shell history)')
.option('--privkey-file <path>', 'Read the private key from a local file')
.option('-y, --yes', 'Skip confirmation prompt and deploy immediately'),
).action((options: DeployOptions) => deploy(resolveMainnetForkOverride(options)));

program
.command('debug')
Expand Down Expand Up @@ -158,30 +174,31 @@ program
await deposit(toAddress, amountInCKB, options);
});

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

program
.command('balance [toAddress]')
Expand All @@ -196,30 +213,32 @@ program

const udtCommand = program.command('udt').description('UDT token commands');

udtCommand
.command('issue <amount>')
.description('Issue new UDT tokens, only devnet and testnet')
.option('--network <network>', 'Specify the network', 'devnet')
.addOption(new Option('--udt-kind <kind>', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt'))
.option('--type-args <typeArgs>', 'Specify the UDT type script args (xudt only; defaults to signer lock hash)')
.option('--to <toAddress>', 'Specify the receiver address (defaults to signer)')
.option('--privkey <privkey>', 'Specify the private key to issue UDT (visible in shell history)')
.option('--privkey-file <path>', 'Read the private key from a local file')
.action(async (amount: string, options: UdtIssueOption) => {
await udtIssue(amount, options);
});
mainnetForkOverrideOption(
udtCommand
.command('issue <amount>')
.description('Issue new UDT tokens, only devnet and testnet')
.option('--network <network>', 'Specify the network', 'devnet')
.addOption(new Option('--udt-kind <kind>', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt'))
.option('--type-args <typeArgs>', 'Specify the UDT type script args (xudt only; defaults to signer lock hash)')
.option('--to <toAddress>', 'Specify the receiver address (defaults to signer)')
.option('--privkey <privkey>', 'Specify the private key to issue UDT (visible in shell history)')
.option('--privkey-file <path>', 'Read the private key from a local file'),
).action(async (amount: string, options: UdtIssueOption) => {
await udtIssue(amount, resolveMainnetForkOverride(options));
});

udtCommand
.command('destroy <amount>')
.description('Destroy UDT tokens, only devnet and testnet')
.option('--network <network>', 'Specify the network', 'devnet')
.addOption(new Option('--udt-kind <kind>', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt'))
.requiredOption('--type-args <typeArgs>', 'Specify the UDT type script args')
.option('--privkey <privkey>', 'Specify the private key to destroy UDT (visible in shell history)')
.option('--privkey-file <path>', 'Read the private key from a local file')
.action(async (amount: string, options: UdtDestroyOption) => {
await udtDestroy(amount, options);
});
mainnetForkOverrideOption(
udtCommand
.command('destroy <amount>')
.description('Destroy UDT tokens, only devnet and testnet')
.option('--network <network>', 'Specify the network', 'devnet')
.addOption(new Option('--udt-kind <kind>', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt'))
.requiredOption('--type-args <typeArgs>', 'Specify the UDT type script args')
.option('--privkey <privkey>', 'Specify the private key to destroy UDT (visible in shell history)')
.option('--privkey-file <path>', 'Read the private key from a local file'),
).action(async (amount: string, options: UdtDestroyOption) => {
await udtDestroy(amount, resolveMainnetForkOverride(options));
});

program
.command('debugger')
Expand Down
32 changes: 15 additions & 17 deletions src/cmd/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions src/cmd/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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)) {
Expand Down
18 changes: 15 additions & 3 deletions src/cmd/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,6 +18,7 @@ export interface DeployOptions extends NetworkOption {
privkeyFile?: string | null;
typeId?: boolean;
yes?: boolean;
allowExternalKeyOnMainnetFork?: boolean;
}

export async function deploy(
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions src/cmd/devnet-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Loading
Loading