diff --git a/CHANGELOG.md b/CHANGELOG.md index 24d5dcc7..668640a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # @offckb/cli +## 0.4.10 + +### Patch Changes + +- 406415e: Change the default devnet log filter from `warn,ckb-script=debug` to `info,ckb-script=debug` in the `ckb.toml` / `ckb-miner.toml` templates (and the config editor's embedded reference templates). A healthy devnet produces almost no `warn`-level output, which left the `offckb status` Logs panel permanently empty and looked broken; `info` keeps the per-block log stream visible while `ckb-script=debug` still surfaces script execution details. Applies to newly initialized chains — edit `[logger] filter` in your existing devnet `ckb.toml` to opt in. + +- 699a850: Fix the `status` command showing missing data on devnet: enable the Terminal RPC module and the TCP listen address in the devnet `ckb.toml` template (and the config editor's embedded reference template), and pass the node's TCP listen address to ckb-tui so the system metrics, mempool, and log panels populate correctly. The devnet RPC now binds to `127.0.0.1` instead of `0.0.0.0` so the unauthenticated RPC (including the new host metrics) is no longer reachable from other machines on the network; edit `rpc.listen_address` in the devnet `ckb.toml` if you rely on remote access. +- 45b0e98: 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. + +- 00bfe6c: Fix the devnet config editor and `status` for existing installs. The config editor's "Edit RPC Modules" dialog now offers the `Terminal` (and `RichIndexer`) modules — its option list was still the pre-ckb-tui hardcoded set, so there was no way to enable `Terminal` from the TUI even though the bundled `ckb.toml` enables it. In addition, starting the node now upgrades a legacy devnet `ckb.toml` in place: configs initialized before the ckb-tui fix never received the `Terminal` RPC module or the enabled `tcp_listen_address` (the template is only copied into fresh config folders, and clearing chain `data/` does not touch config files), which left `offckb status` dashboards empty. The migration adds `"Terminal"` to `rpc.modules` and enables `tcp_listen_address = "127.0.0.1:18114"` while preserving existing comments and custom settings; restart the node to apply. + ## 0.4.9 ### Patch Changes diff --git a/README.md b/README.md index b2e285f1..78a2f7f0 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) @@ -330,7 +331,7 @@ offckb system-scripts --output ### 6. Tweak Devnet Config {#tweak-devnet-config} -By default, OffCKB use a fixed Devnet config. You can customize it, for example by modifying the default log level (`warn,ckb-script=debug`). +By default, OffCKB use a fixed Devnet config. You can customize it, for example by modifying the default log level (`info,ckb-script=debug`). 1. Open the interactive Devnet config editor: @@ -414,7 +415,7 @@ On a forked devnet, `offckb system-scripts`, transfers, deploys and `offckb debu > [!CAUTION] > CKB transactions carry no chain id, so a transaction built on a mainnet fork that spends copied mainnet cells is also valid on mainnet (CKB provides no replay protection). offckb's own flows only use dev keys and fork-mined cells, which cannot replay. Never sign transactions with real mainnet keys against a fork unless you intend to broadcast them yourself. -`offckb transfer` fails closed on a Mainnet fork: non-built-in keys require `--allow-mainnet-replay-risk`, and inputs copied from Mainnet are rejected even with that override. +`offckb transfer` fails closed on a Mainnet fork: non-built-in keys require `--allow-external-key-on-mainnet-fork`, and inputs copied from Mainnet are rejected even with that override. (`--allow-mainnet-replay-risk` from 0.4.9 remains as a deprecated alias.) ## Config Setting diff --git a/ckb/devnet/ckb-miner.toml b/ckb/devnet/ckb-miner.toml index bbc69c71..3482c965 100644 --- a/ckb/devnet/ckb-miner.toml +++ b/ckb/devnet/ckb-miner.toml @@ -10,7 +10,7 @@ data_dir = "data" spec = { file = "specs/dev.toml" } [logger] -filter = "warn,ckb-script=debug" +filter = "info,ckb-script=debug" color = true log_to_file = true log_to_stdout = true diff --git a/ckb/devnet/ckb.toml b/ckb/devnet/ckb.toml index 5cfd4a45..0d70baaa 100644 --- a/ckb/devnet/ckb.toml +++ b/ckb/devnet/ckb.toml @@ -10,7 +10,7 @@ data_dir = "data" spec = { file = "specs/dev.toml" } [logger] -filter = "warn,ckb-script=debug" +filter = "info,ckb-script=debug" color = true log_to_file = true log_to_stdout = true @@ -80,16 +80,19 @@ support_protocols = ["Ping", "Discovery", "Identify", "Feeler", "DisconnectMessa # # Allowing arbitrary machines to access the JSON-RPC port is dangerous and strongly discouraged. # Please strictly limit the access to only trusted machines. -listen_address = "0.0.0.0:8114" +listen_address = "127.0.0.1:8114" # Default is 10MiB = 10 * 1024 * 1024 max_request_body_size = 10485760 -# List of API modules: ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer"] -modules = ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer"] +# List of API modules: ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer", "RichIndexer", "Terminal"] +# "Terminal" powers ckb-tui's `get_overview` system metrics; without it those panels show N/A. +modules = ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer", "Terminal"] # By default RPC only binds to HTTP service, you can bind it to TCP and WebSocket. -# tcp_listen_address = "127.0.0.1:18114" +# The TCP service streams subscription topics (new_transaction, rejected_transaction, log, ...) +# that ckb-tui's mempool and logs dashboards rely on. +tcp_listen_address = "127.0.0.1:18114" # ws_listen_address = "127.0.0.1:28114" reject_ill_transactions = true diff --git a/package.json b/package.json index 92f4982e..420628d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@offckb/cli", - "version": "0.4.9", + "version": "0.4.10", "description": "ckb development network for your first try", "author": "CKB EcoFund", "license": "MIT", @@ -86,7 +86,7 @@ "https-proxy-agent": "^7.0.5", "node-fetch": "2", "semver": "^7.6.0", - "tar": "^7.5.3", + "tar": "^7.5.19", "winston": "^3.17.0" }, "optionalDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9baf027..ac67fd19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,14 +6,17 @@ settings: overrides: form-data@>=4.0.0 <4.0.6: 4.0.6 - hono@<4.12.25: 4.12.25 + hono@<4.12.27: 4.12.27 qs@>=6.11.1 <=6.15.1: 6.15.2 ip-address@<=10.1.0: 10.1.1 js-yaml@<3.15.0: 3.15.0 - js-yaml@>=4.0.0 <=4.1.1: 4.2.0 + js-yaml@>=4.0.0 <4.3.0: 4.3.0 '@babel/core@<=7.29.0': 7.29.7 '@eslint/plugin-kit@<0.3.4': 0.3.4 - brace-expansion@>=5.0.0 <5.0.6: 5.0.6 + brace-expansion@<1.1.16: 1.1.16 + brace-expansion@>=5.0.0 <5.0.7: 5.0.7 + fast-uri@<3.1.4: 3.1.4 + body-parser@>=2.0.0 <2.3.0: 2.3.0 importers: @@ -59,8 +62,8 @@ importers: specifier: ^7.6.0 version: 7.7.3 tar: - specifier: ^7.5.3 - version: 7.5.16 + specifier: ^7.5.19 + version: 7.5.21 winston: specifier: ^3.17.0 version: 3.17.0 @@ -450,7 +453,7 @@ packages: resolution: {integrity: sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==} engines: {node: '>=18.14.1'} peerDependencies: - hono: 4.12.25 + hono: 4.12.27 '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -1232,15 +1235,15 @@ packages: bn.js@4.12.3: resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} - brace-expansion@1.1.13: - resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -1413,6 +1416,10 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1682,8 +1689,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -1866,8 +1873,8 @@ packages: hmac-drbg@1.0.1: resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} - hono@4.12.25: - resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} + hono@4.12.27: + resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} engines: {node: '>=16.9.0'} html-escaper@2.0.2: @@ -2175,8 +2182,8 @@ packages: resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} hasBin: true - js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true jsbi@3.1.3: @@ -2856,8 +2863,8 @@ packages: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} - tar@7.5.16: - resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==} + tar@7.5.21: + resolution: {integrity: sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==} engines: {node: '>=18'} term-size@2.2.1: @@ -2984,6 +2991,10 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript@5.8.2: resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} engines: {node: '>=14.17'} @@ -3477,7 +3488,7 @@ snapshots: '@changesets/parse@0.4.2': dependencies: '@changesets/types': 6.1.0 - js-yaml: 4.2.0 + js-yaml: 4.3.0 '@changesets/pre@2.0.2': dependencies: @@ -3592,7 +3603,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.2.0 + js-yaml: 4.3.0 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3607,9 +3618,9 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 - '@hono/node-server@1.19.13(hono@4.12.25)': + '@hono/node-server@1.19.13(hono@4.12.27)': dependencies: - hono: 4.12.25 + hono: 4.12.27 '@humanfs/core@0.19.1': {} @@ -4012,7 +4023,7 @@ snapshots: '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.13(hono@4.12.25) + '@hono/node-server': 1.19.13(hono@4.12.27) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -4022,7 +4033,7 @@ snapshots: eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 8.3.1(express@5.2.1) - hono: 4.12.25 + hono: 4.12.27 jose: 6.1.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -4111,24 +4122,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.28.6 - '@babel/types': 7.28.6 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.28.6 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.28.6 - '@babel/types': 7.28.6 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.28.6 + '@babel/types': 7.29.7 '@types/blessed@0.1.27': dependencies: @@ -4378,7 +4389,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 + fast-uri: 3.1.4 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -4497,26 +4508,26 @@ snapshots: bn.js@4.12.3: {} - body-parser@2.2.2: + body-parser@2.3.0: dependencies: bytes: 3.1.2 - content-type: 1.0.5 + content-type: 2.0.0 debug: 4.4.3 http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 qs: 6.15.2 raw-body: 3.0.2 - type-is: 2.0.1 + type-is: 2.1.0 transitivePeerDependencies: - supports-color - brace-expansion@1.1.13: + brace-expansion@1.1.16: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.6: + brace-expansion@5.0.7: dependencies: balanced-match: 4.0.4 @@ -4678,6 +4689,8 @@ snapshots: content-type@1.0.5: {} + content-type@2.0.0: {} + convert-source-map@2.0.0: {} cookie-signature@1.2.2: {} @@ -4945,7 +4958,7 @@ snapshots: express@5.2.1: dependencies: accepts: 2.0.0 - body-parser: 2.2.2 + body-parser: 2.3.0 content-disposition: 1.0.1 content-type: 1.0.5 cookie: 0.7.2 @@ -4991,7 +5004,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.2: {} + fast-uri@3.1.4: {} fastq@1.19.1: dependencies: @@ -5188,7 +5201,7 @@ snapshots: minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 - hono@4.12.25: {} + hono@4.12.27: {} html-escaper@2.0.2: {} @@ -5311,7 +5324,7 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.28.6 + '@babel/parser': 7.29.7 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 7.7.3 @@ -5664,7 +5677,7 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.2.0: + js-yaml@4.3.0: dependencies: argparse: 2.0.1 @@ -5817,11 +5830,11 @@ snapshots: minimatch@3.1.5: dependencies: - brace-expansion: 1.1.13 + brace-expansion: 1.1.16 minimatch@9.0.8: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.7 minimist@1.2.8: {} @@ -6274,7 +6287,7 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 - tar@7.5.16: + tar@7.5.21: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -6395,6 +6408,12 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript@5.8.2: {} uglify-js@3.19.3: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d7411c7a..ef8aa1f0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,21 +2,28 @@ overrides: # GHSA-7m2j-8qp9-m8jw: CRLF injection. Remove when no transitive dependency uses form-data >=4.0.0 <4.0.6. "form-data@>=4.0.0 <4.0.6": "4.0.6" # GHSA-88fw-hqm2-52qc: CORS middleware reflects any origin with credentials. Remove when hono >=4.12.25. - "hono@<4.12.25": "4.12.25" + # GHSA-xgm2-5f3f-mvvc / GHSA-hvrm-45r6-mjfj / GHSA-w62v-xxxg-mg59: repeated-header drop, JSX context leakage, JSX escaping bypass. Remove when hono >=4.12.27. + "hono@<4.12.27": "4.12.27" # GHSA-q8mj-m7cp-5q26: qs.stringify DoS. Remove when direct dependency upgrades qs >=6.15.2. "qs@>=6.11.1 <=6.15.1": "6.15.2" # GHSA-v2v4-37r5-5v8g: XSS in Address6 HTML-emitting methods. Remove when direct dependency upgrades ip-address >=10.1.1. "ip-address@<=10.1.0": "10.1.1" # GHSA-h67p-54hq-rp68: quadratic-complexity DoS in merge key handling. Remove when js-yaml 3.x >=3.15.0. "js-yaml@<3.15.0": "3.15.0" - # GHSA-h67p-54hq-rp68: quadratic-complexity DoS in merge key handling. Remove when js-yaml 4.x >=4.2.0. - "js-yaml@>=4.0.0 <=4.1.1": "4.2.0" + # GHSA-h67p-54hq-rp68 / GHSA-52cp-r559-cp3m: quadratic-complexity DoS in merge key handling. Remove when js-yaml 4.x >=4.3.0. + "js-yaml@>=4.0.0 <4.3.0": "4.3.0" # GHSA-4x5r-pxfx-6jf8: arbitrary file read via sourceMappingURL. Remove when @babel/core >=7.29.6. "@babel/core@<=7.29.0": "7.29.7" # GHSA-xffm-g5w8-qvg7: ReDoS in ConfigCommentParser. Remove when @eslint/plugin-kit >=0.3.4. "@eslint/plugin-kit@<0.3.4": "0.3.4" - # GHSA-jxxr-4gwj-5jf2: unbounded brace range expansion DoS. Remove when brace-expansion >=5.0.6. - "brace-expansion@>=5.0.0 <5.0.6": "5.0.6" + # GHSA-3jxr-9vmj-r5cp: exponential-time expansion of consecutive non-expanding {} groups. Remove when brace-expansion 1.x >=1.1.16. + "brace-expansion@<1.1.16": "1.1.16" + # GHSA-jxxr-4gwj-5jf2 / GHSA-3jxr-9vmj-r5cp: brace range / exponential-time expansion DoS. Remove when brace-expansion >=5.0.7. + "brace-expansion@>=5.0.0 <5.0.7": "5.0.7" + # GHSA-v2hh-gcrm-f6hx / GHSA-4c8g-83qw-93j6: host confusion via backslash authority / failed IDN canonicalization. Remove when fast-uri >=3.1.4. + "fast-uri@<3.1.4": "3.1.4" + # GHSA-v422-hmwv-36x6: invalid limit value silently disables size enforcement. Remove when body-parser 2.x >=2.3.0. + "body-parser@>=2.0.0 <2.3.0": "2.3.0" onlyBuiltDependencies: - secp256k1 @@ -26,7 +33,12 @@ minimumReleaseAgeExclude: - "@eslint/plugin-kit@0.3.4" - "ip-address@10.1.1" - "qs@6.15.2" - - "brace-expansion@5.0.6" + - "brace-expansion@1.1.16" + - "brace-expansion@5.0.7" - "@babel/core@7.29.7" - "js-yaml@3.15.0" - - "js-yaml@4.2.0" + - "js-yaml@4.3.0" + - "tar@7.5.21" + - "hono@4.12.27" + - "fast-uri@3.1.4" + - "body-parser@2.3.0" diff --git a/src/cfg/setting.ts b/src/cfg/setting.ts index 113f17ab..52aaa512 100644 --- a/src/cfg/setting.ts +++ b/src/cfg/setting.ts @@ -111,11 +111,13 @@ export function readSettings(): Settings { // Deep-clone defaults before merging to prevent mutation of the shared default return deepMerge(deepClone(defaultSettings), parsed) as Settings; } else { - return defaultSettings; + // Callers mutate the returned settings in place; never hand out the + // shared module-level defaults. + return deepClone(defaultSettings); } } catch (error) { logger.error('Error reading settings:', error); - return defaultSettings; + return deepClone(defaultSettings); } } diff --git a/src/cli.ts b/src/cli.ts index a174a65b..82908323 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,6 +19,7 @@ import { printSystemScripts } from './cmd/system-scripts'; import { transferAll } from './cmd/transfer-all'; import { genSystemScriptsJsonFile } from './scripts/gen'; import { CKBDebugger } from './tools/ckb-debugger'; +import { resolveMainnetForkOverride } from './util/fork-safety'; import { logger } from './util/logger'; import { Network } from './type/base'; import { status } from './cmd/status'; @@ -43,6 +44,20 @@ function commandPath(command: Command): string { return names.join('.') || 'offckb'; } +// Registers the Mainnet-fork override flag plus the 0.4.9 name as a hidden +// deprecated alias; resolveMainnetForkOverride folds the alias into the new +// option before the command handler runs. +function mainnetForkOverrideOption(command: Command): Command { + return command + .option( + '--allow-external-key-on-mainnet-fork', + 'Allow a non-built-in key on a Mainnet fork (copied inputs remain blocked)', + ) + .addOption( + new Option('--allow-mainnet-replay-risk', 'Deprecated alias of --allow-external-key-on-mainnet-fork').hideHelp(), + ); +} + program.option('--json', 'Output logs in JSON format for agent/programmatic consumption'); program.hook('preAction', (_thisCommand, actionCommand) => { activeCommand = commandPath(actionCommand); @@ -83,17 +98,18 @@ program return await createScriptProject(projectName, options); }); -program - .command('deploy') - .description('Deploy contracts to different networks, only supports devnet and testnet') - .option('--network ', 'Specify the network to deploy to', 'devnet') - .option('--target ', 'Specify the script binaries file/folder path to deploy', './') - .option('-o, --output ', 'Specify the output folder path for the deployment record files', './deployment') - .option('-t, --type-id', 'Specify if use upgradable type id to deploy the script') - .option('--privkey ', 'Specify the private key to deploy scripts (visible in shell history)') - .option('--privkey-file ', 'Read the private key from a local file') - .option('-y, --yes', 'Skip confirmation prompt and deploy immediately') - .action((options: DeployOptions) => deploy(options)); +mainnetForkOverrideOption( + program + .command('deploy') + .description('Deploy contracts to different networks, only supports devnet and testnet') + .option('--network ', 'Specify the network to deploy to', 'devnet') + .option('--target ', 'Specify the script binaries file/folder path to deploy', './') + .option('-o, --output ', 'Specify the output folder path for the deployment record files', './deployment') + .option('-t, --type-id', 'Specify if use upgradable type id to deploy the script') + .option('--privkey ', 'Specify the private key to deploy scripts (visible in shell history)') + .option('--privkey-file ', 'Read the private key from a local file') + .option('-y, --yes', 'Skip confirmation prompt and deploy immediately'), +).action((options: DeployOptions) => deploy(resolveMainnetForkOverride(options))); program .command('debug') @@ -158,30 +174,31 @@ program await deposit(toAddress, amountInCKB, options); }); -program - .command('transfer [toAddress] [amount]') - .description('Transfer CKB or UDT tokens to address, only devnet and testnet') - .option('--network ', 'Specify the network to transfer to', 'devnet') - .option('--privkey ', 'Specify the private key to transfer (visible in shell history)') - .option('--privkey-file ', 'Read the private key from a local file') - .addOption(new Option('--udt-kind ', 'Specify the UDT kind').choices(['sudt', 'xudt'])) - .option('--udt-type-args ', 'Specify the UDT type script args to transfer UDT') - .option('--allow-mainnet-replay-risk', 'Allow a non-built-in key on a Mainnet fork (copied inputs remain blocked)') - .option('-r, --proxy-rpc', 'Use Proxy RPC to connect to blockchain') - .action(async (toAddress: string, amount: string, options: TransferOptions) => { - await transfer(toAddress, amount, options); - }); +mainnetForkOverrideOption( + program + .command('transfer [toAddress] [amount]') + .description('Transfer CKB or UDT tokens to address, only devnet and testnet') + .option('--network ', 'Specify the network to transfer to', 'devnet') + .option('--privkey ', 'Specify the private key to transfer (visible in shell history)') + .option('--privkey-file ', 'Read the private key from a local file') + .addOption(new Option('--udt-kind ', 'Specify the UDT kind').choices(['sudt', 'xudt'])) + .option('--udt-type-args ', 'Specify the UDT type script args to transfer UDT') + .option('-r, --proxy-rpc', 'Use Proxy RPC to connect to blockchain'), +).action(async (toAddress: string, amount: string, options: TransferOptions) => { + await transfer(toAddress, amount, resolveMainnetForkOverride(options)); +}); -program - .command('transfer-all [toAddress]') - .description('Transfer All CKB tokens to address, only devnet and testnet') - .option('--network ', 'Specify the network to transfer to', 'devnet') - .option('--privkey ', 'Specify the private key (visible in shell history)') - .option('--privkey-file ', 'Read the private key from a local file') - .option('-r, --proxy-rpc', 'Use Proxy RPC to connect to blockchain') - .action(async (toAddress: string, options: TransferOptions) => { - await transferAll(toAddress, options); - }); +mainnetForkOverrideOption( + program + .command('transfer-all [toAddress]') + .description('Transfer All CKB tokens to address, only devnet and testnet') + .option('--network ', 'Specify the network to transfer to', 'devnet') + .option('--privkey ', 'Specify the private key (visible in shell history)') + .option('--privkey-file ', 'Read the private key from a local file') + .option('-r, --proxy-rpc', 'Use Proxy RPC to connect to blockchain'), +).action(async (toAddress: string, options: TransferOptions) => { + await transferAll(toAddress, resolveMainnetForkOverride(options)); +}); program .command('balance [toAddress]') @@ -196,30 +213,32 @@ program const udtCommand = program.command('udt').description('UDT token commands'); -udtCommand - .command('issue ') - .description('Issue new UDT tokens, only devnet and testnet') - .option('--network ', 'Specify the network', 'devnet') - .addOption(new Option('--udt-kind ', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt')) - .option('--type-args ', 'Specify the UDT type script args (xudt only; defaults to signer lock hash)') - .option('--to ', 'Specify the receiver address (defaults to signer)') - .option('--privkey ', 'Specify the private key to issue UDT (visible in shell history)') - .option('--privkey-file ', 'Read the private key from a local file') - .action(async (amount: string, options: UdtIssueOption) => { - await udtIssue(amount, options); - }); +mainnetForkOverrideOption( + udtCommand + .command('issue ') + .description('Issue new UDT tokens, only devnet and testnet') + .option('--network ', 'Specify the network', 'devnet') + .addOption(new Option('--udt-kind ', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt')) + .option('--type-args ', 'Specify the UDT type script args (xudt only; defaults to signer lock hash)') + .option('--to ', 'Specify the receiver address (defaults to signer)') + .option('--privkey ', 'Specify the private key to issue UDT (visible in shell history)') + .option('--privkey-file ', 'Read the private key from a local file'), +).action(async (amount: string, options: UdtIssueOption) => { + await udtIssue(amount, resolveMainnetForkOverride(options)); +}); -udtCommand - .command('destroy ') - .description('Destroy UDT tokens, only devnet and testnet') - .option('--network ', 'Specify the network', 'devnet') - .addOption(new Option('--udt-kind ', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt')) - .requiredOption('--type-args ', 'Specify the UDT type script args') - .option('--privkey ', 'Specify the private key to destroy UDT (visible in shell history)') - .option('--privkey-file ', 'Read the private key from a local file') - .action(async (amount: string, options: UdtDestroyOption) => { - await udtDestroy(amount, options); - }); +mainnetForkOverrideOption( + udtCommand + .command('destroy ') + .description('Destroy UDT tokens, only devnet and testnet') + .option('--network ', 'Specify the network', 'devnet') + .addOption(new Option('--udt-kind ', 'Specify the UDT kind').choices(['sudt', 'xudt']).default('sudt')) + .requiredOption('--type-args ', 'Specify the UDT type script args') + .option('--privkey ', 'Specify the private key to destroy UDT (visible in shell history)') + .option('--privkey-file ', 'Read the private key from a local file'), +).action(async (amount: string, options: UdtDestroyOption) => { + await udtDestroy(amount, resolveMainnetForkOverride(options)); +}); program .command('debugger') diff --git a/src/cmd/config.ts b/src/cmd/config.ts index b5f849e3..ab1f5dca 100644 --- a/src/cmd/config.ts +++ b/src/cmd/config.ts @@ -48,31 +48,29 @@ export async function Config(action: ConfigAction, item: ConfigItem, value?: str case ConfigItem.proxy: { if (value == null) throw new Error('No proxyUrl!'); + // Only the parse belongs in the try: an I/O failure from + // readSettings/writeSettings must not be mislabeled as a bad URL. + let proxy; try { - const proxy = Request.parseProxyUrl(value); - const settings = readSettings(); - settings.proxy = proxy; - return writeSettings(settings); + proxy = Request.parseProxyUrl(value); } catch (error: unknown) { throw new Error(`invalid proxyURL: ${(error as Error).message}`); } + const settings = readSettings(); + settings.proxy = proxy; + return writeSettings(settings); } case ConfigItem.ckbVersion: { - const settings = readSettings(); - try { - if (isValidVersion(value)) { - const version = extractVersion(value!); - settings.bins.defaultCKBVersion = version; - return writeSettings(settings); - } else { - throw new Error( - `invalid version value, ${value}. Check available versions on https://github.com/nervosnetwork/ckb/tags`, - ); - } - } catch (error: unknown) { - throw new Error(`invalid version value: ${(error as Error).message}`); + if (!isValidVersion(value)) { + throw new Error( + `invalid version value, ${value}. Check available versions on https://github.com/nervosnetwork/ckb/tags`, + ); } + const settings = readSettings(); + const version = extractVersion(value!); + settings.bins.defaultCKBVersion = version; + return writeSettings(settings); } default: diff --git a/src/cmd/debug.ts b/src/cmd/debug.ts index 430326b5..8024d110 100644 --- a/src/cmd/debug.ts +++ b/src/cmd/debug.ts @@ -8,8 +8,10 @@ import { Network } from '../type/base'; import { encodeBinPathForTerminal } from '../util/encoding'; import { callJsonRpc } from '../util/json-rpc'; import { logger } from '../util/logger'; +import { validateTxHash } from '../util/validator'; export async function debugTransaction(txHash: string, network: Network) { + validateTxHash(txHash); const txFile = await buildTxFileOptionBy(txHash, network); const opts = buildTransactionDebugOptions(txHash, network); for (const opt of opts) { @@ -19,6 +21,7 @@ export async function debugTransaction(txHash: string, network: Network) { } export function buildTransactionDebugOptions(txHash: string, network: Network) { + validateTxHash(txHash); const txJsonFilePath = buildTransactionJsonFilePath(network, txHash); const txJson = JSON.parse(fs.readFileSync(txJsonFilePath, 'utf-8')); const cccTx = cccA.JsonRpcTransformers.transactionTo(txJson); @@ -57,6 +60,7 @@ export async function debugSingleScript( network: Network, bin?: string, ) { + validateTxHash(txHash); const txFile = await buildTxFileOptionBy(txHash, network); let opt = `--cell-index ${cellIndex} --cell-type ${cellType} --script-group-type ${scriptType}`; if (bin) { @@ -83,6 +87,9 @@ export function parseSingleScriptOption(value: string) { } export async function buildTxFileOptionBy(txHash: string, network: Network) { + // The hash is interpolated into cache file paths below; reject anything that + // is not a plain 32-byte hash before touching the filesystem. + validateTxHash(txHash); const settings = readSettings(); const outputFilePath = buildDebugFullTransactionFilePath(network, txHash); if (!fs.existsSync(outputFilePath)) { diff --git a/src/cmd/deploy.ts b/src/cmd/deploy.ts index 1347a44f..f388dec1 100644 --- a/src/cmd/deploy.ts +++ b/src/cmd/deploy.ts @@ -9,7 +9,7 @@ import { confirm } from '@inquirer/prompts'; import { logger } from '../util/logger'; import { resolvePrivateKey } from '../util/private-key'; import { warnIfForkIndexerIsBehind } from '../devnet/readiness'; -import { warnIfMainnetForkSigning } from '../util/fork-safety'; +import { validateMainnetForkSigning } from '../util/fork-safety'; export interface DeployOptions extends NetworkOption { target?: string; @@ -18,6 +18,7 @@ export interface DeployOptions extends NetworkOption { privkeyFile?: string | null; typeId?: boolean; yes?: boolean; + allowExternalKeyOnMainnetFork?: boolean; } export async function deploy( @@ -28,7 +29,11 @@ export async function deploy( // we use deployerAccount to deploy contract by default const privateKey = resolvePrivateKey(opt, deployerAccount.privkey); - warnIfMainnetForkSigning(network, privateKey); + const rejectInputsAtOrBeforeBlock = validateMainnetForkSigning( + network, + privateKey, + opt.allowExternalKeyOnMainnetFork, + ); await warnIfForkIndexerIsBehind(network); const ckb = new CKB({ network }); const enableTypeId = opt.typeId ?? false; @@ -73,7 +78,14 @@ export async function deploy( } } - const results = await deployBinaries(outputFolder, binPaths, privateKey, enableTypeId, ckb); + const results = await deployBinaries( + outputFolder, + binPaths, + privateKey, + enableTypeId, + ckb, + rejectInputsAtOrBeforeBlock, + ); logger.info(''); // record the deployed contract infos diff --git a/src/cmd/devnet-config.ts b/src/cmd/devnet-config.ts index c1cb6aa6..55ad95ac 100644 --- a/src/cmd/devnet-config.ts +++ b/src/cmd/devnet-config.ts @@ -70,10 +70,14 @@ export async function devnetConfig(options: DevnetConfigOptions = {}) { logger.info('No changes saved.'); } catch (error) { - let message = error instanceof Error ? error.message : String(error); if (error instanceof InitializationError) { - message += ' Tip: run `offckb node` once to initialize devnet config files first.'; + // Rethrow the same object so its name and stack stay intact. + error.message += ' Tip: run `offckb node` once to initialize devnet config files first.'; + throw error; } - throw new Error(message); + if (error instanceof Error) { + throw error; + } + throw new Error(String(error)); } } diff --git a/src/cmd/node.ts b/src/cmd/node.ts index a9f6b477..146ba7dc 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -1,4 +1,4 @@ -import { exec, spawn, ChildProcess } from 'child_process'; +import { execFile, execFileSync, spawn, ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import { initChainIfNeeded } from '../node/init-chain'; @@ -190,12 +190,50 @@ function resolveDaemonPaths() { return { logDir, logFile, pidFile }; } +// Best-effort check that the spawned process is the one listening on the RPC +// port. Returns null when the check cannot be performed (Windows, no lsof, an +// lsof inspection error, or a hung lsof that hits the timeout) so callers can +// fall back to weaker signals. +// lsof exits 1 both for "no match" and for permission/inspection errors; only +// an empty stderr is a genuine no-match, anything else is indeterminate. A +// timed-out probe is killed with an empty stderr too, so ETIMEDOUT must be +// ruled out first to avoid misreading it as a genuine no-match. +export function isProcessListeningOnPort(pid: number, port: number): boolean | null { + if (process.platform === 'win32') return null; + try { + execFileSync('lsof', ['-a', '-p', String(pid), '-iTCP:' + port, '-sTCP:LISTEN'], { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + }); + return true; + } catch (error) { + const err = error as NodeJS.ErrnoException & { stderr?: Buffer | string }; + if (err.code === 'ENOENT' || err.code === 'ETIMEDOUT') return null; + const stderr = err.stderr?.toString().trim() ?? ''; + return stderr === '' ? false : null; + } +} + +function rpcPortOf(rpcUrl: string): number | null { + try { + const url = new URL(rpcUrl); + if (url.port) return Number(url.port); + return url.protocol === 'https:' ? 443 : 80; + } catch { + return null; + } +} + // Poll the devnet RPC until the spawned node answers with the fork's genesis // hash, then mark the first run as done so subsequent `offckb node` runs boot -// normally. Two guards against clearing the flag on the wrong signal: +// normally. Guards against clearing the flag on the wrong signal: // - the poll aborts when the spawned ckb process exits (e.g. failed boot), // - an answering node is only trusted when its genesis matches the fork -// state — an unrelated node occupying the port must not clear the flag. +// state — an unrelated node occupying the port must not clear the flag, +// - when it can be determined, the spawned process must be the RPC listener: +// the fork keeps the source chain's genesis hash, so a stale source or +// fork node sharing the port would otherwise pass the genesis check and +// supply a wrong fork boundary. async function clearForkFirstRunWhenNodeUp( ckbProcess: ChildProcess, rpcUrl: string, @@ -221,6 +259,16 @@ async function clearForkFirstRunWhenNodeUp( ); return; } + const rpcPort = rpcPortOf(rpcUrl); + const listening = + ckbProcess.pid != null && rpcPort != null ? isProcessListeningOnPort(ckbProcess.pid, rpcPort) : null; + if (listening === false) { + // Something else is answering at the RPC URL while our process has not + // bound the port (yet). Do not read the fork boundary from it. + logger.debug(`Waiting for the spawned CKB process to bind the RPC port ${rpcPort} ..`); + await new Promise((resolve) => setTimeout(resolve, 1000)); + continue; + } // The miner has not started yet, so this tip is the exact boundary // between copied public-chain state and cells mined on the local fork. const forkBlockNumber = BigInt(String(await callJsonRpc(rpcUrl, 'get_tip_block_number', [], 5000))).toString(); @@ -382,24 +430,25 @@ function waitForProcessExit(pid: number, timeoutMs: number): Promise { function getProcessCommandLine(pid: number): Promise { return new Promise((resolve) => { - if (process.platform === 'win32') { - exec(`wmic process where ProcessId=${pid} get CommandLine /format:list`, (error, stdout) => { - if (error) { - resolve(null); - return; - } + // Argument arrays, never an interpolated shell string: even though pid is + // validated as a positive integer on every path here, execFile keeps that + // true after any future refactor. + const [cmd, args]: [string, string[]] = + process.platform === 'win32' + ? ['wmic', ['process', 'where', `ProcessId=${pid}`, 'get', 'CommandLine', '/format:list']] + : ['ps', ['-p', String(pid), '-o', 'args=']]; + execFile(cmd, args, (error, stdout) => { + if (error) { + resolve(null); + return; + } + if (process.platform === 'win32') { const match = stdout.match(/CommandLine=(.+)/); resolve(match ? match[1].trim() : null); - }); - } else { - exec(`ps -p ${pid} -o args=`, (error, stdout) => { - if (error) { - resolve(null); - return; - } + } else { resolve(stdout.trim()); - }); - } + } + }); }); } diff --git a/src/cmd/status.ts b/src/cmd/status.ts index d247e492..3bd331e8 100644 --- a/src/cmd/status.ts +++ b/src/cmd/status.ts @@ -1,3 +1,6 @@ +import fs from 'fs'; +import path from 'path'; +import toml, { JsonMap } from '@iarna/toml'; import { readSettings } from '../cfg/setting'; import { CKBTui } from '../tools/ckb-tui'; import { Network } from '../type/base'; @@ -15,6 +18,28 @@ const NETWORK_SETTINGS_KEY: Record = { [Network.mainnet]: 'mainnet', }; +/** + * Best-effort lookup of the devnet node's TCP subscription endpoint from its + * ckb.toml. ckb-tui connects to it directly (the OffCKB proxy is HTTP-only) to + * stream new/rejected transactions and logs; when absent, those dashboards + * simply stay empty, so any failure here is non-fatal. + */ +function devnetTcpListenAddress(): string | undefined { + try { + const settings = readSettings(); + const ckbTomlPath = path.join(settings.devnet.configPath, 'ckb.toml'); + if (!fs.existsSync(ckbTomlPath)) return undefined; + const parsed = toml.parse(fs.readFileSync(ckbTomlPath, 'utf8')); + const rpc = parsed.rpc as JsonMap | undefined; + const address = rpc?.tcp_listen_address; + if (typeof address !== 'string' || address.trim().length === 0) return undefined; + // A wildcard bind is not a dialable address; the node runs on this host. + return address.trim().replace(/^0\.0\.0\.0:/, '127.0.0.1:'); + } catch { + return undefined; + } +} + export async function status({ network }: StatusOptions) { // ckb-tui is an interactive terminal UI. Running it without a TTY // (pipe, redirect, CI) would hang or produce garbage output. @@ -34,7 +59,14 @@ export async function status({ network }: StatusOptions) { `RPC proxy ${url} is not connected to a healthy ${network} node: ${readiness.error ?? 'health check failed'}`, ); } - const result = CKBTui.run(['-r', url]); + const args = ['-r', url]; + if (network === Network.devnet) { + const tcpAddress = devnetTcpListenAddress(); + if (tcpAddress) { + args.push('-t', tcpAddress); + } + } + const result = CKBTui.run(args); // Propagate ckb-tui exit code so scripts can detect TUI failure if (result.status !== 0) { throw new Error(`ckb-tui exited with code ${result.status ?? 'unknown'}`); diff --git a/src/cmd/transfer-all.ts b/src/cmd/transfer-all.ts index c6aefb81..2377933d 100644 --- a/src/cmd/transfer-all.ts +++ b/src/cmd/transfer-all.ts @@ -5,11 +5,12 @@ import { validateNetworkOpt } from '../util/validator'; import { logger } from '../util/logger'; import { resolvePrivateKey } from '../util/private-key'; import { warnIfForkIndexerIsBehind } from '../devnet/readiness'; -import { warnIfMainnetForkSigning } from '../util/fork-safety'; +import { validateMainnetForkSigning } from '../util/fork-safety'; export interface TransferAllOptions extends NetworkOption { privkey?: string | null; privkeyFile?: string | null; + allowExternalKeyOnMainnetFork?: boolean; } export async function transferAll(toAddress: string, opt: TransferAllOptions = { network: Network.devnet }) { @@ -17,13 +18,20 @@ export async function transferAll(toAddress: string, opt: TransferAllOptions = { validateNetworkOpt(network); const privateKey = resolvePrivateKey(opt); - warnIfMainnetForkSigning(network, privateKey); + // transfer-all sweeps the whole balance, which makes it the most likely + // command to pick up copied pre-fork Mainnet cells — enforce, not just warn. + const rejectInputsAtOrBeforeBlock = validateMainnetForkSigning( + network, + privateKey, + opt.allowExternalKeyOnMainnetFork, + ); await warnIfForkIndexerIsBehind(network); const ckb = new CKB({ network }); const txHash = await ckb.transferAll({ toAddress, privateKey, + rejectInputsAtOrBeforeBlock, }); if (network === 'testnet') { logger.info(`Successfully transfer, check ${buildTestnetTxLink(txHash)} for details.`); diff --git a/src/cmd/transfer.ts b/src/cmd/transfer.ts index 0be49112..e60b1afe 100644 --- a/src/cmd/transfer.ts +++ b/src/cmd/transfer.ts @@ -12,7 +12,7 @@ export interface TransferOptions extends NetworkOption { privkeyFile?: string | null; udtKind?: UdtKind; udtTypeArgs?: string; - allowMainnetReplayRisk?: boolean; + allowExternalKeyOnMainnetFork?: boolean; } export async function transfer(toAddress: string, amount: string, opt: TransferOptions = { network: Network.devnet }) { @@ -32,7 +32,11 @@ export async function transfer(toAddress: string, amount: string, opt: TransferO } const privateKey = resolvePrivateKey(opt); - const rejectInputsAtOrBeforeBlock = validateMainnetForkSigning(network, privateKey, opt.allowMainnetReplayRisk); + const rejectInputsAtOrBeforeBlock = validateMainnetForkSigning( + network, + privateKey, + opt.allowExternalKeyOnMainnetFork, + ); await warnIfForkIndexerIsBehind(network); const ckb = new CKB({ network }); diff --git a/src/cmd/udt.ts b/src/cmd/udt.ts index 517ea5f3..802ca9ea 100644 --- a/src/cmd/udt.ts +++ b/src/cmd/udt.ts @@ -5,7 +5,7 @@ import { validateNetworkOpt, validateUdtAmount, validateUdtKind, validateUdtType import { resolvePrivateKey } from '../util/private-key'; import { logger } from '../util/logger'; import { warnIfForkIndexerIsBehind } from '../devnet/readiness'; -import { warnIfMainnetForkSigning } from '../util/fork-safety'; +import { validateMainnetForkSigning } from '../util/fork-safety'; export interface UdtIssueOption extends NetworkOption { udtKind: UdtKind; @@ -13,6 +13,7 @@ export interface UdtIssueOption extends NetworkOption { to?: string; privkey?: string; privkeyFile?: string; + allowExternalKeyOnMainnetFork?: boolean; } export interface UdtDestroyOption extends NetworkOption { @@ -20,6 +21,7 @@ export interface UdtDestroyOption extends NetworkOption { typeArgs: string; privkey?: string; privkeyFile?: string; + allowExternalKeyOnMainnetFork?: boolean; } export async function udtIssue(amount: string, opt: UdtIssueOption = { network: Network.devnet, udtKind: 'sudt' }) { @@ -30,7 +32,11 @@ export async function udtIssue(amount: string, opt: UdtIssueOption = { network: const typeArgs = opt.typeArgs ? validateUdtTypeArgs(opt.udtKind, opt.typeArgs) : undefined; const privateKey = resolvePrivateKey(opt); - warnIfMainnetForkSigning(network, privateKey); + const rejectInputsAtOrBeforeBlock = validateMainnetForkSigning( + network, + privateKey, + opt.allowExternalKeyOnMainnetFork, + ); await warnIfForkIndexerIsBehind(network); const ckb = new CKB({ network }); @@ -40,6 +46,7 @@ export async function udtIssue(amount: string, opt: UdtIssueOption = { network: amount, typeArgs, toAddress: opt.to, + rejectInputsAtOrBeforeBlock, }); logTxSuccess(network, result.txHash, 'issued UDT'); @@ -70,7 +77,11 @@ export async function udtDestroy( const typeArgs = validateUdtTypeArgs(opt.udtKind, opt.typeArgs); const privateKey = resolvePrivateKey(opt); - warnIfMainnetForkSigning(network, privateKey); + const rejectInputsAtOrBeforeBlock = validateMainnetForkSigning( + network, + privateKey, + opt.allowExternalKeyOnMainnetFork, + ); await warnIfForkIndexerIsBehind(network); const ckb = new CKB({ network }); @@ -79,6 +90,7 @@ export async function udtDestroy( kind: opt.udtKind, amount, typeArgs, + rejectInputsAtOrBeforeBlock, }); logTxSuccess(network, txHash, 'destroyed UDT'); diff --git a/src/deploy/index.ts b/src/deploy/index.ts index 609c98d8..1be75b3b 100644 --- a/src/deploy/index.ts +++ b/src/deploy/index.ts @@ -81,13 +81,14 @@ export async function deployBinaries( privateKey: HexString, enableTypeId: boolean, ckb: CKB, + rejectInputsAtOrBeforeBlock?: bigint, ) { if (binPaths.length === 0) { logger.info('No binary to deploy.'); } const results: DeployedInterfaceType[] = []; for (const bin of binPaths) { - const result = await deployBinary(outputFolder, bin, privateKey, enableTypeId, ckb); + const result = await deployBinary(outputFolder, bin, privateKey, enableTypeId, ckb, rejectInputsAtOrBeforeBlock); results.push(result); } return results; @@ -99,6 +100,7 @@ export async function deployBinary( privateKey: HexString, enableTypeId: boolean, ckb: CKB, + rejectInputsAtOrBeforeBlock?: bigint, ): Promise<{ deploymentRecipe: DeploymentRecipe; deploymentOptions: DeploymentOptions; @@ -107,10 +109,10 @@ export async function deployBinary( const contractName = path.basename(binPath); const result = !enableTypeId - ? await ckb.deployScript(bin, privateKey) + ? await ckb.deployScript(bin, privateKey, rejectInputsAtOrBeforeBlock) : Migration.isDeployedWithTypeId(outputFolder, contractName, ckb.network) - ? await ckb.upgradeTypeIdScript(outputFolder, contractName, bin, privateKey) - : await ckb.deployNewTypeIDScript(bin, privateKey); + ? await ckb.upgradeTypeIdScript(outputFolder, contractName, bin, privateKey, rejectInputsAtOrBeforeBlock) + : await ckb.deployNewTypeIDScript(bin, privateKey, rejectInputsAtOrBeforeBlock); logger.info(`contract ${contractName} deployed, tx hash:`, result.txHash); logger.info('wait for tx confirmed on-chain...'); diff --git a/src/devnet/fork.ts b/src/devnet/fork.ts index 1200bea9..19aaeaff 100644 --- a/src/devnet/fork.ts +++ b/src/devnet/fork.ts @@ -264,16 +264,38 @@ export function copySourceData(sourceDir: string, configPath: string): void { // place, and linked files would corrupt the source chain. const excludedTopLevelEntries = new Set(['network', 'logs', 'tmp']); fs.mkdirSync(targetData, { recursive: true }); + // A symlinked data root would bypass the per-entry checks below: + // readdirSync follows it and its ordinary children would pass assertNoSymlink. + assertNoSymlink(sourceData); // Enumerate top-level entries instead of relying on fs.cp's filter paths, // which may use Windows extended-length prefixes and bypass relative-path // comparisons. for (const entry of fs.readdirSync(sourceData)) { if (excludedTopLevelEntries.has(entry)) continue; - fs.cpSync(path.join(sourceData, entry), path.join(targetData, entry), { recursive: true }); + const sourceEntry = path.join(sourceData, entry); + // fs.cpSync resolves symlinks by default; a symlinked entry (especially + // data/db) would silently copy data from outside the source directory. + assertNoSymlink(sourceEntry); + fs.cpSync(sourceEntry, path.join(targetData, entry), { + recursive: true, + filter: (src) => { + assertNoSymlink(src); + return true; + }, + }); } logger.info('Excluded source network peers and transient logs/tmp data from the fork.'); } +function assertNoSymlink(entryPath: string): void { + if (fs.lstatSync(entryPath).isSymbolicLink()) { + throw new Error( + `Refusing to copy ${entryPath}: symlinked entries are not allowed in the source chain data. ` + + 'Replace the symlink with the real directory and retry.', + ); + } +} + export function isolateForkCkbConfig(config: Record): Record { const network = { ...((config.network as Record) ?? {}) }; network.bootnodes = []; diff --git a/src/node/init-chain.ts b/src/node/init-chain.ts index 29a3a660..b030cb75 100644 --- a/src/node/init-chain.ts +++ b/src/node/init-chain.ts @@ -1,5 +1,6 @@ import fs from 'fs'; import path from 'path'; +import toml, { JsonMap } from '@iarna/toml'; import { isFolderExists, copyFilesWithExclusion } from '../util/fs'; import { packageRootPath, readSettings } from '../cfg/setting'; import { logger } from '../util/logger'; @@ -33,4 +34,138 @@ export async function initChainIfNeeded() { fs.writeFileSync(minerConfigPath, modifiedData, 'utf8'); } } + + migrateLegacyDevnetRpcConfig(devnetConfigPath); +} + +const TERMINAL_RPC_MODULE = 'Terminal'; +const DEFAULT_TCP_LISTEN_ADDRESS = '127.0.0.1:18114'; + +function findRpcSection(lines: string[]): { start: number; end: number } | null { + const start = lines.findIndex((line) => /^\s*\[rpc\]\s*$/.test(line)); + if (start < 0) return null; + let end = lines.length; + for (let i = start + 1; i < lines.length; i++) { + if (/^\s*\[[^\]]*\]\s*$/.test(lines[i])) { + end = i; + break; + } + } + return { start, end }; +} + +// Adds "Terminal" to the rpc.modules array, preserving the file's formatting. +// Handles both the single-line layout used by the bundled template and +// hand-formatted multi-line arrays. +function addTerminalModule(lines: string[], section: { start: number; end: number }): boolean { + const modulesStart = lines.findIndex( + (line, index) => index > section.start && index < section.end && /^\s*modules\s*=\s*\[/.test(line), + ); + if (modulesStart < 0) return false; + + const singleLine = lines[modulesStart].match(/^(\s*modules\s*=\s*\[[^\]]*)\](\s*(?:#.*)?)$/); + if (singleLine) { + lines[modulesStart] = `${singleLine[1]}, "${TERMINAL_RPC_MODULE}"]${singleLine[2]}`; + return true; + } + + // Multi-line array: find the line holding the closing bracket, make sure the + // previous entry ends with a comma, then insert the new module before it. + let closingLine = -1; + for (let i = modulesStart + 1; i < section.end; i++) { + if (lines[i].includes(']')) { + closingLine = i; + break; + } + } + if (closingLine < 0) return false; + for (let i = closingLine - 1; i > modulesStart; i--) { + if (lines[i].trim().length === 0) continue; + if (!lines[i].trimEnd().endsWith(',')) { + lines[i] = `${lines[i].trimEnd()},`; + } + break; + } + lines.splice(closingLine, 0, ` "${TERMINAL_RPC_MODULE}",`); + return true; +} + +// Enables rpc.tcp_listen_address. Only the stock loopback default is +// uncommented in place — a commented non-loopback value (e.g. 0.0.0.0) stays +// disabled and a fresh loopback entry is inserted after the modules array +// instead, so the migration never turns the RPC into a public listener. +function enableTcpListenAddress(lines: string[], section: { start: number; end: number }): boolean { + for (let i = section.start + 1; i < section.end; i++) { + const commented = lines[i].match(/^(\s*)#\s*(tcp_listen_address\s*=.*)$/); + if (!commented) continue; + const value = commented[2].match(/tcp_listen_address\s*=\s*"([^"]*)"/); + if (value?.[1] === DEFAULT_TCP_LISTEN_ADDRESS) { + lines[i] = `${commented[1]}${commented[2]}`; + return true; + } + } + + let insertAt = section.start + 1; + for (let i = section.start + 1; i < section.end; i++) { + if (/^\s*modules\s*=\s*\[/.test(lines[i])) { + insertAt = i + 1; + while (insertAt < section.end && !lines[insertAt - 1].includes(']')) { + insertAt++; + } + break; + } + } + lines.splice(insertAt, 0, `tcp_listen_address = "${DEFAULT_TCP_LISTEN_ADDRESS}"`); + return true; +} + +/** + * Upgrades a pre-existing devnet ckb.toml so `offckb status` (ckb-tui) works: + * the bundled template gained the Terminal RPC module and an enabled + * tcp_listen_address, but initChainIfNeeded only copies the template into + * fresh config folders, so chains initialized before that change never picked + * it up. Edits are text-based to keep user comments/formatting intact, and + * any failure is non-fatal — node startup must never break over a migration. + * Returns true when the file was changed. + */ +export function migrateLegacyDevnetRpcConfig(devnetConfigPath: string): boolean { + const ckbTomlPath = path.join(devnetConfigPath, 'ckb.toml'); + try { + if (!fs.existsSync(ckbTomlPath)) return false; + const source = fs.readFileSync(ckbTomlPath, 'utf8'); + + const parsed = toml.parse(source); + const rpc = parsed.rpc as JsonMap | undefined; + if (rpc == null || typeof rpc !== 'object') return false; + + const modules = rpc.modules; + const needsTerminal = + Array.isArray(modules) && modules.every((m) => typeof m === 'string') && !modules.includes(TERMINAL_RPC_MODULE); + const tcpAddress = rpc.tcp_listen_address; + const needsTcp = typeof tcpAddress !== 'string' || tcpAddress.trim().length === 0; + if (!needsTerminal && !needsTcp) return false; + + const lines = source.split('\n'); + const section = findRpcSection(lines); + if (section == null) return false; + + const changes: string[] = []; + if (needsTerminal && addTerminalModule(lines, section)) { + changes.push('Terminal RPC module'); + } + if (needsTcp && enableTcpListenAddress(lines, findRpcSection(lines) ?? section)) { + changes.push(`tcp_listen_address (${DEFAULT_TCP_LISTEN_ADDRESS})`); + } + if (changes.length === 0) return false; + + fs.writeFileSync(ckbTomlPath, lines.join('\n'), 'utf8'); + logger.info( + `Upgraded devnet ckb.toml for ckb-tui: enabled ${changes.join(' and ')}. ` + + 'Restart the node if it is already running for this to take effect.', + ); + return true; + } catch (error) { + logger.debug(`skipping devnet ckb.toml migration: ${(error as Error).message}`); + return false; + } } diff --git a/src/sdk/ckb.ts b/src/sdk/ckb.ts index 08218a5d..f9f70d5e 100644 --- a/src/sdk/ckb.ts +++ b/src/sdk/ckb.ts @@ -43,7 +43,7 @@ export interface TransferOption { rejectInputsAtOrBeforeBlock?: bigint; } -export type TransferAllOption = Pick; +export type TransferAllOption = Pick; export interface UdtTransferOption { privateKey: HexString; @@ -60,6 +60,7 @@ export interface UdtIssueOption { amount: HexNumber; typeArgs?: HexString; toAddress?: string; + rejectInputsAtOrBeforeBlock?: bigint; } export interface UdtIssueResult { @@ -73,6 +74,7 @@ export interface UdtDestroyOption { kind: UdtKind; typeArgs: HexString; amount: HexNumber; + rejectInputsAtOrBeforeBlock?: bigint; } export interface UdtBalanceInfo { @@ -203,7 +205,7 @@ export class CKB { return txHash; } - async transferAll({ privateKey, toAddress }: TransferAllOption): Promise { + async transferAll({ privateKey, toAddress, rejectInputsAtOrBeforeBlock }: TransferAllOption): Promise { const signer = this.buildSigner(privateKey); const to = await ccc.Address.fromString(toAddress, this.client); const balanceInCKB = await this.balance((await signer.getRecommendedAddressObj()).toString()); @@ -219,6 +221,7 @@ export class CKB { ], }); await tx.completeInputsByCapacity(signer); + await this.assertInputsCreatedAfter(tx, rejectInputsAtOrBeforeBlock); const txHash = await signer.sendTransaction(tx); return txHash; } @@ -277,9 +280,10 @@ export class CKB { { kind: UdtKind; codeHash: HexString; hashType: string; args: HexString; balance: bigint } >(); - let scanned = 0; - + // Each kind gets its own scan budget: if the SUDT scan alone reached + // maxCells, a shared counter would silently drop every XUDT balance. const scan = async (scriptInfo: UdtScriptInfo, kind: UdtKind) => { + let scanned = 0; for await (const cell of this.client.findCells( { script: { @@ -435,7 +439,14 @@ export class CKB { } } - async udtIssue({ privateKey, kind, amount, typeArgs, toAddress }: UdtIssueOption): Promise { + async udtIssue({ + privateKey, + kind, + amount, + typeArgs, + toAddress, + rejectInputsAtOrBeforeBlock, + }: UdtIssueOption): Promise { const signer = this.buildSigner(privateKey); const signerAddress = await signer.getAddressObjSecp256k1(); const to = toAddress ? await ccc.Address.fromString(toAddress, this.client) : signerAddress; @@ -476,12 +487,13 @@ export class CKB { await tx.completeInputsByCapacity(signer); await tx.completeFeeBy(signer, this.feeRate); + await this.assertInputsCreatedAfter(tx, rejectInputsAtOrBeforeBlock); const txHash = await signer.sendTransaction(tx); return { txHash, typeArgs: resolvedTypeArgs, receiver: to.toString() }; } async udtDestroy( - { privateKey, kind, typeArgs, amount }: UdtDestroyOption, + { privateKey, kind, typeArgs, amount, rejectInputsAtOrBeforeBlock }: UdtDestroyOption, { maxInputCells = DEFAULT_UDT_DESTROY_MAX_INPUT_CELLS }: { maxInputCells?: number } = {}, ): Promise { const signer = this.buildSigner(privateKey); @@ -537,11 +549,16 @@ export class CKB { await tx.completeInputsByCapacity(signer); await tx.completeFeeBy(signer, this.feeRate); + await this.assertInputsCreatedAfter(tx, rejectInputsAtOrBeforeBlock); const txHash = await signer.sendTransaction(tx); return txHash; } - async deployScript(scriptBinBytes: Uint8Array, privateKey: string): Promise { + async deployScript( + scriptBinBytes: Uint8Array, + privateKey: string, + rejectInputsAtOrBeforeBlock?: bigint, + ): Promise { const signer = this.buildSigner(privateKey); const signerSecp256k1Address = await signer.getAddressObjSecp256k1(); const tx = ccc.Transaction.from({ @@ -554,11 +571,16 @@ export class CKB { }); await tx.completeInputsByCapacity(signer); await tx.completeFeeBy(signer, this.feeRate); + await this.assertInputsCreatedAfter(tx, rejectInputsAtOrBeforeBlock); const txHash = await signer.sendTransaction(tx); return { txHash, tx, scriptOutputCellIndex: 0, isTypeId: false }; } - async deployNewTypeIDScript(scriptBinBytes: Uint8Array, privateKey: string): Promise { + async deployNewTypeIDScript( + scriptBinBytes: Uint8Array, + privateKey: string, + rejectInputsAtOrBeforeBlock?: bigint, + ): Promise { const signer = this.buildSigner(privateKey); const signerSecp256k1Address = await signer.getAddressObjSecp256k1(); const typeIdTx = ccc.Transaction.from({ @@ -576,6 +598,7 @@ export class CKB { } typeIdTx.outputs[0].type.args = ccc.hashTypeId(typeIdTx.inputs[0], 0); await typeIdTx.completeFeeBy(signer, this.feeRate); + await this.assertInputsCreatedAfter(typeIdTx, rejectInputsAtOrBeforeBlock); const txHash = await signer.sendTransaction(typeIdTx); return { txHash, tx: typeIdTx, scriptOutputCellIndex: 0, isTypeId: true, typeId: typeIdTx.outputs[0].type }; } @@ -585,6 +608,7 @@ export class CKB { scriptName: string, newScriptBinBytes: Uint8Array, privateKey: HexString, + rejectInputsAtOrBeforeBlock?: bigint, ): Promise { const deploymentReceipt = Migration.find(baseFolder, scriptName, this.network); if (deploymentReceipt == null) throw new Error("no migration file, can't be updated."); @@ -635,6 +659,7 @@ export class CKB { } typeIdTx.outputs[0].type.args = typeIdArgs as `0x{string}`; await typeIdTx.completeFeeBy(signer, this.feeRate); + await this.assertInputsCreatedAfter(typeIdTx, rejectInputsAtOrBeforeBlock); const txHash = await signer.sendTransaction(typeIdTx); return { txHash, tx: typeIdTx, scriptOutputCellIndex: 0, isTypeId: true, typeId: typeIdTx.outputs[0].type }; } diff --git a/src/tools/ckb-tui.ts b/src/tools/ckb-tui.ts index 086719fa..d65602e6 100644 --- a/src/tools/ckb-tui.ts +++ b/src/tools/ckb-tui.ts @@ -149,12 +149,17 @@ export class CKBTui { const archivePath = path.join(tempDir, assetName); try { - // 1. Download + // 1. Download. Keep curl's own limit aligned with the outer spawnSync + // timeout so the two never disagree about who gives up first. logger.info(`Downloading ckb-tui from ${downloadUrl}...`); - const curlResult = spawnSync('curl', ['-fsSL', '--max-time', '300', '-o', archivePath, downloadUrl], { - stdio: 'inherit', - timeout: DOWNLOAD_TIMEOUT_MS, - }); + const curlResult = spawnSync( + 'curl', + ['-fsSL', '--max-time', String(DOWNLOAD_TIMEOUT_MS / 1000), '-o', archivePath, downloadUrl], + { + stdio: 'inherit', + timeout: DOWNLOAD_TIMEOUT_MS, + }, + ); if (curlResult.error) { throw new Error(`Failed to download ckb-tui: ${curlResult.error.message}`); @@ -179,8 +184,27 @@ export class CKBTui { throw new Error(`ckb-tui binary ("${binaryName}") was not found after extraction.`); } - // 5. Atomically move to the final location - fs.renameSync(extractedBinary, this.binaryPath); + // 5. Move to the final location. renameSync is atomic but throws EXDEV + // when the temp dir and the data path live on different filesystems + // (common in containers). In that case stage the copy inside binDir and + // publish it with a rename, so a concurrent ensureInstalled() never sees + // a partially copied binary at the final path. + try { + fs.renameSync(extractedBinary, this.binaryPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EXDEV') { + const stagingPath = path.join(binDir, `.${binaryName}.staging-${process.pid}`); + try { + fs.copyFileSync(extractedBinary, stagingPath); + fs.renameSync(stagingPath, this.binaryPath); + fs.unlinkSync(extractedBinary); + } finally { + fs.rmSync(stagingPath, { force: true }); + } + } else { + throw error; + } + } // 6. Make executable on Unix if (process.platform !== 'win32') { diff --git a/src/tui/devnet-config-metadata.ts b/src/tui/devnet-config-metadata.ts index 5fa300e2..65aa0e67 100644 --- a/src/tui/devnet-config-metadata.ts +++ b/src/tui/devnet-config-metadata.ts @@ -149,6 +149,8 @@ const FIXED_ARRAY_SPECS: FixedArraySpec[] = [ { pathPattern: 'rpc.modules', label: 'RPC Modules', + // Keep in sync with the module list in ckb/devnet/ckb.toml; "Terminal" + // powers ckb-tui's get_overview metrics used by `offckb status`. options: [ 'Net', 'Pool', @@ -159,7 +161,9 @@ const FIXED_ARRAY_SPECS: FixedArraySpec[] = [ 'Debug', 'IntegrationTest', 'Indexer', + 'RichIndexer', 'Subscription', + 'Terminal', ], unique: true, allowCustom: true, diff --git a/src/tui/devnet-reference-templates.ts b/src/tui/devnet-reference-templates.ts index 1133ae57..478fd8f8 100644 --- a/src/tui/devnet-reference-templates.ts +++ b/src/tui/devnet-reference-templates.ts @@ -10,7 +10,7 @@ data_dir = "data" spec = { file = "specs/dev.toml" } [logger] -filter = "warn,ckb-script=debug" +filter = "info,ckb-script=debug" color = true log_to_file = true log_to_stdout = true @@ -80,16 +80,19 @@ support_protocols = ["Ping", "Discovery", "Identify", "Feeler", "DisconnectMessa # # Allowing arbitrary machines to access the JSON-RPC port is dangerous and strongly discouraged. # Please strictly limit the access to only trusted machines. -listen_address = "0.0.0.0:8114" +listen_address = "127.0.0.1:8114" # Default is 10MiB = 10 * 1024 * 1024 max_request_body_size = 10485760 -# List of API modules: ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer"] -modules = ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer"] +# List of API modules: ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer", "RichIndexer", "Terminal"] +# "Terminal" powers ckb-tui's \`get_overview\` system metrics; without it those panels show N/A. +modules = ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer", "Terminal"] # By default RPC only binds to HTTP service, you can bind it to TCP and WebSocket. -# tcp_listen_address = "127.0.0.1:18114" +# The TCP service streams subscription topics (new_transaction, rejected_transaction, log, ...) +# that ckb-tui's mempool and logs dashboards rely on. +tcp_listen_address = "127.0.0.1:18114" # ws_listen_address = "127.0.0.1:28114" reject_ill_transactions = true @@ -190,7 +193,7 @@ data_dir = "data" spec = { file = "specs/dev.toml" } [logger] -filter = "warn,ckb-script=debug" +filter = "info,ckb-script=debug" color = true log_to_file = true log_to_stdout = true diff --git a/src/util/fork-safety.ts b/src/util/fork-safety.ts index b5508dd5..964a6ab7 100644 --- a/src/util/fork-safety.ts +++ b/src/util/fork-safety.ts @@ -5,6 +5,23 @@ import { ForkState, readForkState } from '../devnet/fork'; import { Network } from '../type/base'; import { logger } from './logger'; +export interface MainnetForkOverrideOptions { + allowExternalKeyOnMainnetFork?: boolean; + allowMainnetReplayRisk?: boolean; +} + +/** + * Map the deprecated --allow-mainnet-replay-risk flag (0.4.9) onto its + * replacement so scripts written against the old name keep working. + */ +export function resolveMainnetForkOverride(options: T): T { + if (options.allowMainnetReplayRisk) { + logger.warn('`--allow-mainnet-replay-risk` is deprecated; use `--allow-external-key-on-mainnet-fork` instead.'); + options.allowExternalKeyOnMainnetFork = true; + } + return options; +} + const BUILT_IN_DEV_KEYS = new Set( [...accountConfig.map((account) => account.privkey), ckbDevnetMinerAccount.privkey].map((key) => key.toLowerCase()), ); @@ -23,16 +40,16 @@ export function warnIfMainnetForkSigning(network: Network, privateKey: string): export function validateMainnetForkSigning( network: Network, privateKey: string, - allowMainnetReplayRisk = false, + allowExternalKeyOnMainnetFork = false, ): bigint | undefined { const fork = readMainnetForkState(network); if (!fork) return undefined; logMainnetForkSigningWarning(privateKey); - if (!BUILT_IN_DEV_KEYS.has(privateKey.trim().toLowerCase()) && !allowMainnetReplayRisk) { + if (!BUILT_IN_DEV_KEYS.has(privateKey.trim().toLowerCase()) && !allowExternalKeyOnMainnetFork) { throw new Error( 'Refusing to sign with a non-built-in private key on a Mainnet fork. ' + - 'Use --allow-mainnet-replay-risk only after verifying that no copied Mainnet input will be selected.', + 'Use --allow-external-key-on-mainnet-fork only after verifying that no copied Mainnet input will be selected.', ); } if (fork.forkBlockNumber == null) { diff --git a/src/util/validator.ts b/src/util/validator.ts index 5f7b4620..98b32f7d 100644 --- a/src/util/validator.ts +++ b/src/util/validator.ts @@ -142,6 +142,7 @@ export function validateUdtAmount(amount: string): bigint { } const HEX_REGEX = /^0x[0-9a-fA-F]*$/; +const TX_HASH_REGEX = /^0x[0-9a-fA-F]{64}$/; export function validateHexString(value: string, name: string): HexString { if (!value || !HEX_REGEX.test(value)) { @@ -150,14 +151,26 @@ export function validateHexString(value: string, name: string): HexString { return value as HexString; } +export function validateTxHash(txHash: string): HexString { + if (!TX_HASH_REGEX.test(txHash)) { + throw new Error(`invalid transaction hash "${txHash}", must be a 0x-prefixed 32-byte hex string`); + } + return txHash as HexString; +} + export function validateUdtTypeArgs(kind: UdtKind, typeArgs: string): HexString { const hex = validateHexString(typeArgs, 'type args'); + if ((hex.length - 2) % 2 !== 0) { + throw new Error(`invalid ${kind === 'sudt' ? 'SUDT' : 'xUDT'} type args: hex must encode whole bytes`); + } const byteLength = (hex.length - 2) / 2; if (kind === 'sudt' && byteLength !== 32) { throw new Error(`invalid SUDT type args length: expected 32 bytes, got ${byteLength}`); } - if (kind === 'xudt' && byteLength !== 32) { - throw new Error(`invalid xUDT type args length: expected 32 bytes, got ${byteLength}`); + // xUDT args are the 32-byte owner lock hash plus optional flags and + // extension data, so 32 bytes is the minimum, not the exact, length. + if (kind === 'xudt' && byteLength < 32) { + throw new Error(`invalid xUDT type args length: expected at least 32 bytes, got ${byteLength}`); } return hex; } diff --git a/tests/cli-mainnet-fork-flag.test.ts b/tests/cli-mainnet-fork-flag.test.ts new file mode 100644 index 00000000..6a72bc4b --- /dev/null +++ b/tests/cli-mainnet-fork-flag.test.ts @@ -0,0 +1,122 @@ +const mockDeploy = jest.fn(); +const mockTransfer = jest.fn(); +const mockTransferAll = jest.fn(); +const mockUdtIssue = jest.fn(); +const mockUdtDestroy = jest.fn(); + +jest.mock('../src/cmd/node', () => ({ startNode: jest.fn(), stopNode: jest.fn() })); +jest.mock('../src/cmd/accounts', () => ({ accounts: jest.fn() })); +jest.mock('../src/cmd/clean', () => ({ clean: jest.fn() })); +jest.mock('../src/cmd/deposit', () => ({ deposit: jest.fn() })); +jest.mock('../src/cmd/deploy', () => ({ deploy: (...args: unknown[]) => mockDeploy(...args) })); +jest.mock('../src/cmd/transfer', () => ({ transfer: (...args: unknown[]) => mockTransfer(...args) })); +jest.mock('../src/cmd/balance', () => ({ balanceOf: jest.fn() })); +jest.mock('../src/cmd/udt', () => ({ + udtIssue: (...args: unknown[]) => mockUdtIssue(...args), + udtDestroy: (...args: unknown[]) => mockUdtDestroy(...args), +})); +jest.mock('../src/cmd/create', () => ({ createScriptProject: jest.fn() })); +jest.mock('../src/cmd/config', () => ({ Config: jest.fn() })); +jest.mock('../src/cmd/devnet-config', () => ({ devnetConfig: jest.fn() })); +jest.mock('../src/cmd/devnet-fork', () => ({ devnetFork: jest.fn() })); +jest.mock('../src/cmd/devnet-info', () => ({ devnetInfo: jest.fn() })); +jest.mock('../src/cmd/debug', () => ({ + debugSingleScript: jest.fn(), + debugTransaction: jest.fn(), + parseSingleScriptOption: jest.fn(), +})); +jest.mock('../src/cmd/system-scripts', () => ({ printSystemScripts: jest.fn() })); +jest.mock('../src/cmd/transfer-all', () => ({ transferAll: (...args: unknown[]) => mockTransferAll(...args) })); +jest.mock('../src/cmd/status', () => ({ status: jest.fn() })); +jest.mock('../src/scripts/gen', () => ({ genSystemScriptsJsonFile: jest.fn() })); +jest.mock('../src/tools/ckb-debugger', () => ({ CKBDebugger: { runWithArgs: jest.fn() } })); +jest.mock('../src/util/logger', () => ({ + logger: { + success: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + result: jest.fn(), + failure: jest.fn(), + setJsonMode: jest.fn(), + isJsonMode: jest.fn(() => false), + hasResult: jest.fn(() => false), + }, +})); + +// src/cli.ts builds its commander program at module scope and commander keeps +// parsed option values between parseAsync calls, so each test gets a fresh +// module registry to avoid option state leaking across runs. +function loadCli() { + jest.resetModules(); + const cli = require('../src/cli') as typeof import('../src/cli'); + const { logger } = require('../src/util/logger') as typeof import('../src/util/logger'); + return { runCli: cli.runCli, logger }; +} + +describe('deprecated --allow-mainnet-replay-risk CLI alias', () => { + beforeEach(() => { + jest.clearAllMocks(); + process.exitCode = undefined; + }); + + afterEach(() => { + process.exitCode = undefined; + }); + + it('maps the deprecated flag onto --allow-external-key-on-mainnet-fork', async () => { + const { runCli, logger } = loadCli(); + await runCli(['node', 'offckb', 'transfer', '0xrecipient', '100', '--allow-mainnet-replay-risk']); + + expect(mockTransfer).toHaveBeenCalledWith( + '0xrecipient', + '100', + expect.objectContaining({ allowExternalKeyOnMainnetFork: true }), + ); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('--allow-external-key-on-mainnet-fork')); + }); + + it('keeps the new flag working without a deprecation warning', async () => { + const { runCli, logger } = loadCli(); + await runCli(['node', 'offckb', 'transfer', '0xrecipient', '100', '--allow-external-key-on-mainnet-fork']); + + expect(mockTransfer).toHaveBeenCalledWith( + '0xrecipient', + '100', + expect.objectContaining({ allowExternalKeyOnMainnetFork: true }), + ); + expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('deprecated')); + }); + + it('accepts the deprecated alias on every guarded command', async () => { + const { runCli } = loadCli(); + + await runCli(['node', 'offckb', 'deploy', '--allow-mainnet-replay-risk']); + expect(mockDeploy).toHaveBeenCalledWith(expect.objectContaining({ allowExternalKeyOnMainnetFork: true })); + + await runCli(['node', 'offckb', 'transfer-all', '0xrecipient', '--allow-mainnet-replay-risk']); + expect(mockTransferAll).toHaveBeenCalledWith( + '0xrecipient', + expect.objectContaining({ allowExternalKeyOnMainnetFork: true }), + ); + + await runCli(['node', 'offckb', 'udt', 'issue', '100', '--allow-mainnet-replay-risk']); + expect(mockUdtIssue).toHaveBeenCalledWith('100', expect.objectContaining({ allowExternalKeyOnMainnetFork: true })); + + await runCli([ + 'node', + 'offckb', + 'udt', + 'destroy', + '100', + '--type-args', + '0x' + '00'.repeat(32), + '--allow-mainnet-replay-risk', + ]); + expect(mockUdtDestroy).toHaveBeenCalledWith( + '100', + expect.objectContaining({ allowExternalKeyOnMainnetFork: true }), + ); + }); +}); diff --git a/tests/debug-tx-file.test.ts b/tests/debug-tx-file.test.ts index d1622e9a..1567581e 100644 --- a/tests/debug-tx-file.test.ts +++ b/tests/debug-tx-file.test.ts @@ -120,4 +120,15 @@ describe('buildTxFileOptionBy', () => { `Failed to fetch transaction ${TX_HASH} from http://127.0.0.1:8114: connect ECONNREFUSED`, ); }); + + it('rejects a malformed tx hash before touching any cache path', async () => { + for (const badHash of ['0x../escape', 'not-a-hash', '0x' + 'ab'.repeat(31), '0x' + 'ab'.repeat(33)]) { + await expect(buildTxFileOptionBy(badHash, Network.devnet)).rejects.toThrow('invalid transaction hash'); + } + + expect(mockExistsSync).not.toHaveBeenCalled(); + expect(mockCallJsonRpc).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + expect(mockDumpTransaction).not.toHaveBeenCalled(); + }); }); diff --git a/tests/devnet-config-metadata.test.ts b/tests/devnet-config-metadata.test.ts new file mode 100644 index 00000000..bfebd1dc --- /dev/null +++ b/tests/devnet-config-metadata.test.ts @@ -0,0 +1,24 @@ +import fs from 'fs'; +import path from 'path'; +import toml, { JsonMap } from '@iarna/toml'; +import { getFixedArraySpec } from '../src/tui/devnet-config-metadata'; + +describe('devnet-config-metadata rpc.modules spec', () => { + it('offers the Terminal and RichIndexer modules in the config editor', () => { + const spec = getFixedArraySpec(['rpc', 'modules']); + expect(spec).not.toBeNull(); + expect(spec!.options).toContain('Terminal'); + expect(spec!.options).toContain('RichIndexer'); + }); + + it('covers every module enabled by the bundled devnet ckb.toml', () => { + const templatePath = path.resolve(__dirname, '..', 'ckb', 'devnet', 'ckb.toml'); + const parsed = toml.parse(fs.readFileSync(templatePath, 'utf8')); + const templateModules = (parsed.rpc as JsonMap).modules as string[]; + + const spec = getFixedArraySpec(['rpc', 'modules']); + for (const moduleName of templateModules) { + expect(spec!.options).toContain(moduleName); + } + }); +}); diff --git a/tests/devnet-fork.test.ts b/tests/devnet-fork.test.ts index 03feb6e7..a2c3f87b 100644 --- a/tests/devnet-fork.test.ts +++ b/tests/devnet-fork.test.ts @@ -179,6 +179,49 @@ describe('fork data isolation and migration preflight', () => { expect(fs.existsSync(path.join(target, 'data', 'tmp'))).toBe(false); }); + it('rejects symlinked top-level entries in the source chain data', () => { + if (process.platform === 'win32') return; + const source = path.join(root, 'source'); + const target = path.join(root, 'target'); + const outside = path.join(root, 'outside'); + fs.mkdirSync(path.join(source, 'data'), { recursive: true }); + fs.mkdirSync(outside, { recursive: true }); + fs.writeFileSync(path.join(outside, 'fixture'), 'secret'); + fs.symlinkSync(outside, path.join(source, 'data', 'db'), 'dir'); + + expect(() => copySourceData(source, target)).toThrow('symlinked entries are not allowed'); + expect(fs.existsSync(path.join(target, 'data', 'db', 'fixture'))).toBe(false); + }); + + it('rejects symlinks nested inside copied directories', () => { + if (process.platform === 'win32') return; + const source = path.join(root, 'source'); + const target = path.join(root, 'target'); + const outside = path.join(root, 'outside'); + fs.mkdirSync(path.join(source, 'data', 'db'), { recursive: true }); + fs.writeFileSync(path.join(source, 'data', 'db', 'fixture'), 'db'); + fs.mkdirSync(outside, { recursive: true }); + fs.writeFileSync(path.join(outside, 'fixture'), 'secret'); + fs.symlinkSync(path.join(outside, 'fixture'), path.join(source, 'data', 'db', 'linked')); + + expect(() => copySourceData(source, target)).toThrow('symlinked entries are not allowed'); + expect(fs.existsSync(path.join(target, 'data', 'db', 'linked'))).toBe(false); + }); + + it('rejects a symlinked data directory at the source root', () => { + if (process.platform === 'win32') return; + const source = path.join(root, 'source'); + const target = path.join(root, 'target'); + const outside = path.join(root, 'outside'); + fs.mkdirSync(source, { recursive: true }); + fs.mkdirSync(outside, { recursive: true }); + fs.writeFileSync(path.join(outside, 'fixture'), 'secret'); + fs.symlinkSync(outside, path.join(source, 'data'), 'dir'); + + expect(() => copySourceData(source, target)).toThrow('symlinked entries are not allowed'); + expect(fs.existsSync(path.join(target, 'data', 'fixture'))).toBe(false); + }); + it('forces forked nodes into an outbound-isolated network config', () => { const config = isolateForkCkbConfig({ network: { bootnodes: ['mainnet-peer'], max_outbound_peers: 8, discovery_local_address: true }, diff --git a/tests/fork-safety.test.ts b/tests/fork-safety.test.ts index 87d93b86..88861191 100644 --- a/tests/fork-safety.test.ts +++ b/tests/fork-safety.test.ts @@ -7,7 +7,11 @@ jest.mock('../src/devnet/fork', () => ({ readForkState: () => mockFork })); jest.mock('../src/util/logger', () => ({ logger: { warn: jest.fn() } })); import accountConfig from '../account/account.json'; -import { validateMainnetForkSigning, warnIfMainnetForkSigning } from '../src/util/fork-safety'; +import { + resolveMainnetForkOverride, + validateMainnetForkSigning, + warnIfMainnetForkSigning, +} from '../src/util/fork-safety'; import { logger } from '../src/util/logger'; import { Network } from '../src/type/base'; @@ -41,7 +45,7 @@ describe('Mainnet fork signing warning', () => { it('requires an explicit override for an external key', () => { mockFork = { source: 'mainnet', forkBlockNumber: '100' }; expect(() => validateMainnetForkSigning(Network.devnet, '0x' + '11'.repeat(32))).toThrow( - '--allow-mainnet-replay-risk', + '--allow-external-key-on-mainnet-fork', ); }); @@ -64,3 +68,19 @@ describe('Mainnet fork signing warning', () => { ); }); }); + +describe('deprecated --allow-mainnet-replay-risk alias', () => { + beforeEach(() => jest.clearAllMocks()); + + it('folds the deprecated flag into the new option with a warning', () => { + const options = resolveMainnetForkOverride({ allowMainnetReplayRisk: true }); + expect(options.allowExternalKeyOnMainnetFork).toBe(true); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('--allow-external-key-on-mainnet-fork')); + }); + + it('leaves options untouched when the deprecated flag is absent', () => { + const options = resolveMainnetForkOverride({ allowExternalKeyOnMainnetFork: true }); + expect(options.allowExternalKeyOnMainnetFork).toBe(true); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/init-chain.test.ts b/tests/init-chain.test.ts index f9205c75..c405f7b4 100644 --- a/tests/init-chain.test.ts +++ b/tests/init-chain.test.ts @@ -1,6 +1,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import toml, { JsonMap } from '@iarna/toml'; let mockConfigPath = ''; @@ -12,10 +13,32 @@ jest.mock('../src/cfg/setting', () => ({ })); jest.mock('../src/util/logger', () => ({ - logger: { debug: jest.fn(), error: jest.fn() }, + logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }, })); -import { initChainIfNeeded } from '../src/node/init-chain'; +import { initChainIfNeeded, migrateLegacyDevnetRpcConfig } from '../src/node/init-chain'; + +const LEGACY_CKB_TOML = `# legacy devnet config from before the ckb-tui fix +# a custom comment that must survive migration + +[rpc] +listen_address = "127.0.0.1:8114" + +# List of API modules: ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer"] +modules = ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer"] + +# By default RPC only binds to HTTP service, you can bind it to TCP and WebSocket. +# tcp_listen_address = "127.0.0.1:18114" +# ws_listen_address = "127.0.0.1:28114" +reject_ill_transactions = true + +[miner] +# keep this section untouched +`; + +function readCkbToml(configPath: string): JsonMap { + return toml.parse(fs.readFileSync(path.join(configPath, 'ckb.toml'), 'utf8')); +} describe('initChainIfNeeded', () => { let root: string; @@ -59,3 +82,127 @@ describe('initChainIfNeeded', () => { expect(fs.existsSync(path.join(mockConfigPath, 'specs', 'dev.toml'))).toBe(true); }); }); + +describe('migrateLegacyDevnetRpcConfig', () => { + let root: string; + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-migrate-rpc-')); + mockConfigPath = path.join(root, 'devnet'); + fs.mkdirSync(mockConfigPath, { recursive: true }); + }); + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + + it('enables the Terminal module and TCP stream on a legacy config, preserving comments', () => { + fs.writeFileSync(path.join(mockConfigPath, 'ckb.toml'), LEGACY_CKB_TOML); + + expect(migrateLegacyDevnetRpcConfig(mockConfigPath)).toBe(true); + + const text = fs.readFileSync(path.join(mockConfigPath, 'ckb.toml'), 'utf8'); + expect(text).toContain('# a custom comment that must survive migration'); + expect(text).not.toContain('# tcp_listen_address'); + const parsed = readCkbToml(mockConfigPath); + const rpc = parsed.rpc as JsonMap; + expect(rpc.modules).toContain('Terminal'); + expect(rpc.modules).toContain('Indexer'); + expect(rpc.tcp_listen_address).toBe('127.0.0.1:18114'); + expect(rpc.reject_ill_transactions).toBe(true); + }); + + it('runs through initChainIfNeeded so existing installs pick it up on node start', async () => { + fs.mkdirSync(path.join(mockConfigPath, 'specs'), { recursive: true }); + fs.writeFileSync(path.join(mockConfigPath, 'ckb.toml'), LEGACY_CKB_TOML); + fs.writeFileSync(path.join(mockConfigPath, 'ckb-miner.toml'), 'custom-miner'); + fs.writeFileSync(path.join(mockConfigPath, 'specs', 'dev.toml'), 'custom-spec'); + + await initChainIfNeeded(); + + const rpc = readCkbToml(mockConfigPath).rpc as JsonMap; + expect(rpc.modules).toContain('Terminal'); + expect(rpc.tcp_listen_address).toBe('127.0.0.1:18114'); + expect(fs.readFileSync(path.join(mockConfigPath, 'ckb-miner.toml'), 'utf8')).toBe('custom-miner'); + }); + + it('is a no-op on the current bundled template', async () => { + await initChainIfNeeded(); + const before = fs.readFileSync(path.join(mockConfigPath, 'ckb.toml'), 'utf8'); + + expect(migrateLegacyDevnetRpcConfig(mockConfigPath)).toBe(false); + expect(fs.readFileSync(path.join(mockConfigPath, 'ckb.toml'), 'utf8')).toBe(before); + }); + + it('appends Terminal without dropping a custom module subset', () => { + const custom = LEGACY_CKB_TOML.replace( + 'modules = ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer"]', + 'modules = ["Net", "Chain"]', + ); + fs.writeFileSync(path.join(mockConfigPath, 'ckb.toml'), custom); + + expect(migrateLegacyDevnetRpcConfig(mockConfigPath)).toBe(true); + + const rpc = readCkbToml(mockConfigPath).rpc as JsonMap; + expect(rpc.modules).toEqual(['Net', 'Chain', 'Terminal']); + }); + + it('handles a hand-formatted multi-line modules array', () => { + const multiline = LEGACY_CKB_TOML.replace( + 'modules = ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer"]', + 'modules = [\n "Net",\n "Indexer"\n]', + ); + fs.writeFileSync(path.join(mockConfigPath, 'ckb.toml'), multiline); + + expect(migrateLegacyDevnetRpcConfig(mockConfigPath)).toBe(true); + + const rpc = readCkbToml(mockConfigPath).rpc as JsonMap; + expect(rpc.modules).toEqual(['Net', 'Indexer', 'Terminal']); + }); + + it('inserts tcp_listen_address when no commented line exists', () => { + const noTcpStub = LEGACY_CKB_TOML.replace('# tcp_listen_address = "127.0.0.1:18114"\n', ''); + fs.writeFileSync(path.join(mockConfigPath, 'ckb.toml'), noTcpStub); + + expect(migrateLegacyDevnetRpcConfig(mockConfigPath)).toBe(true); + + const rpc = readCkbToml(mockConfigPath).rpc as JsonMap; + expect(rpc.tcp_listen_address).toBe('127.0.0.1:18114'); + }); + + it('never enables a commented non-loopback tcp_listen_address; adds the loopback default instead', () => { + const exposed = LEGACY_CKB_TOML.replace( + '# tcp_listen_address = "127.0.0.1:18114"', + '# tcp_listen_address = "0.0.0.0:18114"', + ); + fs.writeFileSync(path.join(mockConfigPath, 'ckb.toml'), exposed); + + expect(migrateLegacyDevnetRpcConfig(mockConfigPath)).toBe(true); + + const text = fs.readFileSync(path.join(mockConfigPath, 'ckb.toml'), 'utf8'); + expect(text).toContain('# tcp_listen_address = "0.0.0.0:18114"'); + expect(text).not.toMatch(/^tcp_listen_address\s*=\s*"0\.0\.0\.0/m); + const rpc = readCkbToml(mockConfigPath).rpc as JsonMap; + expect(rpc.tcp_listen_address).toBe('127.0.0.1:18114'); + }); + + it('keeps an explicitly configured tcp_listen_address', () => { + const custom = LEGACY_CKB_TOML.replace( + '# tcp_listen_address = "127.0.0.1:18114"', + 'tcp_listen_address = "127.0.0.1:29114"', + ); + fs.writeFileSync(path.join(mockConfigPath, 'ckb.toml'), custom); + + expect(migrateLegacyDevnetRpcConfig(mockConfigPath)).toBe(true); + + const rpc = readCkbToml(mockConfigPath).rpc as JsonMap; + expect(rpc.tcp_listen_address).toBe('127.0.0.1:29114'); + expect(rpc.modules).toContain('Terminal'); + }); + + it('ignores unparseable or section-less configs without throwing', () => { + fs.writeFileSync(path.join(mockConfigPath, 'ckb.toml'), 'custom-ckb'); + expect(migrateLegacyDevnetRpcConfig(mockConfigPath)).toBe(false); + expect(fs.readFileSync(path.join(mockConfigPath, 'ckb.toml'), 'utf8')).toBe('custom-ckb'); + }); + + it('returns false when ckb.toml does not exist', () => { + expect(migrateLegacyDevnetRpcConfig(mockConfigPath)).toBe(false); + }); +}); diff --git a/tests/node-command.test.ts b/tests/node-command.test.ts index c7807200..c3e189ba 100644 --- a/tests/node-command.test.ts +++ b/tests/node-command.test.ts @@ -3,7 +3,7 @@ import { Network } from '../src/type/base'; import * as path from 'path'; const mockSpawn = jest.fn(); -const mockExec = jest.fn(); +const mockExecFile = jest.fn(); const mockOpenSync = jest.fn(); const mockWriteFileSync = jest.fn(); const mockMkdirSync = jest.fn(); @@ -17,7 +17,7 @@ const mockWaitForNodeReady = jest.fn(); jest.mock('child_process', () => ({ ...jest.requireActual('child_process'), spawn: (...args: unknown[]) => mockSpawn(...args), - exec: (...args: unknown[]) => mockExec(...args), + execFile: (...args: unknown[]) => mockExecFile(...args), })); jest.mock('fs', () => ({ @@ -80,22 +80,23 @@ import { logger } from '../src/util/logger'; const dataPath = '/tmp/offckb-devnet-data'; const logDir = path.join(dataPath, 'logs'); const pidFile = path.join(logDir, 'daemon.pid'); -const logFile = path.join(logDir, 'daemon.log'); function mockDaemonCommandLine(scriptPath: string) { - mockExec.mockImplementation((cmd: string, callback: (err: Error | null, stdout?: string) => void) => { - if (cmd.startsWith('ps ')) { - callback(null, `/usr/bin/node ${scriptPath} node`); - return undefined as unknown as ReturnType; - } - if (cmd.startsWith('wmic ')) { - // WMIC returns key/value pairs, e.g. "CommandLine=..." - callback(null, `CommandLine=/usr/bin/node ${scriptPath} node`); - return undefined as unknown as ReturnType; - } - callback(null, ''); - return undefined as unknown as ReturnType; - }); + mockExecFile.mockImplementation( + (file: string, _args: string[], callback: (err: Error | null, stdout?: string) => void) => { + if (file === 'ps') { + callback(null, `/usr/bin/node ${scriptPath} node`); + return undefined as unknown as ReturnType; + } + if (file === 'wmic') { + // WMIC returns key/value pairs, e.g. "CommandLine=..." + callback(null, `CommandLine=/usr/bin/node ${scriptPath} node`); + return undefined as unknown as ReturnType; + } + callback(null, ''); + return undefined as unknown as ReturnType; + }, + ); } describe('node command daemon mode', () => { @@ -195,10 +196,12 @@ describe('node command daemon mode', () => { mockReadFileSync.mockReturnValue( JSON.stringify({ pid: 9999, scriptPath: '/path/to/offckb', startedAt: new Date().toISOString() }), ); - mockExec.mockImplementation((_cmd: string, callback: (err: Error | null, stdout?: string) => void) => { - callback(null, '/usr/bin/some-unrelated-process'); - return undefined as unknown as ReturnType; - }); + mockExecFile.mockImplementation( + (_file: string, _args: string[], callback: (err: Error | null, stdout?: string) => void) => { + callback(null, '/usr/bin/some-unrelated-process'); + return undefined as unknown as ReturnType; + }, + ); await startNode({ network: Network.devnet, daemon: true }); @@ -407,7 +410,7 @@ describe('node command stop', () => { jest.useFakeTimers(); jest.clearAllMocks(); processAlive = true; - mockExec.mockReset(); + mockExecFile.mockReset(); mockStatSync.mockReturnValue({ isFile: () => true }); mockReadFileSync.mockReturnValue(JSON.stringify({ pid: 12345, scriptPath, startedAt: new Date().toISOString() })); mockDaemonCommandLine(scriptPath); @@ -504,10 +507,12 @@ describe('node command stop', () => { }); it('refuses to kill a process that does not look like the daemon', async () => { - mockExec.mockImplementation((cmd: string, callback: (err: Error | null, stdout?: string) => void) => { - callback(null, '/usr/bin/some-other-process'); - return undefined as unknown as ReturnType; - }); + mockExecFile.mockImplementation( + (_file: string, _args: string[], callback: (err: Error | null, stdout?: string) => void) => { + callback(null, '/usr/bin/some-other-process'); + return undefined as unknown as ReturnType; + }, + ); await expect(stopNode()).rejects.toThrow('does not appear to be the offckb daemon'); diff --git a/tests/node-listener.test.ts b/tests/node-listener.test.ts new file mode 100644 index 00000000..1173e26b --- /dev/null +++ b/tests/node-listener.test.ts @@ -0,0 +1,112 @@ +const mockExecFileSync = jest.fn(); + +jest.mock('child_process', () => ({ + ...jest.requireActual('child_process'), + execFileSync: (...args: unknown[]) => mockExecFileSync(...args), +})); +jest.mock('../src/node/install', () => ({ installCKBBinary: jest.fn() })); +jest.mock('../src/node/init-chain', () => ({ initChainIfNeeded: jest.fn() })); +jest.mock('../src/cfg/setting', () => ({ + readSettings: () => ({ + bins: { defaultCKBVersion: '0.207.0' }, + devnet: { + configPath: '/tmp/offckb-devnet', + dataPath: '/tmp/offckb-devnet/data', + rpcUrl: 'http://127.0.0.1:8114', + rpcProxyPort: 28114, + }, + }), + getCKBBinaryPath: () => '/tmp/ckb', +})); +jest.mock('../src/devnet/fork', () => ({ readForkState: jest.fn(), markForkFirstRunComplete: jest.fn() })); +jest.mock('../src/util/json-rpc', () => ({ callJsonRpc: jest.fn() })); +jest.mock('../src/devnet/readiness', () => ({ checkNodeReadiness: jest.fn(), waitForNodeReady: jest.fn() })); +jest.mock('../src/tools/rpc-proxy', () => ({ createRPCProxy: jest.fn() })); +jest.mock('../src/util/logger', () => ({ + logger: { + success: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + result: jest.fn(), + }, +})); + +import { isProcessListeningOnPort } from '../src/cmd/node'; + +function lsofError(stderr: string, code?: string): Error & { stderr: Buffer; code?: string } { + const error = new Error('lsof failed') as Error & { stderr: Buffer; code?: string }; + error.stderr = Buffer.from(stderr); + error.code = code; + return error; +} + +describe('isProcessListeningOnPort', () => { + const realPlatform = process.platform; + + // The implementation short-circuits to null on win32 without invoking lsof; + // force a unix platform so the lsof-probing behavior is exercised on every + // CI OS, including the Windows runners. + beforeAll(() => Object.defineProperty(process, 'platform', { value: 'linux' })); + afterAll(() => Object.defineProperty(process, 'platform', { value: realPlatform })); + beforeEach(() => jest.clearAllMocks()); + + it('returns true when lsof finds the process listening', () => { + mockExecFileSync.mockReturnValue(Buffer.from('p1234')); + expect(isProcessListeningOnPort(1234, 8114)).toBe(true); + expect(mockExecFileSync).toHaveBeenCalledWith( + 'lsof', + ['-a', '-p', '1234', '-iTCP:8114', '-sTCP:LISTEN'], + expect.objectContaining({ timeout: 5000 }), + ); + }); + + it('returns null on Windows without probing lsof', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + expect(isProcessListeningOnPort(1234, 8114)).toBeNull(); + expect(mockExecFileSync).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(process, 'platform', { value: 'linux' }); + } + }); + + it('returns false when lsof reports no match (empty stderr)', () => { + mockExecFileSync.mockImplementation(() => { + throw lsofError(''); + }); + expect(isProcessListeningOnPort(1234, 8114)).toBe(false); + }); + + it('returns null when the lsof inspection itself failed (stderr output)', () => { + mockExecFileSync.mockImplementation(() => { + throw lsofError('lsof: permission denied\n'); + }); + expect(isProcessListeningOnPort(1234, 8114)).toBeNull(); + }); + + it('returns null when lsof is not installed', () => { + mockExecFileSync.mockImplementation(() => { + throw lsofError('spawn lsof ENOENT', 'ENOENT'); + }); + expect(isProcessListeningOnPort(1234, 8114)).toBeNull(); + }); + + it('returns null when lsof hangs and hits the probe timeout', () => { + mockExecFileSync.mockImplementation(() => { + throw lsofError('', 'ETIMEDOUT'); + }); + expect(isProcessListeningOnPort(1234, 8114)).toBeNull(); + }); + + it('bounds the lsof probe with a timeout', () => { + mockExecFileSync.mockReturnValue(Buffer.from('p1234')); + isProcessListeningOnPort(1234, 8114); + expect(mockExecFileSync).toHaveBeenCalledWith( + 'lsof', + ['-a', '-p', '1234', '-iTCP:8114', '-sTCP:LISTEN'], + expect.objectContaining({ timeout: 5000 }), + ); + }); +}); diff --git a/tests/status.test.ts b/tests/status.test.ts index 5a532ab4..8786d9c2 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -5,14 +5,23 @@ jest.mock('../src/tools/ckb-tui', () => ({ CKBTui: { run: (...args: unknown[]) = jest.mock('../src/devnet/readiness', () => ({ checkNodeReadiness: (...args: unknown[]) => mockCheckNodeReadiness(...args), })); + +const mockSettings: { + devnet: Record; + testnet: Record; + mainnet: Record; +} = { + devnet: { rpcProxyPort: 28114 }, + testnet: { rpcProxyPort: 38114 }, + mainnet: { rpcProxyPort: 48114 }, +}; jest.mock('../src/cfg/setting', () => ({ - readSettings: () => ({ - devnet: { rpcProxyPort: 28114 }, - testnet: { rpcProxyPort: 38114 }, - mainnet: { rpcProxyPort: 48114 }, - }), + readSettings: () => mockSettings, })); +import fs from 'fs'; +import os from 'os'; +import path from 'path'; import { status } from '../src/cmd/status'; import { Network } from '../src/type/base'; @@ -22,6 +31,7 @@ describe('status command', () => { beforeEach(() => { jest.clearAllMocks(); + mockSettings.devnet = { rpcProxyPort: 28114 }; Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true }); Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true }); }); @@ -31,6 +41,13 @@ describe('status command', () => { Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: originalStdinTTY }); }); + function useDevnetConfig(ckbToml: string): string { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-status-test-')); + fs.writeFileSync(path.join(configDir, 'ckb.toml'), ckbToml); + mockSettings.devnet = { rpcProxyPort: 28114, configPath: configDir }; + return configDir; + } + it('launches ckb-tui only after a real JSON-RPC health check', async () => { mockCheckNodeReadiness.mockResolvedValue({ ready: true }); mockRun.mockReturnValue({ status: 0 }); @@ -55,4 +72,36 @@ describe('status command', () => { mockRun.mockReturnValue({ status: 7 }); await expect(status({ network: Network.devnet })).rejects.toThrow('ckb-tui exited with code 7'); }); + + it('passes the devnet node TCP subscription endpoint to ckb-tui', async () => { + useDevnetConfig('[rpc]\ntcp_listen_address = "127.0.0.1:18114"\n'); + mockCheckNodeReadiness.mockResolvedValue({ ready: true }); + mockRun.mockReturnValue({ status: 0 }); + await status({ network: Network.devnet }); + expect(mockRun).toHaveBeenCalledWith(['-r', 'http://127.0.0.1:28114', '-t', '127.0.0.1:18114']); + }); + + it('dials localhost when the node binds the TCP service to a wildcard address', async () => { + useDevnetConfig('[rpc]\ntcp_listen_address = "0.0.0.0:18114"\n'); + mockCheckNodeReadiness.mockResolvedValue({ ready: true }); + mockRun.mockReturnValue({ status: 0 }); + await status({ network: Network.devnet }); + expect(mockRun).toHaveBeenCalledWith(['-r', 'http://127.0.0.1:28114', '-t', '127.0.0.1:18114']); + }); + + it('omits -t when the devnet config has no TCP listener', async () => { + useDevnetConfig('[rpc]\nmodules = ["Net"]\n'); + mockCheckNodeReadiness.mockResolvedValue({ ready: true }); + mockRun.mockReturnValue({ status: 0 }); + await status({ network: Network.devnet }); + expect(mockRun).toHaveBeenCalledWith(['-r', 'http://127.0.0.1:28114']); + }); + + it('never passes -t for proxied public networks', async () => { + useDevnetConfig('[rpc]\ntcp_listen_address = "127.0.0.1:18114"\n'); + mockCheckNodeReadiness.mockResolvedValue({ ready: true }); + mockRun.mockReturnValue({ status: 0 }); + await status({ network: Network.testnet }); + expect(mockRun).toHaveBeenCalledWith(['-r', 'http://127.0.0.1:38114']); + }); }); 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');