diff --git a/.changeset/fiber-devnet.md b/.changeset/fiber-devnet.md new file mode 100644 index 00000000..52234e6c --- /dev/null +++ b/.changeset/fiber-devnet.md @@ -0,0 +1,5 @@ +--- +'@offckb/cli': minor +--- + +Add Fiber (FNN) support to the local devnet. The devnet genesis now carries the Fiber contracts `auth`, `funding_lock` and `commitment_lock` (pinned to the FNN v0.9.0-rc7 source), and a new `offckb fiber` command family manages a local Fiber environment: `offckb node --fiber` starts CKB, miner, RPC proxy and FNN nodes in one go, while `offckb fiber start` adds FNN nodes to an already-running devnet (with `--daemon`/`offckb fiber stop` for background operation). Each node gets its own built-in CKB account (node N → account N+2), network identity, RPC port 21713+N, P2P port 8343+N and a `fnn.log`; `offckb fiber status [--json]` reports live node health, `offckb fiber logs --node ` reads node logs, and `offckb fiber clean [--data]` removes stores or the whole fiber environment. Startup verifies the devnet spec, CKB and every FNN agree on the same chain and checks node identity keys, funding accounts and balances. Plain local devnet only — mainnet/testnet and forked devnets are rejected. `offckb clean` now also removes fiber stores with `--data` and refuses to delete data while a CKB/fiber daemon or a live FNN store lock can be confirmed. New devnets only: devnets initialized by earlier offckb versions lack the Fiber contracts and must be rebuilt (`offckb clean`) to use Fiber, which changes the genesis hash. diff --git a/.gitmodules b/.gitmodules index ac05c35c..9197c065 100644 --- a/.gitmodules +++ b/.gitmodules @@ -25,3 +25,6 @@ [submodule "ckb/ckb-system-scripts"] path = ckb/ckb-system-scripts url = https://github.com/nervosnetwork/ckb-system-scripts.git +[submodule "ckb/fiber"] + path = ckb/fiber + url = https://github.com/nervosnetwork/fiber.git diff --git a/Makefile b/Makefile index 9543ece4..8f70436e 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ -.PHONY: all omnilock anyone-can-pay xudt spore ckb-js-vm nostr-lock ckb-debugger apply-debugger-patches clean-debugger-patches +.PHONY: all omnilock anyone-can-pay xudt spore ckb-js-vm nostr-lock fiber ckb-debugger apply-debugger-patches clean-debugger-patches -all: omnilock anyone-can-pay xudt spore ckb-js-vm nostr-lock pw-lock secp256k1_multisig_v2 ckb-debugger +all: omnilock anyone-can-pay xudt spore ckb-js-vm nostr-lock pw-lock secp256k1_multisig_v2 fiber ckb-debugger omnilock: @echo "Building omnilock via submodule" @@ -53,6 +53,24 @@ secp256k1_multisig_v2: cd ckb/ckb-system-scripts/ && make all-via-docker cp ckb/ckb-system-scripts/specs/cells/secp256k1_blake160_multisig_all ckb/devnet/specs/secp256k1_blake160_multisig_all_v2 +# Fiber contracts are copied (not rebuilt) from the pinned ckb/fiber submodule +# (FNN v0.9.0-rc7, fiber commit bc361aa) and committed under +# ckb/devnet/specs/fiber/ so published packages work offline; re-run this +# target after re-pinning the submodule. The upstream binaries embed the +# builder's home directory in panic metadata, so the copies are sanitized +# below with equal-length replacements (contract logic is untouched). +fiber: + @echo "Copying Fiber contracts via submodule" + @test -d ckb/fiber/tests/deploy/contracts || \ + (echo "ckb/fiber submodule is missing. Run: git submodule update --init ckb/fiber" && exit 1) + mkdir -p ckb/devnet/specs/fiber + cp ckb/fiber/tests/deploy/contracts/auth ckb/devnet/specs/fiber/auth + cp ckb/fiber/tests/deploy/contracts/funding-lock ckb/devnet/specs/fiber/funding_lock + cp ckb/fiber/tests/deploy/contracts/commitment-lock ckb/devnet/specs/fiber/commitment_lock + cp ckb/fiber/config/testnet/config.yml ckb/devnet/specs/fiber/testnet-config.yml + perl -pi -e 's{/home/quake/}{/home/fiber/}g' ckb/devnet/specs/fiber/funding_lock + perl -pi -e 's{/Users/quake/}{/Users/fiber/}g' ckb/devnet/specs/fiber/commitment_lock + ckb-debugger: @echo "Building ckb-debugger via submodule" @echo "Applying patches to ckb-standalone-debugger..." diff --git a/README.md b/README.md index a3eb7a64..40787d81 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,11 @@ Options: Commands: node [CKB-Version] Use the CKB to start devnet node stop Stop the running CKB devnet daemon + fiber start [FNN-Version] Start Fiber (FNN) nodes on the running devnet CKB + fiber stop Stop the daemon-managed fiber nodes + fiber status Show the status of the local CKB and all fiber nodes + fiber logs --node Show the log of a fiber node + fiber clean Clean the fiber environment create [options] [project-name] Create a new CKB Smart Contract project in JavaScript. deploy [options] Deploy contracts to different networks, only supports devnet and testnet debug [options] Quickly debug transaction with tx-hash @@ -435,6 +440,38 @@ On a forked devnet, `offckb system-scripts`, transfers, deploys and `offckb debu `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.) +### 8. Run a Fiber Devnet {#fiber-devnet} + +OffCKB can start and manage a local [Fiber](https://github.com/nervosnetwork/fiber) development environment on top of the devnet: the Fiber contracts (`auth`, `funding_lock`, `commitment_lock`) live in the local chain's genesis block, and each FNN node gets its own CKB account, network identity, ports, data and log file. + +```sh +# Start CKB, miner, RPC proxy and 2 FNN nodes in one command +offckb node --fiber + +# Or start only the FNN nodes on an already-running devnet +offckb node +offckb fiber start + +# Background mode +offckb node --fiber --daemon # one manager for CKB + FNNs, stopped by `offckb node stop` +offckb fiber start --daemon # separate fiber manager, stopped by `offckb fiber stop` + +# Inspect +offckb fiber status [--json] +offckb fiber logs --node 1 [-f] + +# Clean up +offckb fiber clean --data # delete only the FNN stores (channels/payments) +offckb fiber clean # delete the whole fiber environment +``` + +- Only the plain local devnet is supported: no mainnet/testnet, and no forked devnet (a `fork.json` present in the devnet directory rejects Fiber startup). +- Node `N` uses built-in CKB account `N+2` (accounts 3-18 are reserved for Fiber), RPC port `21713+N` and P2P port `8343+N`. Up to 16 nodes: `offckb fiber start --nodes 4`. +- `offckb fiber start [FNN-Version]` downloads a tested FNN release (currently `0.9.0-rc7`). Use `--binary-path ` (or `--fnn-binary-path ` with `node --fiber`) to run a locally built FNN. +- Every FNN writes its stdout/stderr to `devnet/fiber/nodes//fnn.log`, never to your terminal. Per-node FNN config overrides live in `devnet/fiber/nodes.yml` (regenerated `config.yml` files do not keep hand edits). +- Startup verifies that the devnet spec, the running CKB and every FNN agree on the same chain (genesis hash), and checks each node's identity key, CKB account and available balance before reporting ready. +- UDT channels: the FNN config whitelists the devnet sUDT and xUDT issued by built-in account 19, so issue test UDTs from that account (`offckb udt issue ... --privkey-file` with account 19's key) to the node accounts before opening UDT channels. + ## Config Setting ### List All Settings @@ -492,6 +529,8 @@ LOG_LEVEL=debug offckb node - version: 1.0.0 - [x] Nostr-Lock https://github.com/cryptape/nostr-binding/tree/main/contracts/nostr-lock - version: 25dd59d +- [x] Fiber (auth / funding-lock / commitment-lock) https://github.com/nervosnetwork/fiber + - commit id: bc361aa (FNN v0.9.0-rc7) - [x] Type ID built-in ## Accounts diff --git a/ckb/devnet/specs/dev.toml b/ckb/devnet/specs/dev.toml index c3b8f84f..ee79174f 100644 --- a/ckb/devnet/specs/dev.toml +++ b/ckb/devnet/specs/dev.toml @@ -79,6 +79,15 @@ create_type_id = false [[genesis.system_cells]] file = { file = "secp256k1_blake160_multisig_all_v2" } create_type_id = false +[[genesis.system_cells]] +file = { file = "fiber/auth" } +create_type_id = false +[[genesis.system_cells]] +file = { file = "fiber/funding_lock" } +create_type_id = false +[[genesis.system_cells]] +file = { file = "fiber/commitment_lock" } +create_type_id = false [genesis.system_cells_lock] code_hash = "0x0000000000000000000000000000000000000000000000000000000000000000" diff --git a/ckb/devnet/specs/fiber/auth b/ckb/devnet/specs/fiber/auth new file mode 100755 index 00000000..4c4b885b Binary files /dev/null and b/ckb/devnet/specs/fiber/auth differ diff --git a/ckb/devnet/specs/fiber/commitment_lock b/ckb/devnet/specs/fiber/commitment_lock new file mode 100755 index 00000000..698ef3bc Binary files /dev/null and b/ckb/devnet/specs/fiber/commitment_lock differ diff --git a/ckb/devnet/specs/fiber/funding_lock b/ckb/devnet/specs/fiber/funding_lock new file mode 100755 index 00000000..46c8b604 Binary files /dev/null and b/ckb/devnet/specs/fiber/funding_lock differ diff --git a/ckb/devnet/specs/fiber/testnet-config.yml b/ckb/devnet/specs/fiber/testnet-config.yml new file mode 100644 index 00000000..d87c471d --- /dev/null +++ b/ckb/devnet/specs/fiber/testnet-config.yml @@ -0,0 +1,104 @@ +# This configuration file only contains the necessary configurations for the testnet deployment. +# All options' descriptions can be found via `fnn --help` and be overridden by command line arguments or environment variables. +fiber: + listening_addr: "/ip4/0.0.0.0/tcp/8228" + # Node name announced to the Fiber network. It is shown in RPC responses, + # the TUI header, and the network graph. + # announced_node_name: "my-fiber-node" + # Disable automatic peer reconnect backoff after disconnects. + # enable_peer_reconnect_backoff: true + bootnode_addrs: + - "/ip4/54.179.226.154/tcp/8228/p2p/Qmes1EBD4yNo9Ywkfe6eRw9tG1nVNGLDmMud1xJMsoYFKy" + - "/ip4/16.163.7.105/tcp/8228/p2p/QmdyQWjPtbK4NWWsvy8s69NGJaQULwgeQDT5ZpNDrTNaeV" + announce_listening_addr: true + announced_addrs: + # If you want to announce your fiber node public address to the network, you need to add the address here, please change the ip to your public ip accordingly. + # - "/ip4/YOUR-FIBER-NODE-PUBLIC-IP/tcp/8228" + chain: testnet + + ## SOCKS5 proxy settings + ## Uncomment to route all outbound P2P connections through a SOCKS5 proxy (e.g. Tor). + # proxy: + # proxy_url: "socks5://127.0.0.1:9050" + # ## Use random username/password for each proxy connection to improve Tor stream isolation. [default: true] + # proxy_random_auth: true + + ## Tor onion hidden service settings + ## Uncomment to make this node reachable via a .onion address. + ## Requires a running Tor daemon with ControlPort enabled. + # onion: + # listen_on_onion: false + # ## Tor SOCKS5 proxy url for routing .onion address connections. e.g. 127.0.0.1:9050 + # onion_server: "127.0.0.1:9050" + # ## The local address that the onion service forwards traffic to. + # ## If not set, it is derived from listening_addr. e.g. "127.0.0.1:8228" + # p2p_listen_address: "127.0.0.1:8228" + # ## Path to store the onion service private key. [default: $BASE_DIR/fiber/onion_private_key] + # onion_private_key_path: "" + # ## Tor controller address. [default: 127.0.0.1:9051] + # tor_controller: "127.0.0.1:9051" + # ## Tor controller hashed password (if HashedControlPassword is set in torrc). + # tor_password: "" + # ## The external port exposed by the onion service. [default: 8228] + # onion_external_port: 8228 + # ## Maximum time in seconds to wait for the onion service to register with Tor. [default: 5] + # onion_service_start_timeout: 5 + # lock script configurations related to fiber network + # https://github.com/nervosnetwork/fiber-scripts/blob/main/deployment/testnet/migrations/2025-02-28-111246.json + scripts: + - name: FundingLock + script: + code_hash: 0x6c67887fe201ee0c7853f1682c0b77c0e6214044c156c7558269390a8afa6d7c + hash_type: type + args: 0x + cell_deps: + - type_id: + code_hash: 0x00000000000000000000000000000000000000000000000000545950455f4944 + hash_type: type + args: 0x3cb7c0304fe53f75bb5727e2484d0beae4bd99d979813c6fc97c3cca569f10f6 + - cell_dep: + out_point: + tx_hash: 0x12c569a258dd9c5bd99f632bb8314b1263b90921ba31496467580d6b79dd14a7 # ckb_auth + index: 0x0 + dep_type: code + - name: CommitmentLock + script: + code_hash: 0x740dee83f87c6f309824d8fd3fbdd3c8380ee6fc9acc90b1a748438afcdf81d8 + hash_type: type + args: 0x + cell_deps: + - type_id: + code_hash: 0x00000000000000000000000000000000000000000000000000545950455f4944 + hash_type: type + args: 0xf7e458887495cf70dd30d1543cad47dc1dfe9d874177bf19291e4db478d5751b + - cell_dep: + out_point: + tx_hash: 0x12c569a258dd9c5bd99f632bb8314b1263b90921ba31496467580d6b79dd14a7 #ckb_auth + index: 0x0 + dep_type: code + +rpc: + # By default RPC only binds to localhost, thus it only allows accessing from the same machine. + # Allowing arbitrary machines to access the JSON-RPC port is dangerous and strongly discouraged. + # Please strictly limit the access to only trusted machines. + listening_addr: "127.0.0.1:8227" + +ckb: + rpc_url: "https://testnet.ckbapp.dev/" + udt_whitelist: + - name: RUSD + script: + code_hash: 0x1142755a044bf2ee358cba9f2da187ce928c91cd4dc8692ded0337efa677d21a + hash_type: type + args: 0x878fcc6f1f08d48e87bb1c3b3d5083f23f8a39c5d5c764f253b55b998526439b + cell_deps: + - type_id: + code_hash: 0x00000000000000000000000000000000000000000000000000545950455f4944 + hash_type: type + args: 0x97d30b723c0b2c66e9cb8d4d0df4ab5d7222cbb00d4a9a2055ce2e5d7f0d8b0f + auto_accept_amount: 1000000000 + +services: + - fiber + - rpc + - ckb diff --git a/ckb/fiber b/ckb/fiber new file mode 160000 index 00000000..bc361aaa --- /dev/null +++ b/ckb/fiber @@ -0,0 +1 @@ +Subproject commit bc361aaaa40d1394b83e6a1808869b0b06c48c13 diff --git a/package.json b/package.json index 420628d4..572118dc 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@types/adm-zip": "^0.5.5", "@types/blessed": "0.1.27", "@types/jest": "^30.0.0", + "@types/js-yaml": "^4.0.9", "@types/node": "^20.17.24", "@types/node-fetch": "^2.6.11", "@types/semver": "^7.5.7", @@ -84,6 +85,7 @@ "commander": "^12.0.0", "http-proxy": "^1.18.1", "https-proxy-agent": "^7.0.5", + "js-yaml": "4.3.0", "node-fetch": "2", "semver": "^7.6.0", "tar": "^7.5.19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac67fd19..d49be618 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ importers: https-proxy-agent: specifier: ^7.0.5 version: 7.0.6 + js-yaml: + specifier: 4.3.0 + version: 4.3.0 node-fetch: specifier: '2' version: 2.7.0 @@ -80,6 +83,9 @@ importers: '@types/jest': specifier: ^30.0.0 version: 30.0.0 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/node': specifier: ^20.17.24 version: 20.17.24 @@ -859,6 +865,9 @@ packages: '@types/jest@30.0.0': resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -4166,6 +4175,8 @@ snapshots: expect: 30.2.0 pretty-format: 30.2.0 + '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} '@types/node-fetch@2.6.12': diff --git a/src/cfg/setting.ts b/src/cfg/setting.ts index f96de691..4990d88e 100644 --- a/src/cfg/setting.ts +++ b/src/cfg/setting.ts @@ -29,6 +29,7 @@ export interface Settings { bins: { rootFolder: string; defaultCKBVersion: string; + defaultFnnVersion: string; downloadPath: string; }; devnet: { @@ -67,6 +68,7 @@ export const defaultSettings: Settings = { bins: { rootFolder: path.resolve(dataPath, 'bins'), defaultCKBVersion: '0.208.0', + defaultFnnVersion: '0.9.0-rc7', downloadPath: path.resolve(cachePath, 'download'), }, devnet: { diff --git a/src/cli.ts b/src/cli.ts index 5015a66b..8b4a48c9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,6 +18,16 @@ import { debugSingleScript, debugTransaction, parseSingleScriptOption } from './ import { logsCommand, LogsOptions } from './cmd/logs'; import { printSystemScripts } from './cmd/system-scripts'; import { transferAll } from './cmd/transfer-all'; +import { + fiberCleanCommand, + fiberLogs, + fiberStart, + fiberStatusCommand, + fiberStopCommand, + FiberLogsOptions, + FiberStartOptions, +} from './cmd/fiber'; +import { FiberCleanOptions } from './fiber/clean'; import { genSystemScriptsJsonFile } from './scripts/gen'; import { CKBDebugger } from './tools/ckb-debugger'; import { resolveMainnetForkOverride } from './util/fork-safety'; @@ -81,10 +91,29 @@ const nodeCommand = program '--verbose', 'Print the full raw node/miner output (default shows lifecycle events, script output, tx hashes, and RPC errors)', ) + .option('--fiber', 'Also start Fiber (FNN) nodes on the devnet (plain local chain only)') + .option('--fnn-version ', 'Specify the FNN version to use with --fiber') + .option('--fnn-binary-path ', 'Specify a locally built FNN binary to use with --fiber') + .option('--fiber-nodes ', 'Number of FNN nodes to start with --fiber (1-16, default 2)', (value: string) => { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 16) { + throw new InvalidArgumentError('--fiber-nodes must be an integer between 1 and 16'); + } + return parsed; + }) .action( async ( version: string, - options: { network: Network; binaryPath?: string; daemon?: boolean; verbose?: boolean }, + options: { + network: Network; + binaryPath?: string; + daemon?: boolean; + verbose?: boolean; + fiber?: boolean; + fnnVersion?: string; + fiberNodes?: number; + fnnBinaryPath?: string; + }, ) => { return startNode({ version, @@ -92,6 +121,10 @@ const nodeCommand = program binaryPath: options.binaryPath, daemon: options.daemon, verbose: options.verbose, + fiber: options.fiber, + fnnVersion: options.fnnVersion, + fiberNodes: options.fiberNodes, + fnnBinaryPath: options.fnnBinaryPath, }); }, ); @@ -101,6 +134,61 @@ nodeCommand .description('Stop the running CKB devnet daemon') .action(async () => stopNode()); +const fiberCommand = program.command('fiber').description('Manage Fiber (FNN) nodes on the local devnet'); + +fiberCommand + .command('start [FNN-Version]') + .description('Start Fiber (FNN) nodes on the running devnet CKB') + .option('--nodes ', 'Total number of FNN nodes (1-16, default 2)', (value: string) => { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 16) { + throw new InvalidArgumentError('--nodes must be an integer between 1 and 16'); + } + return parsed; + }) + .option('-b, --binary-path ', 'Specify a locally built FNN binary path to use') + .option('--daemon', 'Run the fiber nodes in the background as a daemon') + .action(async (version: string | undefined, options: FiberStartOptions) => { + return fiberStart(version, options); + }); + +fiberCommand + .command('stop') + .description('Stop the daemon-managed fiber nodes') + .action(async () => fiberStopCommand()); + +fiberCommand + .command('status') + .description('Show the status of the local CKB and all fiber nodes') + .action(async () => fiberStatusCommand()); + +fiberCommand + .command('logs') + .description('Show the log of a fiber node') + .requiredOption('--node ', 'Which fiber node to read logs from', (value: string) => { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new InvalidArgumentError('--node must be a positive integer'); + } + return parsed; + }) + .option('-f, --follow', 'Stream new log lines as they are written (like tail -f)') + .option('--tail ', 'Show the last N lines before following', (value: string) => { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new InvalidArgumentError('--tail must be a non-negative integer'); + } + return parsed; + }) + .action((options: FiberLogsOptions) => fiberLogs(options)); + +fiberCommand + .command('clean') + .description('Clean the fiber environment (does not touch the devnet CKB config or chain data)') + .option('-d, --data', 'Only remove the FNN stores, keep node accounts, identity keys and configs') + .option('-y, --yes', 'Skip the confirmation prompt') + .action(async (options: FiberCleanOptions) => fiberCleanCommand(options)); + program .command('logs') .description('Show devnet logs: node (default), contract script debug output, miner, or RPC proxy events') diff --git a/src/cmd/clean.ts b/src/cmd/clean.ts index a7c5738c..9a2c8186 100644 --- a/src/cmd/clean.ts +++ b/src/cmd/clean.ts @@ -2,41 +2,85 @@ import fs from 'fs'; import { isFolderExists } from '../util/fs'; import { readSettings } from '../cfg/setting'; import { logger } from '../util/logger'; +import { isProcessAlive, nodeDaemonPaths, readPidFile } from '../util/daemon'; +import { acquireEnvLock } from '../fiber/env-lock'; +import { assertFiberFullyStopped } from '../fiber/clean'; +import { fiberNodeIds, fiberNodePaths } from '../fiber/paths'; +import { removeRuntimeFileIfStale } from '../fiber/runtime'; export interface CleanOptions { data?: boolean; } +function assertCkbDaemonStopped() { + const pidFile = nodeDaemonPaths(readSettings()).pidFile; + const metadata = readPidFile(pidFile); + if (metadata && Number.isInteger(metadata.pid) && metadata.pid > 0 && isProcessAlive(metadata.pid)) { + throw new Error( + `The CKB devnet daemon is still running (PID ${metadata.pid}). Stop it first with: offckb node stop`, + ); + } +} + +function fiberStoreDirs(settings: ReturnType): string[] { + return fiberNodeIds(settings) + .map((id) => fiberNodePaths(id, settings).fiberStoreDir) + .filter((storeDir) => isFolderExists(storeDir)); +} + export function clean(options?: CleanOptions) { const settings = readSettings(); const allDevnetDataPath = settings.devnet.configPath; const dataOnly = options?.data || false; - if (dataOnly) { - // Only clean the chain data subdirectory - const chainDataPath = settings.devnet.dataPath; - if (isFolderExists(chainDataPath)) { - try { - fs.rmSync(chainDataPath, { recursive: true }); - logger.info(`Chain data cleaned. Devnet config files preserved.`); - } catch (error: unknown) { - throw new Error(`Failed to clean chain data. Did you stop the chain first? ${(error as Error).message}`); + // The environment lock lives next to the devnet directory, so it can be + // held while the whole devnet tree (including every fiber store) is + // deleted; other OffCKB processes stay out for the whole operation. + const lock = acquireEnvLock(dataOnly ? 'offckb clean --data' : 'offckb clean'); + try { + assertCkbDaemonStopped(); + // Any fiber data being removed requires every FNN stopped; refusing when + // that cannot be confirmed is cheaper than corrupting a live store. + assertFiberFullyStopped(settings); + + if (dataOnly) { + // Only clean the chain data subdirectory + const chainDataPath = settings.devnet.dataPath; + if (isFolderExists(chainDataPath)) { + try { + fs.rmSync(chainDataPath, { recursive: true }); + logger.info(`Chain data cleaned. Devnet config files preserved.`); + } catch (error: unknown) { + throw new Error(`Failed to clean chain data. Did you stop the chain first? ${(error as Error).message}`); + } + } else { + logger.info(`Nothing to clean. Chain data directory ${chainDataPath} not found.`); } - } else { - logger.info(`Nothing to clean. Chain data directory ${chainDataPath} not found.`); - } - } else { - // Clean everything - the original behavior - // this is the root folder of devnet, it contains config, data, debugFullTransactions, transactions, failed-transactions, contracts - if (isFolderExists(allDevnetDataPath)) { - try { - fs.rmSync(allDevnetDataPath, { recursive: true }); - logger.info(`Chain data cleaned.`); - } catch (error: unknown) { - throw new Error(`Failed to clean devnet data. Did you stop the chain first? ${(error as Error).message}`); + + // Fiber stores (channels, payments, runtime records) can no longer map + // onto the reset chain and are removed too; node configs, keys and + // passwords are kept. + removeRuntimeFileIfStale(settings); + for (const storeDir of fiberStoreDirs(settings)) { + fs.rmSync(storeDir, { recursive: true, force: true }); + logger.info(`Fiber store cleaned: ${storeDir}`); } } else { - logger.info(`Nothing to clean. Devnet data directory ${allDevnetDataPath} not found.`); + // Clean everything - the original behavior + // this is the root folder of devnet, it contains config, data, debugFullTransactions, transactions, failed-transactions, contracts + // and the whole fiber environment (configs, keys, stores, logs) + if (isFolderExists(allDevnetDataPath)) { + try { + fs.rmSync(allDevnetDataPath, { recursive: true }); + logger.info(`Chain data cleaned.`); + } catch (error: unknown) { + throw new Error(`Failed to clean devnet data. Did you stop the chain first? ${(error as Error).message}`); + } + } else { + logger.info(`Nothing to clean. Devnet data directory ${allDevnetDataPath} not found.`); + } } + } finally { + lock.release(); } } diff --git a/src/cmd/config.ts b/src/cmd/config.ts index ab1f5dca..f4276436 100644 --- a/src/cmd/config.ts +++ b/src/cmd/config.ts @@ -13,6 +13,7 @@ export enum ConfigAction { export enum ConfigItem { proxy = 'proxy', ckbVersion = 'ckb-version', + fnnVersion = 'fnn-version', } export async function Config(action: ConfigAction, item: ConfigItem, value?: string) { @@ -38,6 +39,12 @@ export async function Config(action: ConfigAction, item: ConfigItem, value?: str return logger.info(`${version}`); } + case ConfigItem.fnnVersion: { + const settings = readSettings(); + const version = settings.bins.defaultFnnVersion; + return logger.info(`${version}`); + } + default: break; } @@ -73,6 +80,18 @@ export async function Config(action: ConfigAction, item: ConfigItem, value?: str return writeSettings(settings); } + case ConfigItem.fnnVersion: { + if (!isValidVersion(value)) { + throw new Error( + `invalid version value, ${value}. Check available versions on https://github.com/nervosnetwork/fiber/tags`, + ); + } + const settings = readSettings(); + const version = extractVersion(value!); + settings.bins.defaultFnnVersion = version; + return writeSettings(settings); + } + default: break; } diff --git a/src/cmd/fiber.ts b/src/cmd/fiber.ts new file mode 100644 index 00000000..4daf445d --- /dev/null +++ b/src/cmd/fiber.ts @@ -0,0 +1,201 @@ +import { readSettings, Settings } from '../cfg/setting'; +import { logger } from '../util/logger'; +import { assertPlainDevnet, assertCkbEnvReadyForFiber } from '../fiber/ckb-env'; +import { acquireEnvLock } from '../fiber/env-lock'; +import { resolveFnnBinary } from '../fiber/install'; +import { resolveFiberChainScripts, FiberContractsMissingError } from '../fiber/scripts'; +import { startFiberEnvironment, stopFiberNodes, FiberEnvironment } from '../fiber/manager'; +import { startFiberDaemon, stopFiber } from '../fiber/daemon'; +import { fiberStatus } from '../fiber/status'; +import { fiberClean, FiberCleanOptions } from '../fiber/clean'; +import { fiberAccountIndex, fiberNodePaths, FIBER_DAEMON_PID_FILE, fiberDaemonPaths } from '../fiber/paths'; +import { readNodesYml } from '../fiber/nodes-yml'; +import { readLogTail, followLogFile } from '../devnet/log-file'; +import { cleanupPidFile } from '../util/daemon'; +import * as fs from 'fs'; + +export interface FiberStartOptions { + nodes?: number; + binaryPath?: string; + daemon?: boolean; +} + +function fiberDaemonChildArgs(): string[] { + return process.argv.slice(2).filter((arg) => arg !== '--daemon'); +} + +function logMissingContractsGuidance(error: FiberContractsMissingError) { + logger.error(error.message); + logger.error( + 'The current devnet was initialized without the Fiber contracts. To rebuild it: stop CKB and all FNNs, ' + + 'then run `offckb clean` and start again. WARNING: `offckb clean` deletes the local chain data, ' + + 'all Fiber channels and all node data.', + ); +} + +export function printFiberSummary(env: FiberEnvironment) { + logger.success(`Fiber environment is ready (${env.nodes.length} node(s)).`); + for (const node of env.nodes) { + const info = env.nodeInfos.get(node.id); + const version = info ? `${info.version} (${(info.commit_hash || '').slice(0, 7) || 'unknown commit'})` : 'unknown'; + logger.info( + ` node ${node.id}: FNN ${version}, RPC ${node.rpcUrl}, account #${fiberAccountIndex(node.id)}, log: ${node.logFile}`, + ); + } +} + +/** + * Keep the current process managing the FNN children until one of them exits + * or a stop signal arrives. An unexpected child exit stops the rest of the + * group; a signal stops the children, drops runtime.json and exits. + */ +export async function superviseFiberNodes( + env: FiberEnvironment, + settings: Settings, + extraCleanup?: () => void, +): Promise { + let stopping = false; + const stopAll = async (reason: string, exitCode: number): Promise => { + if (stopping) { + // A second FNN exit while we are already stopping: nothing more to do. + return new Promise(() => {}); + } + stopping = true; + if (reason) logger.error(reason); + await stopFiberNodes(env.nodes, settings); + if (process.env.OFFCKB_DAEMON_CHILD === '1') { + cleanupPidFile(fiberDaemonPaths(settings).pidFile); + } + extraCleanup?.(); + process.exit(exitCode); + }; + + for (const node of env.nodes) { + node.process.once('exit', (code, signal) => { + void stopAll( + `FNN node ${node.id} exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'none'}). ` + + `See its log: ${node.logFile}`, + typeof code === 'number' && code > 0 ? code : 1, + ); + }); + } + + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.once(signal, () => { + void stopAll(`Received ${signal}, stopping fiber nodes...`, signal === 'SIGINT' ? 130 : 143); + }); + } + + // FNN children keep the event loop alive; this promise resolves only via stopAll. + return new Promise(() => {}); +} + +/** + * `offckb fiber start`: launch only the FNN side of the devnet on top of an + * already-running CKB environment. Never starts, stops or replaces CKB, the + * miner or the RPC proxy. + */ +export async function fiberStart(version: string | undefined, options: FiberStartOptions) { + const settings = readSettings(); + // Network/fork checks run before anything else — including before a + // --daemon respawn, so an unsupported environment fails in the foreground. + assertPlainDevnet(settings); + + if (options.daemon) { + return startFiberDaemon(fiberDaemonChildArgs(), settings); + } + + const lock = acquireEnvLock('offckb fiber start'); + try { + await assertCkbEnvReadyForFiber(settings); + + const fnn = await resolveFnnBinary({ version, binaryPath: options.binaryPath }, settings); + let chainScripts; + try { + chainScripts = resolveFiberChainScripts(); + } catch (error) { + if (error instanceof FiberContractsMissingError) { + logMissingContractsGuidance(error); + process.exitCode = 1; + return; + } + throw error; + } + + const env = await startFiberEnvironment({ + fnnPath: fnn.fnnPath, + testnetConfigPath: fnn.testnetConfigPath, + chainScripts, + nodeCount: options.nodes, + settings, + }); + printFiberSummary(env); + logger.result({ + command: 'fiber.start', + daemon: false, + nodes: env.nodes.map((node) => ({ + id: node.id, + pid: node.process.pid, + rpcUrl: node.rpcUrl, + logFile: node.logFile, + })), + }); + lock.release(); + return superviseFiberNodes(env, settings); + } catch (error) { + lock.release(); + throw error; + } +} + +export async function fiberStopCommand() { + const settings = readSettings(); + const lock = acquireEnvLock('offckb fiber stop'); + try { + await stopFiber(settings); + } finally { + lock.release(); + } +} + +export async function fiberStatusCommand() { + await fiberStatus(readSettings()); +} + +export interface FiberLogsOptions { + node: number; + follow?: boolean; + tail?: number; +} + +export function fiberLogs(options: FiberLogsOptions) { + const settings = readSettings(); + const nodeId = Number(options.node); + if (!Number.isInteger(nodeId) || nodeId <= 0) { + throw new Error('--node must be a positive integer (the node number, e.g. --node 1).'); + } + const entries = readNodesYml(settings); + if (entries == null || !entries.some((entry) => entry.id === nodeId)) { + throw new Error(`Fiber node ${nodeId} does not exist (no matching entry in fiber/nodes.yml).`); + } + const { logFile } = fiberNodePaths(nodeId, settings); + if (!fs.existsSync(logFile)) { + throw new Error(`Fiber node ${nodeId} has no log yet (${logFile} has not been created).`); + } + + const tail = options.tail ?? 100; + for (const line of readLogTail(logFile, tail)) { + logger.info(line); + } + if (options.follow) { + followLogFile(logFile, (line) => logger.info(line)); + } + logger.result({ command: 'fiber.logs', node: nodeId, logFile, follow: !!options.follow }); +} + +export async function fiberCleanCommand(options: FiberCleanOptions) { + await fiberClean(options, readSettings()); +} + +// Re-exported so `node --fiber` can share the same pieces without a cycle. +export { assertPlainDevnet, resolveFnnBinary, resolveFiberChainScripts, startFiberEnvironment, FIBER_DAEMON_PID_FILE }; diff --git a/src/cmd/node.ts b/src/cmd/node.ts index 23882bb7..335756af 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -1,4 +1,4 @@ -import { execFile, execFileSync, spawn, ChildProcess } from 'child_process'; +import { execFileSync, spawn, ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import { @@ -8,7 +8,7 @@ import { TERMINAL_RPC_MIN_CKB_VERSION, } from '../node/init-chain'; import { getVersionFromBinary, installCKBBinary } from '../node/install'; -import { getCKBBinaryPath, readSettings } from '../cfg/setting'; +import { getCKBBinaryPath, readSettings, Settings } from '../cfg/setting'; import { createRPCProxy } from '../tools/rpc-proxy'; import { markForkFirstRunComplete, readForkState } from '../devnet/fork'; import { callJsonRpc } from '../util/json-rpc'; @@ -17,6 +17,28 @@ import { logger } from '../util/logger'; import { checkNodeReadiness, waitForNodeReady } from '../devnet/readiness'; import { devnetTcpListenAddress, subscribeToNodeLogs, SubscriptionHandle } from '../devnet/log-subscription'; import { SCRIPT_LOG_TARGET } from '../devnet/log-file'; +import { + cleanupPidFile, + closeFileDescriptors, + isProcessAlive, + nodeDaemonPaths, + PidMetadata, + readPidFile, + reservePidFile, + resolveCliEntry, + terminateProcess, + verifyDaemonIdentity, + waitForProcessExit, + writePidFile, +} from '../util/daemon'; +import { assertPlainDevnet } from '../fiber/ckb-env'; +import { acquireEnvLock, EnvLockHandle } from '../fiber/env-lock'; +import { resolveFnnBinary, ResolvedFnn } from '../fiber/install'; +import { resolveFiberChainScripts } from '../fiber/scripts'; +import { FiberEnvironment, startFiberEnvironment, stopFiberNodes } from '../fiber/manager'; +import { printFiberSummary } from './fiber'; +import { readLiveRuntime, readRuntime } from '../fiber/runtime'; +import { fiberDaemonPaths } from '../fiber/paths'; export interface NodeProp { version?: string; @@ -24,18 +46,12 @@ export interface NodeProp { binaryPath?: string; daemon?: boolean; verbose?: boolean; + fiber?: boolean; + fnnVersion?: string; + fiberNodes?: number; + fnnBinaryPath?: string; } -interface PidMetadata { - pid: number; - scriptPath: string; - startedAt: string; - status?: 'starting' | 'running'; -} - -const DAEMON_LOG_DIR = 'logs'; -const DAEMON_LOG_FILE = 'daemon.log'; -const DAEMON_PID_FILE = 'daemon.pid'; const DAEMON_CHILD_ENV = 'OFFCKB_DAEMON_CHILD'; const NODE_READY_TIMEOUT_MS = 90_000; const FORK_NODE_READY_TIMEOUT_MS = 10 * 60_000; @@ -52,7 +68,17 @@ function cleanChildOutput(data: unknown): string { .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, ''); } -export function startNode({ version, network = Network.devnet, binaryPath, daemon, verbose }: NodeProp) { +export function startNode({ + version, + network = Network.devnet, + binaryPath, + daemon, + verbose, + fiber, + fnnVersion, + fiberNodes, + fnnBinaryPath, +}: NodeProp) { if (binaryPath && network !== Network.devnet) { logger.warn('Custom binaryPath is only supported for devnet. The provided binaryPath will be ignored.'); } @@ -60,9 +86,18 @@ export function startNode({ version, network = Network.devnet, binaryPath, daemo logger.warn('Daemon mode is only supported for devnet. The daemon flag will be ignored.'); } + if (fiber) { + if (network !== Network.devnet) { + throw new Error(`--fiber is only supported on the plain local devnet; --network ${network} cannot be used.`); + } + // A forked devnet is rejected before any daemon respawn, so an + // unsupported environment always fails in the foreground. + assertPlainDevnet(readSettings()); + } + switch (network) { case Network.devnet: - return nodeDevnet({ version, binaryPath, daemon, verbose }); + return nodeDevnet({ version, binaryPath, daemon, verbose, fiber, fnnVersion, fiberNodes, fnnBinaryPath }); case Network.testnet: return nodeTestnet(); case Network.mainnet: @@ -72,13 +107,42 @@ export function startNode({ version, network = Network.devnet, binaryPath, daemo } } -export async function nodeDevnet({ version, binaryPath, daemon, verbose }: NodeProp) { +export async function nodeDevnet(props: NodeProp) { + const { daemon, fiber } = props; if (daemon) { - return startDaemon(); + return startDaemon(!!fiber); } const settings = readSettings(); + // --fiber shares the devnet environment with the fiber commands, so it + // takes the same environment lock before mutating anything, and refuses to + // adopt an already-running CKB (use `offckb fiber start` for that). + let envLock: EnvLockHandle | null = null; + if (fiber) { + const occupied = await checkNodeReadiness(settings.devnet.rpcUrl, 1000); + if (occupied.ready) { + throw new Error( + `A CKB node is already answering at ${settings.devnet.rpcUrl}. OffCKB does not take over a node it did not start; ` + + 'add FNN nodes to it with: offckb fiber start', + ); + } + envLock = acquireEnvLock('offckb node --fiber'); + } + try { + return await runNodeDevnet(props, envLock, settings); + } catch (error) { + envLock?.release(); + throw error; + } +} + +async function runNodeDevnet( + { version, binaryPath, verbose, fiber, fnnVersion, fiberNodes, fnnBinaryPath }: NodeProp, + envLock: EnvLockHandle | null, + settings: Settings, +) { const ckbVersion = version || settings.bins.defaultCKBVersion; + let ckbBinPath = ''; // The version the chain config will be validated against. A managed binary // knows its version by construction; a custom --binary-path is probed, and @@ -155,6 +219,16 @@ export async function nodeDevnet({ version, binaryPath, daemon, verbose }: NodeP ckbExited = true; }); + // With --fiber, FNN selection/download starts as soon as CKB begins to + // start, so it overlaps with the devnet readiness wait below. + let fnnPrep: Promise | null = null; + if (fiber) { + fnnPrep = resolveFnnBinary({ version: fnnVersion, binaryPath: fnnBinaryPath }, settings); + fnnPrep.catch(() => { + // surfaced when awaited after the CKB environment is ready + }); + } + const timeoutMs = forkState ? FORK_NODE_READY_TIMEOUT_MS : NODE_READY_TIMEOUT_MS; const readiness = await waitForNodeReady(settings.devnet.rpcUrl, timeoutMs, () => !ckbExited); if (!readiness.ready) { @@ -226,30 +300,106 @@ export async function nodeDevnet({ version, binaryPath, daemon, verbose }: NodeP if (!verbose) { logger.info('Follow the full node log with: offckb logs -f'); } + + // The CKB environment is up. With --fiber, wait for the FNN binary + // preparation (started above, concurrent with CKB startup) and run the + // shared Fiber startup flow. Any failure stops everything started here. + let fiberEnv: FiberEnvironment | null = null; + if (fiber && fnnPrep) { + const stopStartedProcesses = () => { + logSubscription?.close(); + if (!ckbProcess.killed) ckbProcess.kill('SIGTERM'); + if (!minerProcess.killed) minerProcess.kill('SIGTERM'); + proxy.stop(); + envLock?.release(); + }; + try { + const fnn = await fnnPrep; + fiberEnv = await startFiberEnvironment({ + fnnPath: fnn.fnnPath, + testnetConfigPath: fnn.testnetConfigPath, + chainScripts: resolveFiberChainScripts(), + nodeCount: fiberNodes, + settings, + }); + } catch (error) { + stopStartedProcesses(); + throw error; + } + printFiberSummary(fiberEnv); + // The environment is built; further mutations by other OffCKB processes + // (stop/clean) check the manager records instead of the lock. + envLock?.release(); + envLock = null; + } + logger.result({ command: 'node', network: Network.devnet, daemon: false, rpcUrl: settings.devnet.rpcUrl, proxyUrl: `http://127.0.0.1:${settings.devnet.rpcProxyPort}`, + ...(fiberEnv + ? { fiber: fiberEnv.nodes.map((node) => ({ id: node.id, pid: node.process.pid, rpcUrl: node.rpcUrl })) } + : {}), }); - // Treat CKB, miner and proxy as one service. A dead CKB must not leave a - // healthy-looking proxy and a miner that retries forever. + // Treat CKB, miner, proxy and the FNNs as one service. A dead component + // must not leave the rest looking healthy. let serviceStopping = false; - const stopService = (component: 'CKB node' | 'CKB miner', code: number | null, signal: NodeJS.Signals | null) => { + const stopService = (component: string, code: number | null, signal: NodeJS.Signals | null) => { if (serviceStopping) return; serviceStopping = true; - logSubscription?.close(); - if (component !== 'CKB node' && !ckbProcess.killed) ckbProcess.kill('SIGTERM'); - if (component !== 'CKB miner' && !minerProcess.killed) minerProcess.kill('SIGTERM'); - proxy.stop(); - if (process.env[DAEMON_CHILD_ENV] === '1') cleanupPidFile(resolveDaemonPaths().pidFile); - logger.error(`${component} exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'none'}).`); - process.exitCode = typeof code === 'number' && code > 0 ? code : 1; + void (async () => { + logSubscription?.close(); + if (component !== 'CKB node' && !ckbProcess.killed) ckbProcess.kill('SIGTERM'); + if (component !== 'CKB miner' && !minerProcess.killed) minerProcess.kill('SIGTERM'); + proxy.stop(); + if (fiberEnv) { + await stopFiberNodes(fiberEnv.nodes, settings); + } + if (process.env[DAEMON_CHILD_ENV] === '1') cleanupPidFile(resolveDaemonPaths().pidFile); + envLock?.release(); + logger.error(`${component} exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'none'}).`); + process.exitCode = typeof code === 'number' && code > 0 ? code : 1; + })(); }; ckbProcess.once('exit', (code, signal) => stopService('CKB node', code, signal)); minerProcess.once('exit', (code, signal) => stopService('CKB miner', code, signal)); + if (fiberEnv) { + for (const node of fiberEnv.nodes) { + node.process.once('exit', (code, signal) => stopService(`FNN node ${node.id}`, code, signal)); + } + installFiberSignalHandlers(ckbProcess, minerProcess, proxy, fiberEnv, settings); + } +} + +// With --fiber the process group contains FNNs whose runtime.json should not +// outlive a clean shutdown. Stop the whole group on Ctrl+C/SIGTERM instead of +// letting each process fend for itself. +function installFiberSignalHandlers( + ckbProcess: ChildProcess, + minerProcess: ChildProcess, + proxy: { stop: () => void }, + fiberEnv: FiberEnvironment, + settings: Settings, +) { + let handling = false; + const handler = (signal: 'SIGINT' | 'SIGTERM') => { + if (handling) return; + handling = true; + void (async () => { + logger.info(`Received ${signal}, stopping the devnet and fiber nodes...`); + if (!ckbProcess.killed) ckbProcess.kill('SIGTERM'); + if (!minerProcess.killed) minerProcess.kill('SIGTERM'); + proxy.stop(); + await stopFiberNodes(fiberEnv.nodes, settings); + if (process.env[DAEMON_CHILD_ENV] === '1') cleanupPidFile(resolveDaemonPaths().pidFile); + process.exit(signal === 'SIGINT' ? 130 : 143); + })(); + }; + process.once('SIGINT', () => handler('SIGINT')); + process.once('SIGTERM', () => handler('SIGTERM')); } // CKB < 0.205.0 rejects the Terminal RPC module during config deserialization @@ -281,11 +431,7 @@ function waitForChildSpawn(child: ChildProcess, label: string): Promise { } function resolveDaemonPaths() { - const settings = readSettings(); - const logDir = path.join(settings.devnet.dataPath, DAEMON_LOG_DIR); - const logFile = path.join(logDir, DAEMON_LOG_FILE); - const pidFile = path.join(logDir, DAEMON_PID_FILE); - return { logDir, logFile, pidFile }; + return nodeDaemonPaths(readSettings()); } // Best-effort check that the spawned process is the one listening on the RPC @@ -384,220 +530,6 @@ async function clearForkFirstRunWhenNodeUp( } } -function readPidFile(pidFile: string): PidMetadata | null { - let raw: string; - try { - raw = fs.readFileSync(pidFile, 'utf8').trim(); - } catch (error) { - // Treat a missing or unreadable PID file as "no daemon". - return null; - } - - if (!raw) { - return null; - } - - // Backward compatibility: plain integer PID written by older versions. - const plainPid = Number(raw); - if (Number.isInteger(plainPid) && plainPid > 0) { - return { pid: plainPid, scriptPath: resolveCliEntry() ?? '', startedAt: new Date(0).toISOString() }; - } - - try { - const parsed = JSON.parse(raw) as Partial; - const pid = Number(parsed.pid); - if (Number.isInteger(pid) && pid > 0 && typeof parsed.scriptPath === 'string') { - return { - pid, - scriptPath: parsed.scriptPath, - startedAt: parsed.startedAt ?? new Date(0).toISOString(), - status: parsed.status, - }; - } - } catch { - // fall through to sentinel below - } - - // Content exists but is neither a valid plain PID nor valid metadata. - // Return a sentinel so stopNode can report an invalid PID and clean up. - return { pid: NaN, scriptPath: '', startedAt: new Date(0).toISOString() }; -} - -function writePidFile(pidFile: string, metadata: PidMetadata) { - fs.writeFileSync(pidFile, JSON.stringify(metadata, null, 2)); -} - -function reservePidFile(pidFile: string, scriptPath: string): void { - let fd: number; - try { - fd = fs.openSync(pidFile, 'wx'); - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err.code === 'EEXIST') { - throw new Error('A CKB devnet daemon startup is already in progress. Try again after it completes.'); - } - throw new Error(`Failed to reserve daemon PID file ${pidFile}: ${err.message}`); - } - - let writeError: Error | undefined; - try { - const reservation: PidMetadata = { - pid: process.pid, - scriptPath, - startedAt: new Date().toISOString(), - status: 'starting', - }; - fs.writeFileSync(fd, JSON.stringify(reservation, null, 2)); - } catch (error) { - writeError = error as Error; - } finally { - fs.closeSync(fd); - } - if (writeError) { - cleanupPidFile(pidFile); - throw new Error(`Failed to initialize daemon PID reservation ${pidFile}: ${writeError.message}`); - } -} - -function resolveCliEntry(): string | null { - // In priority order. process.argv[1] is the most reliable for a Node CLI. - // OFFCKB_CLI_PATH is an escape hatch for packaged/npx/weird environments. - // require.main?.filename is a final fallback when argv is unavailable. - const candidates = [process.env.OFFCKB_CLI_PATH, process.argv[1], require.main?.filename].filter( - (c): c is string => typeof c === 'string' && c.length > 0, - ); - - for (const candidate of candidates) { - try { - const resolved = path.resolve(candidate); - const stats = fs.statSync(resolved); - if (stats.isFile()) { - return resolved; - } - } catch { - // Candidate is missing or not a file; try the next one. - } - } - - return null; -} - -function isProcessAlive(pid: number): boolean { - if (!Number.isInteger(pid) || pid <= 0) return false; - try { - process.kill(pid, 0); - return true; - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err.code === 'ESRCH') return false; - if (err.code === 'EPERM') throw new Error(`Permission denied when checking daemon process ${pid}.`); - throw error; - } -} - -function cleanupPidFile(pidFile: string) { - try { - fs.unlinkSync(pidFile); - } catch (error) { - logger.warn(`Failed to remove PID file ${pidFile}:`, error); - } -} - -function waitForProcessExit(pid: number, timeoutMs: number): Promise { - const start = Date.now(); - return new Promise((resolve, reject) => { - const check = () => { - try { - if (!isProcessAlive(pid)) { - resolve(true); - return; - } - } catch (error) { - reject(error); - return; - } - if (Date.now() - start >= timeoutMs) { - resolve(false); - return; - } - setTimeout(check, 100); - }; - check(); - }); -} - -function getProcessCommandLine(pid: number): Promise { - return new Promise((resolve) => { - // 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 { - resolve(stdout.trim()); - } - }); - }); -} - -async function verifyDaemonIdentity(pid: number, metadata: PidMetadata): Promise { - const cmdline = await getProcessCommandLine(pid); - if (!cmdline) { - return false; - } - - // The daemon child re-runs the same CLI entry point, so its command line - // should reference the same script and should be a Node process. - const scriptName = path.basename(metadata.scriptPath); - const scriptDir = path.dirname(metadata.scriptPath); - const looksLikeNode = cmdline.includes('node') || cmdline.includes('nodejs'); - const looksLikeOurScript = - cmdline.includes(metadata.scriptPath) || (scriptName !== '' && cmdline.includes(scriptName)); - const looksLikeOffckb = cmdline.includes('offckb') || scriptDir.includes('offckb'); - - return looksLikeNode && (looksLikeOurScript || looksLikeOffckb); -} - -function terminateProcess(pid: number, signal: 'SIGTERM' | 'SIGKILL'): Promise { - return new Promise((resolve, reject) => { - if (process.platform === 'win32') { - // Windows has no POSIX signals and process.kill(pid) only terminates the - // single process. Use taskkill to terminate the whole tree. - // /T kills the process and all child processes. - // /F forces termination when SIGKILL is requested. - const args = signal === 'SIGKILL' ? ['/T', '/F', '/PID', String(pid)] : ['/T', '/PID', String(pid)]; - const taskkill = spawn('taskkill', args, { stdio: 'ignore' }); - taskkill.on('error', reject); - taskkill.on('exit', () => { - // taskkill may return non-zero if the process is already gone, which - // is acceptable for our purposes. - resolve(); - }); - return; - } - - // On POSIX, detached: true makes the child a session/process group leader. - // A negative pid sends the signal to the entire process group, ensuring - // the CKB node, miner and RPC proxy all receive it. - try { - process.kill(-pid, signal); - resolve(); - } catch (error) { - reject(error); - } - }); -} - async function failDaemonStartup(error: Error, pid: number, pidFile: string): Promise { let exited = false; try { @@ -628,7 +560,7 @@ async function failDaemonStartup(error: Error, pid: number, pidFile: string): Pr throw error; } -async function startDaemon() { +async function startDaemon(waitForFiber = false) { const { logDir, logFile, pidFile } = resolveDaemonPaths(); try { @@ -745,6 +677,11 @@ async function startDaemon() { `CKB devnet daemon failed to become ready. See ${logFile}. ${readiness.error ?? 'Daemon process exited.'}`, ); } + if (waitForFiber) { + // node --fiber --daemon: the child records a running fiber environment + // in runtime.json only after every Fiber startup check has passed. + await waitForFiberRuntimeRunning(child.pid!, settings, logFile); + } writePidFile(pidFile, { ...metadata, status: 'running' }); } catch (error) { return failDaemonStartup(error as Error, child.pid, pidFile); @@ -766,15 +703,22 @@ async function startDaemon() { }); } -function closeFileDescriptors(...fds: (number | undefined)[]) { - for (const fd of fds) { - if (fd === undefined) continue; - try { - fs.closeSync(fd); - } catch { - // ignore +async function waitForFiberRuntimeRunning(managerPid: number, settings: Settings, logFile: string) { + // Matches FIBER_DAEMON_READY_TIMEOUT_MS in fiber/daemon.ts: the child's + // first run may still be downloading FNN. + const timeoutMs = 10 * 60_000; + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (!isProcessAlive(managerPid)) { + throw new Error(`The daemon exited before the fiber environment became ready. See ${logFile}.`); } + const runtime = readRuntime(settings); + if (runtime && runtime.managerPid === managerPid && runtime.status === 'running') { + return; + } + await new Promise((resolve) => setTimeout(resolve, 500)); } + throw new Error(`Timed out waiting for the fiber environment to become ready. See ${logFile}.`); } export async function stopNode() { @@ -787,6 +731,24 @@ export async function stopNode() { return; } + // FNNs managed by a separate fiber daemon must be stopped by that daemon's + // owner command; node stop never reaches across another manager. + const settings = readSettings(); + const fiberDaemon = readPidFile(fiberDaemonPaths(settings).pidFile); + if (fiberDaemon && Number.isInteger(fiberDaemon.pid) && fiberDaemon.pid > 0 && isProcessAlive(fiberDaemon.pid)) { + throw new Error( + `Fiber nodes are managed by a separate fiber daemon (PID ${fiberDaemon.pid}). ` + + 'Stop them first with: offckb fiber stop', + ); + } + const fiberRuntime = readLiveRuntime(settings); + if (fiberRuntime && fiberRuntime.managerPid !== metadata.pid) { + logger.warn( + `FNN nodes appear to be managed by a foreground OffCKB process (PID ${fiberRuntime.managerPid}); ` + + 'stop them in that terminal. Continuing to stop the CKB daemon...', + ); + } + const pid = metadata.pid; if (!Number.isInteger(pid) || pid <= 0) { cleanupPidFile(pidFile); diff --git a/src/fiber/accounts.ts b/src/fiber/accounts.ts new file mode 100644 index 00000000..a185364c --- /dev/null +++ b/src/fiber/accounts.ts @@ -0,0 +1,113 @@ +import * as fs from 'fs'; +import crypto from 'crypto'; +import { ccc } from '@ckb-ccc/core'; +import accountConfig from '../../account/account.json'; +import { fiberAccountIndex, fiberNodePaths, UDT_ISSUER_ACCOUNT_INDEX } from './paths'; +import { readSettings, Settings } from '../cfg/setting'; + +export interface BuiltinAccount { + privkey: string; + pubkey: string; + lockScript: { + codeHash: string; + hashType: string; + args: string; + }; + address: string; + args: string; +} + +const accounts = accountConfig as unknown as BuiltinAccount[]; + +export function getBuiltinAccount(index: number): BuiltinAccount { + const account = accounts[index]; + if (!account) { + throw new Error(`Built-in account #${index} does not exist (account.json has ${accounts.length} accounts).`); + } + return account; +} + +// Node N uses CKB account N+2; accounts 3-18 are reserved for Fiber nodes. +export function fiberNodeAccount(nodeId: number): BuiltinAccount { + return getBuiltinAccount(fiberAccountIndex(nodeId)); +} + +// Account 19 deploys contracts and issues the sUDT/xUDT used for testing. +export function udtIssuerAccount(): BuiltinAccount { + return getBuiltinAccount(UDT_ISSUER_ACCOUNT_INDEX); +} + +export function udtIssuerLockHash(): string { + const issuer = udtIssuerAccount(); + return ccc.Script.from(issuer.lockScript as ccc.ScriptLike).hash(); +} + +// Derive the compressed secp256k1 public key of a raw 32-byte secret, the +// format FNN reports as node_info.pubkey (hex, no 0x prefix). +export function fiberPublicKeyFromSecret(secret: Buffer): string { + const signer = new ccc.SignerCkbPrivateKey({} as never, `0x${secret.toString('hex')}` as `0x${string}`); + return signer.publicKey.slice(2).toLowerCase(); +} + +export function readFiberNodeSecretKey(nodeId: number, settings: Settings = readSettings()): Buffer | null { + const skFile = fiberNodePaths(nodeId, settings).fiberSkFile; + try { + const data = fs.readFileSync(skFile); + return data.length >= 32 ? data.subarray(0, 32) : null; + } catch { + return null; + } +} + +function writePrivateFile(file: string, content: string) { + fs.writeFileSync(file, content, { mode: 0o600 }); + try { + fs.chmodSync(file, 0o600); + } catch { + // Windows has no POSIX modes; the file inherits directory ACLs. + } +} + +/** + * Create the node directory layout and its CKB key material. The CKB secret + * key is written as plain hex (FNN encrypts it in place on first start, + * using the per-node password passed via FIBER_SECRET_KEY_PASSWORD). The + * Fiber network identity key (fiber/sk) is generated by FNN itself on first + * start and must never be overwritten here. + * + * Existing nodes keep their key and password; a node directory with missing + * key material is an error, never silently re-created (the on-chain account + * and any channels are tied to the original keys). + */ +export function ensureNodeKeyMaterial(nodeId: number, settings: Settings = readSettings()): { created: boolean } { + const paths = fiberNodePaths(nodeId, settings); + const keyExists = fs.existsSync(paths.ckbKeyFile); + const passwordExists = fs.existsSync(paths.passwordFile); + if (keyExists && passwordExists) { + return { created: false }; + } + if (keyExists !== passwordExists) { + const missing = keyExists ? paths.passwordFile : paths.ckbKeyFile; + throw new Error( + `Fiber node ${nodeId} has incomplete key material: ${missing} is missing. ` + + 'The CKB key and its password must both come from the same provisioning; ' + + 'restore the file or remove the node directory and start again.', + ); + } + + fs.mkdirSync(paths.ckbDir, { recursive: true }); + fs.mkdirSync(paths.fiberDir, { recursive: true }); + const account = fiberNodeAccount(nodeId); + writePrivateFile(paths.ckbKeyFile, account.privkey.replace(/^0x/, '')); + writePrivateFile(paths.passwordFile, crypto.randomBytes(24).toString('base64')); + return { created: true }; +} + +export function readNodePassword(nodeId: number, settings: Settings = readSettings()): string { + const passwordFile = fiberNodePaths(nodeId, settings).passwordFile; + try { + return fs.readFileSync(passwordFile, 'utf8').trim(); + } catch (error) { + throw new Error(`Failed to read the password of fiber node ${nodeId}: ${(error as Error).message}`); + } +} diff --git a/src/fiber/ckb-env.ts b/src/fiber/ckb-env.ts new file mode 100644 index 00000000..4d3f85ae --- /dev/null +++ b/src/fiber/ckb-env.ts @@ -0,0 +1,69 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { checkNodeReadiness } from '../devnet/readiness'; +import { readForkState, FORK_STATE_FILE } from '../devnet/fork'; +import { callJsonRpc } from '../util/json-rpc'; +import { readSettings, Settings } from '../cfg/setting'; + +// Fiber commands only work on a plain local devnet. A forked devnet keeps its +// source chain's data in the same directory and records the fork in +// fork.json, so the file's mere presence — valid or not — rejects Fiber. +export function assertPlainDevnet(settings: Settings = readSettings()) { + const forkFile = path.join(settings.devnet.configPath, FORK_STATE_FILE); + if (!fs.existsSync(forkFile)) return; + const forkState = readForkState(settings.devnet.configPath); + if (forkState) { + throw new Error( + `Fiber is not supported on a forked devnet (fork of ${forkState.source}, recorded in ${forkFile}). ` + + 'Run `offckb clean` and start a plain local chain to use Fiber.', + ); + } + throw new Error( + `${forkFile} exists but cannot be read or parsed; cannot verify this is a plain local chain. ` + + 'Refusing to start Fiber. Remove the file only if you are sure this devnet is not a fork.', + ); +} + +function parseHexNumber(value: unknown): bigint | null { + if (typeof value !== 'string' || !/^0x[0-9a-f]+$/i.test(value)) return null; + return BigInt(value); +} + +/** + * `fiber start` requires a healthy local CKB environment it must not create + * or replace itself: RPC answering, indexer answering, and the chain still + * producing blocks. + */ +export async function assertCkbEnvReadyForFiber(settings: Settings = readSettings()) { + const readiness = await checkNodeReadiness(settings.devnet.rpcUrl, 2000); + if (!readiness.ready) { + throw new Error( + `The local CKB node is not answering at ${settings.devnet.rpcUrl}: ${readiness.error ?? 'unavailable'}. ` + + 'Start it first with `offckb node` (or use `offckb node --fiber` to start everything at once).', + ); + } + if (readiness.indexerTip == null) { + throw new Error( + `The CKB indexer is not ready at ${settings.devnet.rpcUrl}. Fiber needs the indexer; ` + + 'wait for the node to finish starting and try again.', + ); + } + + const firstTip = readiness.nodeTip ?? BigInt(0); + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 1500)); + try { + const tip = parseHexNumber(await callJsonRpc(settings.devnet.rpcUrl, 'get_tip_block_number', [], 2000)); + if (tip != null && tip > firstTip) { + return; + } + } catch { + // keep waiting until the deadline + } + } + throw new Error( + `The CKB devnet at ${settings.devnet.rpcUrl} is not producing blocks. ` + + 'Fiber requires a mining devnet; check the node and miner (e.g. `offckb logs`).', + ); +} diff --git a/src/fiber/clean.ts b/src/fiber/clean.ts new file mode 100644 index 00000000..069574be --- /dev/null +++ b/src/fiber/clean.ts @@ -0,0 +1,109 @@ +import * as fs from 'fs'; +import { confirm } from '@inquirer/prompts'; +import { acquireEnvLock } from './env-lock'; +import { fiberDaemonPaths, fiberNodeIds, fiberNodePaths, fiberRootPath } from './paths'; +import { readLiveRuntime, removeRuntimeFileIfStale } from './runtime'; +import { readPidFile, isProcessAlive } from '../util/daemon'; +import { isStoreLockHeld } from './store-lock'; +import { readSettings, Settings } from '../cfg/setting'; +import { isFolderExists } from '../util/fs'; +import { logger } from '../util/logger'; + +export interface FiberCleanOptions { + data?: boolean; + yes?: boolean; +} + +function existingStoreLockFiles(settings: Settings): string[] { + return fiberNodeIds(settings) + .map((id) => fiberNodePaths(id, settings).storeLockFile) + .filter((lockFile) => fs.existsSync(lockFile)); +} + +/** + * Cleaning is only allowed when every FNN is stopped: no live manager + * (daemon or foreground) and every existing store lock acquirable. Anything + * that cannot be confirmed refuses the clean — a running FNN must never + * watch its store disappear. + */ +export function assertFiberFullyStopped(settings: Settings = readSettings()) { + const live = readLiveRuntime(settings); + if (live) { + throw new Error( + `Fiber nodes are still managed by OffCKB process ${live.managerPid}. ` + + 'Stop them first (`offckb fiber stop` for a daemon, or Ctrl+C in its terminal).', + ); + } + const { pidFile } = fiberDaemonPaths(settings); + const daemon = readPidFile(pidFile); + if (daemon && Number.isInteger(daemon.pid) && daemon.pid > 0 && isProcessAlive(daemon.pid)) { + throw new Error(`A fiber daemon is still running (PID ${daemon.pid}). Stop it first with: offckb fiber stop`); + } + + const heldLocks = existingStoreLockFiles(settings).filter((lockFile) => isStoreLockHeld(lockFile) !== false); + if (heldLocks.length > 0) { + throw new Error( + `Cannot confirm all Fiber stores are closed (lock(s) still held or unverifiable: ${heldLocks.join(', ')}). ` + + 'Stop every FNN process and try again.', + ); + } +} + +async function confirmOrAbort(message: string, yes?: boolean) { + if (yes) return; + const answer = await confirm({ message, default: false }); + if (!answer) { + throw new Error('Aborted.'); + } +} + +export async function fiberClean(options: FiberCleanOptions, settings: Settings = readSettings()) { + const lock = acquireEnvLock(options.data ? 'offckb fiber clean --data' : 'offckb fiber clean'); + try { + const root = fiberRootPath(settings); + if (!isFolderExists(root)) { + logger.info('Nothing to clean. No fiber environment found.'); + logger.result({ command: 'fiber.clean', cleaned: false, reason: 'not-found' }); + return; + } + + assertFiberFullyStopped(settings); + + if (options.data) { + const stores = fiberNodeIds(settings) + .map((id) => fiberNodePaths(id, settings).fiberStoreDir) + .filter((storeDir) => isFolderExists(storeDir)); + logger.warn( + 'This permanently deletes every FNN store (channels, payments and other node data). ' + + 'Deleted data cannot be recovered. Node accounts, identity keys, passwords and logs are kept.', + ); + for (const store of stores) { + logger.info(` will delete: ${store}`); + } + await confirmOrAbort('Delete all FNN stores?', options.yes); + + removeRuntimeFileIfStale(settings); + for (const store of stores) { + fs.rmSync(store, { recursive: true, force: true }); + logger.info(`Deleted ${store}`); + } + logger.success('All FNN stores cleaned. Node accounts and network identities are unchanged.'); + logger.result({ command: 'fiber.clean', cleaned: true, dataOnly: true, removed: stores }); + return; + } + + logger.warn( + 'This deletes the whole fiber environment, including node configs, the CKB account keys, ' + + 'the Fiber network identity keys and passwords of every node. Restarting creates NEW node identities. ' + + 'The downloaded FNN binary and the devnet CKB data are kept.', + ); + logger.info(` will delete: ${root}`); + await confirmOrAbort('Delete the whole fiber environment?', options.yes); + + fs.rmSync(root, { recursive: true, force: true }); + logger.success('Fiber environment cleaned.'); + logger.result({ command: 'fiber.clean', cleaned: true, dataOnly: false, removed: [root] }); + } finally { + lock.release(); + } +} diff --git a/src/fiber/config-gen.ts b/src/fiber/config-gen.ts new file mode 100644 index 00000000..859aecbb --- /dev/null +++ b/src/fiber/config-gen.ts @@ -0,0 +1,104 @@ +import * as fs from 'fs'; +import yaml from 'js-yaml'; +import { fiberNodePaths, fiberP2pAddr, fiberRpcPort } from './paths'; +import { FiberChainScripts } from './scripts'; +import { FiberNodeEntry } from './nodes-yml'; +import { readSettings, Settings } from '../cfg/setting'; + +// The devnet spec file, resolved by FNN relative to the node directory +// (/fiber/nodes//). The specs directory is shared, so the config +// points at the original dev.toml instead of copying it per node. +const DEV_TOML_RELATIVE_TO_NODE = '../../../specs/dev.toml'; + +// RPC modules the devnet environment serves. cch is intentionally off, and +// dev-only modules (only available in debug builds) are not relied upon. +const ENABLED_RPC_MODULES = ['channel', 'payment', 'graph', 'info', 'invoice', 'peer', 'watchtower']; + +function isPlainObject(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +// Deep-merge per-node overrides onto the generated config: objects merge +// recursively, lists replace wholesale (matching FNN's own config layering). +export function mergeNodeConfig( + base: Record, + override: Record, +): Record { + const result: Record = { ...base }; + for (const [key, value] of Object.entries(override)) { + const existing = result[key]; + if (isPlainObject(existing) && isPlainObject(value)) { + result[key] = mergeNodeConfig(existing, value); + } else { + result[key] = value; + } + } + return result; +} + +/** + * Generate one node's config.yml. The template is the testnet config bundled + * with the FNN release, parsed as a generic mapping so config fields added by + * future FNN versions survive the round trip. Chain- and environment-specific + * values are replaced with the devnet ones; everything else is kept. + * + * Hand edits to config.yml do not survive regeneration — persistent + * customization belongs in fiber/nodes.yml. + */ +export function generateNodeConfig(options: { + node: FiberNodeEntry; + chainScripts: FiberChainScripts; + testnetConfigPath: string; + settings?: Settings; +}): string { + const settings = options.settings ?? readSettings(); + const nodeId = options.node.id; + + let template: unknown; + try { + template = yaml.load(fs.readFileSync(options.testnetConfigPath, 'utf8')); + } catch (error) { + throw new Error(`Failed to parse FNN testnet config ${options.testnetConfigPath}: ${(error as Error).message}`); + } + if (!isPlainObject(template)) { + throw new Error(`FNN testnet config ${options.testnetConfigPath} is not a YAML mapping.`); + } + + const fiber = isPlainObject(template.fiber) ? { ...template.fiber } : {}; + fiber.chain = DEV_TOML_RELATIVE_TO_NODE; + fiber.listening_addr = fiberP2pAddr(nodeId); + fiber.bootnode_addrs = []; + fiber.announce_listening_addr = true; + fiber.announce_private_addr = true; + fiber.gossip_network_maintenance_interval_ms = 1000; + fiber.gossip_store_maintenance_interval_ms = 1000; + const nodeName = `offckb-fnn-${nodeId}`; + if (Buffer.byteLength(nodeName, 'utf8') > 32) { + throw new Error(`Fiber node name "${nodeName}" exceeds 32 UTF-8 bytes.`); + } + fiber.announced_node_name = nodeName; + fiber.scripts = options.chainScripts.fiberScripts; + + const rpc = isPlainObject(template.rpc) ? { ...template.rpc } : {}; + rpc.listening_addr = `127.0.0.1:${fiberRpcPort(nodeId)}`; + rpc.enabled_modules = ENABLED_RPC_MODULES; + rpc.cors_enabled = false; + + const ckb = isPlainObject(template.ckb) ? { ...template.ckb } : {}; + ckb.rpc_url = settings.devnet.rpcUrl; + ckb.udt_whitelist = options.chainScripts.udtWhitelist; + + let config: Record = { + ...template, + fiber, + rpc, + ckb, + services: ['fiber', 'rpc', 'ckb'], + }; + config = mergeNodeConfig(config, options.node.config); + + const paths = fiberNodePaths(nodeId, settings); + fs.mkdirSync(paths.dir, { recursive: true }); + fs.writeFileSync(paths.configFile, yaml.dump(config, { noRefs: true, lineWidth: -1 })); + return paths.configFile; +} diff --git a/src/fiber/daemon.ts b/src/fiber/daemon.ts new file mode 100644 index 00000000..8deccd52 --- /dev/null +++ b/src/fiber/daemon.ts @@ -0,0 +1,307 @@ +import { spawn } from 'child_process'; +import * as fs from 'fs'; +import { + cleanupPidFile, + closeFileDescriptors, + isProcessAlive, + nodeDaemonPaths, + readPidFile, + reservePidFile, + resolveCliEntry, + terminateProcess, + verifyDaemonIdentity, + waitForProcessExit, + writePidFile, + PidMetadata, +} from '../util/daemon'; +import { fiberDaemonPaths, fiberNodePaths } from './paths'; +import { readRuntime, readLiveRuntime, isRuntimeStale, removeRuntimeFile } from './runtime'; +import { isStoreLockHeld, waitForStoreLocksReleased } from './store-lock'; +import { readSettings, Settings } from '../cfg/setting'; +import { logger } from '../util/logger'; + +const FIBER_DAEMON_CHILD_ENV = 'OFFCKB_DAEMON_CHILD'; +const FIBER_DAEMON_READY_TIMEOUT_MS = 10 * 60_000; // first run may download FNN +const STOP_WAIT_TIMEOUT_MS = 15_000; +const STORE_LOCK_WAIT_TIMEOUT_MS = 15_000; + +/** + * Daemonize `fiber start`: the current command spawns a detached manager + * child re-running the same command without --daemon, waits until the child + * reports a running environment in runtime.json, then exits. The child keeps + * managing all FNNs; `offckb fiber stop` signals it later. + */ +export async function startFiberDaemon(childArgs: string[], settings: Settings = readSettings()) { + const { logDir, logFile, pidFile } = fiberDaemonPaths(settings); + fs.mkdirSync(logDir, { recursive: true }); + + const existing = readPidFile(pidFile); + if (existing) { + if (isProcessAlive(existing.pid)) { + const identityOk = await verifyDaemonIdentity(existing.pid, existing); + if (identityOk) { + if (existing.status === 'starting') { + throw new Error(`Another fiber daemon startup is already in progress (PID ${existing.pid}).`); + } + throw new Error( + `A fiber daemon is already running (PID ${existing.pid}). Stop it first with: offckb fiber stop`, + ); + } + logger.warn( + `PID ${existing.pid} from ${pidFile} belongs to another process; removing stale daemon metadata without signaling it.`, + ); + } + cleanupPidFile(pidFile); + } + + const scriptPath = resolveCliEntry(); + if (!scriptPath) { + throw new Error( + 'Unable to determine the CLI entry point for daemon mode. Set OFFCKB_CLI_PATH to the offckb script.', + ); + } + reservePidFile(pidFile, scriptPath); + + let out: number | undefined; + let err: number | undefined; + try { + out = fs.openSync(logFile, 'a'); + err = fs.openSync(logFile, 'a'); + } catch (error) { + closeFileDescriptors(out, err); + cleanupPidFile(pidFile); + throw new Error(`Failed to prepare daemon log file at ${logFile}: ${(error as Error).message}`); + } + + const childEnv = { ...process.env, [FIBER_DAEMON_CHILD_ENV]: '1' }; + let child; + try { + child = spawn(process.execPath, [scriptPath, ...childArgs], { + detached: true, + stdio: ['ignore', out, err], + env: childEnv, + }); + } catch (error) { + closeFileDescriptors(out, err); + cleanupPidFile(pidFile); + throw new Error(`Failed to spawn fiber daemon process: ${(error as Error).message}`); + } + if (!child.pid) { + closeFileDescriptors(out, err); + cleanupPidFile(pidFile); + throw new Error('Failed to spawn fiber daemon process: no PID returned.'); + } + child.unref(); + child.on('error', (error) => { + logger.error('Fiber daemon child process failed to start:', error); + cleanupPidFile(pidFile); + }); + + const metadata: PidMetadata = { + pid: child.pid, + scriptPath, + startedAt: new Date().toISOString(), + status: 'starting', + }; + try { + writePidFile(pidFile, metadata); + } catch (error) { + closeFileDescriptors(out, err); + return failFiberDaemonStartup(error as Error, child.pid, pidFile); + } + closeFileDescriptors(out, err); + + // Readiness: the child records a running environment in runtime.json only + // after every startup check has passed. + const start = Date.now(); + let ready = false; + while (!ready && Date.now() - start < FIBER_DAEMON_READY_TIMEOUT_MS) { + if (!isProcessAlive(child.pid)) { + return failFiberDaemonStartup( + new Error(`Fiber daemon exited before the environment became ready. See ${logFile}.`), + child.pid, + pidFile, + ); + } + const runtime = readRuntime(settings); + if (runtime && runtime.managerPid === child.pid && runtime.status === 'running') { + ready = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + if (!ready) { + return failFiberDaemonStartup( + new Error(`Timed out waiting for the fiber environment to become ready. See ${logFile}.`), + child.pid, + pidFile, + ); + } + writePidFile(pidFile, { ...metadata, status: 'running' }); + + logger.success(`Fiber daemon started with PID ${child.pid}; all startup checks passed.`); + logger.info(`Logs: ${logFile}`); + logger.info(`PID file: ${pidFile}`); + logger.info('Stop the daemon with: offckb fiber stop'); + logger.result({ command: 'fiber.start', daemon: true, pid: child.pid, logFile, pidFile }); +} + +async function failFiberDaemonStartup(error: Error, pid: number, pidFile: string): Promise { + let exited = false; + try { + exited = !isProcessAlive(pid); + if (!exited) { + await terminateProcess(pid, 'SIGTERM'); + exited = await waitForProcessExit(pid, 5000); + if (!exited) { + await terminateProcess(pid, 'SIGKILL'); + exited = await waitForProcessExit(pid, 5000); + } + } + } catch { + try { + exited = !isProcessAlive(pid); + } catch { + exited = false; + } + } + + if (exited) { + cleanupPidFile(pidFile); + } else { + error.message += ` Process ${pid} is still running; PID file was preserved.`; + } + throw error; +} + +function storeLockFilesForRuntime(settings: Settings): string[] { + const runtime = readRuntime(settings); + if (!runtime) return []; + return runtime.nodes.map((node) => fiberNodePaths(node.id, settings).storeLockFile); +} + +async function stopManagerAndCleanup(options: { + pid: number; + pidFile: string | null; + label: string; + settings: Settings; +}) { + const { pid, pidFile, label, settings } = options; + // Capture the node lock files while runtime.json still exists; the manager + // removes it during its own shutdown. + const lockFiles = storeLockFilesForRuntime(settings); + logger.info(`Stopping ${label} (PID ${pid}); its FNN nodes stop with it...`); + await terminateProcess(pid, 'SIGTERM'); + const exited = await waitForProcessExit(pid, STOP_WAIT_TIMEOUT_MS); + + let locksReleased = await waitForStoreLocksReleased(lockFiles, STORE_LOCK_WAIT_TIMEOUT_MS); + if (!exited || !locksReleased) { + logger.warn(`${label} or its FNN nodes did not finish stopping in time; sending SIGKILL once...`); + try { + await terminateProcess(pid, 'SIGKILL'); + } catch { + // the process group may already be gone + } + await waitForProcessExit(pid, 5000); + locksReleased = await waitForStoreLocksReleased(lockFiles, 5000); + } + if (!locksReleased) { + const held = lockFiles.filter((file) => isStoreLockHeld(file) !== false); + logger.warn( + `Could not confirm all Fiber store locks were released (${held.join(', ') || 'unknown'}). ` + + 'Check for leftover fnn processes before starting Fiber again.', + ); + } + + if (pidFile) cleanupPidFile(pidFile); + removeRuntimeFile(settings); +} + +/** + * Stop daemon-managed FNNs. Only manager processes recorded in a daemon PID + * file are ever signaled: the fiber daemon of `fiber start --daemon`, or the + * CKB daemon of `node --fiber --daemon` (which manages CKB and FNNs as one + * group, so stopping it stops the whole environment). Foreground managers + * are reported, never signaled. FNNs are never killed individually by + * runtime.json, port, path or version. + */ +export async function stopFiber(settings: Settings = readSettings()) { + const { pidFile } = fiberDaemonPaths(settings); + + const fiberDaemon = readPidFile(pidFile); + if (fiberDaemon && Number.isInteger(fiberDaemon.pid) && fiberDaemon.pid > 0) { + if (isProcessAlive(fiberDaemon.pid)) { + if (fiberDaemon.status === 'starting') { + throw new Error( + `The fiber daemon startup is still in progress (PID ${fiberDaemon.pid}). Try stopping it again shortly.`, + ); + } + const identityOk = await verifyDaemonIdentity(fiberDaemon.pid, fiberDaemon); + if (!identityOk) { + throw new Error( + `Process ${fiberDaemon.pid} does not appear to be the offckb fiber daemon. Refusing to signal it. ` + + `If you are sure, stop it manually and remove ${pidFile}.`, + ); + } + await stopManagerAndCleanup({ pid: fiberDaemon.pid, pidFile, label: 'fiber daemon', settings }); + logger.success('Fiber daemon stopped.'); + logger.result({ command: 'fiber.stop', stopped: true, pid: fiberDaemon.pid }); + return; + } + logger.warn(`Fiber daemon process ${fiberDaemon.pid} is not running; removing the stale PID file.`); + cleanupPidFile(pidFile); + } else if (fiberDaemon) { + cleanupPidFile(pidFile); + } + + // No fiber daemon. The FNNs may belong to a `node --fiber --daemon` + // environment, whose CKB daemon manages CKB and FNNs as one group. + const runtime = readLiveRuntime(settings); + if (runtime == null) { + const stale = readRuntime(settings); + if (stale && isRuntimeStale(stale)) { + removeRuntimeFile(settings); + logger.warn( + `The fiber manager process ${stale.managerPid} has already exited. ` + + 'If any FNN processes outlived it they are now unmanaged; stop them manually.', + ); + logger.result({ command: 'fiber.stop', stopped: false, reason: 'stale-runtime' }); + return; + } + logger.info('No running fiber environment found.'); + logger.result({ command: 'fiber.stop', stopped: false, reason: 'not-running' }); + return; + } + + const nodeDaemon = readPidFile(nodeDaemonPaths(settings).pidFile); + if ( + nodeDaemon && + Number.isInteger(nodeDaemon.pid) && + nodeDaemon.pid === runtime.managerPid && + isProcessAlive(nodeDaemon.pid) + ) { + const identityOk = await verifyDaemonIdentity(nodeDaemon.pid, nodeDaemon); + if (!identityOk) { + throw new Error(`Process ${nodeDaemon.pid} does not appear to be the offckb node daemon. Refusing to signal it.`); + } + logger.warn( + 'The FNN nodes are managed by the `offckb node --fiber --daemon` manager; ' + + 'stopping it stops the whole environment (CKB, miner, RPC proxy and FNNs).', + ); + await stopManagerAndCleanup({ + pid: nodeDaemon.pid, + pidFile: nodeDaemonPaths(settings).pidFile, + label: 'node --fiber daemon', + settings, + }); + logger.success('The node --fiber environment (CKB and FNNs) stopped.'); + logger.result({ command: 'fiber.stop', stopped: true, pid: nodeDaemon.pid, includedCkb: true }); + return; + } + + logger.warn( + `The FNN nodes are managed by a foreground OffCKB process (PID ${runtime.managerPid}). ` + + 'Stop it with Ctrl+C in the terminal where it is running.', + ); + logger.result({ command: 'fiber.stop', stopped: false, reason: 'foreground-manager', pid: runtime.managerPid }); +} diff --git a/src/fiber/env-lock.ts b/src/fiber/env-lock.ts new file mode 100644 index 00000000..24b05204 --- /dev/null +++ b/src/fiber/env-lock.ts @@ -0,0 +1,115 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { envLockPath } from './paths'; +import { isProcessAlive } from '../util/daemon'; +import { logger } from '../util/logger'; + +export interface EnvLockHandle { + lockFile: string; + release: () => void; +} + +interface LockRecord { + pid: number; + acquiredAt: string; +} + +// A held lock marks that this process is currently mutating the devnet +// environment (starting/stopping FNNs, cleaning data). It is released after +// the operation, not for the lifetime of the managed processes. +const heldLocks = new Set(); + +function readLockRecord(lockFile: string): LockRecord | null { + try { + const parsed = JSON.parse(fs.readFileSync(lockFile, 'utf8')) as Partial; + if (Number.isInteger(parsed.pid) && (parsed.pid as number) > 0) { + return { pid: parsed.pid as number, acquiredAt: String(parsed.acquiredAt ?? '') }; + } + } catch { + // Unreadable or invalid content: cannot identify a holder. + } + return null; +} + +/** + * Acquire the devnet environment lock. Throws when another live OffCKB + * process holds it. A leftover lock whose recorded holder no longer exists + * is removed and re-acquired; that is the only condition under which a stale + * lock may be deleted. + */ +export function acquireEnvLock(purpose: string, lockFile: string = envLockPath()): EnvLockHandle { + fs.mkdirSync(path.dirname(lockFile), { recursive: true }); + for (let attempt = 0; attempt < 2; attempt++) { + let fd: number; + try { + fd = fs.openSync(lockFile, 'wx'); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== 'EEXIST') { + throw new Error(`Failed to acquire environment lock ${lockFile}: ${err.message}`); + } + const record = readLockRecord(lockFile); + if (record) { + let alive = false; + try { + alive = isProcessAlive(record.pid); + } catch { + alive = true; // cannot check → assume held, never break a live lock + } + if (alive) { + throw new Error( + `Another OffCKB process (PID ${record.pid}, since ${record.acquiredAt || 'unknown time'}) is ` + + `modifying this devnet environment. Wait for it to finish before running: ${purpose}.`, + ); + } + logger.debug(`Removing stale environment lock left by dead process ${record.pid}.`); + } else { + logger.debug(`Removing unreadable environment lock file ${lockFile}.`); + } + try { + fs.unlinkSync(lockFile); + } catch (unlinkError) { + throw new Error(`Failed to remove stale environment lock ${lockFile}: ${(unlinkError as Error).message}`); + } + continue; + } + + try { + const record: LockRecord = { pid: process.pid, acquiredAt: new Date().toISOString() }; + fs.writeFileSync(fd, JSON.stringify(record)); + } finally { + fs.closeSync(fd); + } + heldLocks.add(lockFile); + return { + lockFile, + release: () => releaseEnvLock(lockFile), + }; + } + throw new Error(`Failed to acquire environment lock ${lockFile}.`); +} + +export function releaseEnvLock(lockFile: string = envLockPath()) { + if (!heldLocks.has(lockFile)) return; + heldLocks.delete(lockFile); + try { + // Only delete the lock if it still records this process; never remove a + // lock that another process re-acquired after us. + const record = readLockRecord(lockFile); + if (record == null || record.pid === process.pid) { + fs.unlinkSync(lockFile); + } + } catch (error) { + logger.warn(`Failed to release environment lock ${lockFile}: ${(error as Error).message}`); + } +} + +export function isEnvLockHeld(lockFile: string = envLockPath()): boolean { + const record = readLockRecord(lockFile); + if (record == null) return false; + try { + return isProcessAlive(record.pid); + } catch { + return true; + } +} diff --git a/src/fiber/install.ts b/src/fiber/install.ts new file mode 100644 index 00000000..1c9d314f --- /dev/null +++ b/src/fiber/install.ts @@ -0,0 +1,203 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import os from 'os'; +import yaml from 'js-yaml'; +import { Request } from '../util/request'; +import { getVersionFromBinary } from '../node/install'; +import { unZipFile } from '../node/install'; +import { readSettings, Settings } from '../cfg/setting'; +import { bundledFiberTestnetConfigPath } from './paths'; +import { logger } from '../util/logger'; + +// Only FNN versions tested against the contracts and config rules bundled +// with this offckb release may be downloaded. Other versions require +// --binary-path / --fnn-binary-path with a locally built FNN. +export const SUPPORTED_FNN_VERSIONS = ['0.9.0-rc7'] as const; +export const DEFAULT_FNN_VERSION = SUPPORTED_FNN_VERSIONS[0]; + +export interface ResolvedFnn { + fnnPath: string; + testnetConfigPath: string; + // Where the binary came from: a downloaded release or a user-supplied path. + source: 'download' | 'binary-path'; + // Version reported by the binary, when it can be probed. + version: string | null; +} + +export function getFnnInstallPath(version: string, settings: Settings = readSettings()): string { + return path.join(settings.bins.rootFolder, 'fnn', version); +} + +export function getFnnBinaryPath(version: string, settings: Settings = readSettings()): string { + const binaryName = process.platform === 'win32' ? 'fnn.exe' : 'fnn'; + return path.join(getFnnInstallPath(version, settings), binaryName); +} + +export function getFnnBundledTestnetConfigPath(version: string, settings: Settings = readSettings()): string { + return path.join(getFnnInstallPath(version, settings), 'config', 'testnet', 'config.yml'); +} + +function buildFnnPackageName(version: string): string { + const platform = os.platform(); + const arch = os.arch(); + if (platform === 'linux') { + return arch === 'arm64' ? `fnn_v${version}-aarch64-linux-portable` : `fnn_v${version}-x86_64-linux-portable`; + } + if (platform === 'darwin') { + return arch === 'arm64' ? `fnn_v${version}-aarch64-darwin-portable` : `fnn_v${version}-x86_64-darwin-portable`; + } + if (platform === 'win32') { + // Fiber only publishes x86_64 Windows packages. + return `fnn_v${version}-x86_64-windows`; + } + throw new Error(`Unsupported operating system for FNN: ${platform}`); +} + +export function buildFnnDownloadUrl(version: string): string { + const packageName = buildFnnPackageName(version); + return `https://github.com/nervosnetwork/fiber/releases/download/v${version}/${packageName}.tar.gz`; +} + +export function assertSupportedFnnVersion(version: string) { + if (!(SUPPORTED_FNN_VERSIONS as readonly string[]).includes(version)) { + throw new Error( + `FNN version ${version} is not supported by this offckb release. ` + + `Supported versions: ${SUPPORTED_FNN_VERSIONS.join(', ')}. ` + + 'To run a different FNN, use --binary-path with a locally built binary.', + ); + } +} + +// The release tarball must keep its full extracted layout: the bundled +// config/testnet/config.yml is the starting point for the devnet config. +function isInstallComplete(version: string, settings: Settings): boolean { + const configPath = getFnnBundledTestnetConfigPath(version, settings); + if (!fs.existsSync(getFnnBinaryPath(version, settings)) || !fs.existsSync(configPath)) return false; + try { + const parsed = yaml.load(fs.readFileSync(configPath, 'utf8')); + return parsed != null && typeof parsed === 'object'; + } catch { + return false; + } +} + +export async function downloadFnnAndUnzip(version: string, settings: Settings = readSettings()) { + const packageName = buildFnnPackageName(version); + const downloadURL = buildFnnDownloadUrl(version); + const tempFilePath = path.join(os.tmpdir(), `${packageName}.tar.gz`); + + logger.info(`downloading ${downloadURL} ..`); + const response = await Request.send(downloadURL); + const arrayBuffer = await response.arrayBuffer(); + fs.writeFileSync(tempFilePath, Buffer.from(arrayBuffer)); + + try { + const extractDir = path.join(settings.bins.downloadPath, `fnn_v${version}`); + fs.rmSync(extractDir, { recursive: true, force: true }); + await unZipFile(tempFilePath, extractDir, true); + + // FNN packages ship the binary and config/ flat at the tarball root (unlike + // CKB packages, which nest everything in a package-name directory); accept + // either layout. + const nestedPath = path.join(extractDir, packageName); + const sourcePath = fs.existsSync(nestedPath) ? nestedPath : extractDir; + if (!fs.existsSync(path.join(sourcePath, process.platform === 'win32' ? 'fnn.exe' : 'fnn'))) { + throw new Error(`FNN release package layout is unexpected: no fnn binary found in ${extractDir}.`); + } + const targetPath = getFnnInstallPath(version, settings); + fs.rmSync(targetPath, { recursive: true, force: true }); + fs.mkdirSync(targetPath, { recursive: true }); + for (const entry of fs.readdirSync(sourcePath)) { + fs.cpSync(path.join(sourcePath, entry), path.join(targetPath, entry), { recursive: true, force: true }); + } + fs.rmSync(extractDir, { recursive: true, force: true }); + if (process.platform !== 'win32') { + fs.chmodSync(getFnnBinaryPath(version, settings), '755'); + } + } finally { + // The tarball is only an intermediate; never leave it in the temp dir, + // whether the install succeeded or failed. + fs.rmSync(tempFilePath, { force: true }); + } + logger.info(`FNN ${version} installed successfully.`); +} + +/** + * Ensure a supported FNN release is installed. A cached install whose binary + * is missing, won't run, reports a different version, or lost its bundled + * testnet config is replaced by one fresh download; no retry loop. + */ +export async function installFnnBinary(version: string, settings: Settings = readSettings()) { + assertSupportedFnnVersion(version); + + const binPath = getFnnBinaryPath(version, settings); + const cachedVersion = getVersionFromBinary(binPath); + if (cachedVersion === version && isInstallComplete(version, settings)) { + return; + } + if (cachedVersion && cachedVersion !== version) { + logger.info(`Cached FNN version ${cachedVersion} does not match ${version}; downloading the release build.`); + } else if (!cachedVersion) { + logger.info(`FNN binary not found or unusable, downloading FNN ${version} ..`); + } else { + logger.info(`FNN ${version} installation is incomplete (missing bundled config); downloading again ..`); + } + await downloadFnnAndUnzip(version, settings); + + const installedVersion = getVersionFromBinary(binPath); + if (installedVersion !== version || !isInstallComplete(version, settings)) { + throw new Error( + `FNN ${version} was downloaded but the installed binary reports ` + + `${installedVersion ?? 'no usable version'}; installation failed.`, + ); + } +} + +/** + * Resolve the FNN binary and the testnet config used as the devnet config + * template. A user-supplied binary path skips download and version checks; + * its sibling config/testnet/config.yml is used when present (and must + * parse), otherwise the testnet config shipped with offckb is the fallback. + */ +export async function resolveFnnBinary( + options: { version?: string; binaryPath?: string }, + settings: Settings = readSettings(), +): Promise { + if (options.binaryPath) { + const fnnPath = options.binaryPath; + if (!fs.existsSync(fnnPath)) { + throw new Error(`FNN binary not found at ${fnnPath}`); + } + const siblingConfig = path.join(path.dirname(fnnPath), 'config', 'testnet', 'config.yml'); + let testnetConfigPath: string; + if (fs.existsSync(siblingConfig)) { + try { + const parsed = yaml.load(fs.readFileSync(siblingConfig, 'utf8')); + if (parsed == null || typeof parsed !== 'object') throw new Error('empty or non-object config'); + testnetConfigPath = siblingConfig; + } catch (error) { + throw new Error( + `The testnet config next to the FNN binary (${siblingConfig}) cannot be parsed: ${(error as Error).message}. ` + + 'Fix that file or remove it to fall back to the config shipped with offckb.', + ); + } + } else { + testnetConfigPath = bundledFiberTestnetConfigPath(); + if (!fs.existsSync(testnetConfigPath)) { + throw new Error(`Bundled FNN testnet config is missing at ${testnetConfigPath}.`); + } + logger.info(`No config/testnet/config.yml next to ${fnnPath}; using the testnet config shipped with offckb.`); + } + logger.info(`Using FNN testnet config: ${testnetConfigPath}`); + return { fnnPath, testnetConfigPath, source: 'binary-path', version: getVersionFromBinary(fnnPath) }; + } + + const version = options.version || settings.bins.defaultFnnVersion || DEFAULT_FNN_VERSION; + await installFnnBinary(version, settings); + return { + fnnPath: getFnnBinaryPath(version, settings), + testnetConfigPath: getFnnBundledTestnetConfigPath(version, settings), + source: 'download', + version, + }; +} diff --git a/src/fiber/manager.ts b/src/fiber/manager.ts new file mode 100644 index 00000000..537d1942 --- /dev/null +++ b/src/fiber/manager.ts @@ -0,0 +1,406 @@ +import { spawn, ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import { ccc } from '@ckb-ccc/core'; +import { callJsonRpc } from '../util/json-rpc'; +import { logger } from '../util/logger'; +import { readSettings, Settings } from '../cfg/setting'; +import { fiberNodePaths, fiberRpcUrl, fiberRpcPort, fiberP2pPort, fiberAccountIndex, runtimeJsonPath } from './paths'; +import { ensureNodesYml, FiberNodeEntry } from './nodes-yml'; +import { + ensureNodeKeyMaterial, + fiberNodeAccount, + readNodePassword, + fiberPublicKeyFromSecret, + readFiberNodeSecretKey, +} from './accounts'; +import { generateNodeConfig } from './config-gen'; +import { FiberChainScripts } from './scripts'; +import { fnnNodeInfo, fnnConnectPeer, fnnListPeers, checkPortFree, FnnNodeInfo } from './rpc'; +import { lockMatches } from './status'; +import { writeRuntime, readLiveRuntime, removeRuntimeFile, removeRuntimeFileIfStale, FiberRuntime } from './runtime'; +import { closeFileDescriptors } from '../util/daemon'; + +export interface FnnProcessHandle { + id: number; + process: ChildProcess; + rpcUrl: string; + dir: string; + logFile: string; +} + +export interface FiberEnvironment { + nodes: FnnProcessHandle[]; + nodeInfos: Map; + genesisHash: string; +} + +const FNN_RPC_TIMEOUT_MS = 90_000; +const STOP_GRACE_TIMEOUT_MS = 10_000; + +export class FiberStartupError extends Error { + constructor( + message: string, + public readonly startedNodes: FnnProcessHandle[] = [], + ) { + super(message); + this.name = 'FiberStartupError'; + } +} + +/** + * Refuse to touch the environment while another live OffCKB process manages + * FNNs (foreground or daemon). A leftover runtime record whose manager is + * dead is stale and discarded, never used to hunt processes. + */ +export function assertNoLiveFiberManager(settings: Settings = readSettings()) { + const live = readLiveRuntime(settings); + if (live) { + throw new Error( + `Fiber nodes are already managed by OffCKB process ${live.managerPid} (started ${live.startedAt || 'unknown'}). ` + + 'Stop that environment first (`offckb fiber stop` for a daemon, or Ctrl+C in its terminal).', + ); + } + removeRuntimeFileIfStale(settings); +} + +async function assertFiberPortsFree(nodes: FiberNodeEntry[]) { + const conflicts: string[] = []; + for (const node of nodes) { + const rpcPort = fiberRpcPort(node.id); + const p2pPort = fiberP2pPort(node.id); + if (!(await checkPortFree(rpcPort))) { + conflicts.push(`node ${node.id} RPC port ${rpcPort}`); + } + if (!(await checkPortFree(p2pPort))) { + conflicts.push(`node ${node.id} P2P port ${p2pPort}`); + } + } + if (conflicts.length > 0) { + throw new Error( + `Fiber port conflict: ${conflicts.join('; ')} ${conflicts.length === 1 ? 'is' : 'are'} already in use. ` + + 'OffCKB does not stop processes it did not start; free the port(s) or stop the program using them.', + ); + } +} + +function spawnFnn(node: FiberNodeEntry, fnnPath: string, settings: Settings): FnnProcessHandle { + const paths = fiberNodePaths(node.id, settings); + fs.mkdirSync(paths.dir, { recursive: true }); + const logFd = fs.openSync(paths.logFile, 'a'); + const password = readNodePassword(node.id, settings); + const child = spawn(fnnPath, ['-d', paths.dir], { + stdio: ['ignore', logFd, logFd], + env: { + ...process.env, + // FNN stays silent without an explicit filter (EnvFilter::from_default_env); + // respect a user-provided RUST_LOG, default to info otherwise. Its fmt + // layer writes ANSI colors unless NO_COLOR is present — keep the log + // files plain. + RUST_LOG: process.env.RUST_LOG ?? 'info', + NO_COLOR: process.env.NO_COLOR ?? '1', + FIBER_SECRET_KEY_PASSWORD: password, + LOG_PREFIX: `[fiber ${node.id}]`, + }, + }); + // The child's stdio owns the fd now; close our copy so the file is only + // held open by the FNN process. + closeFileDescriptors(logFd); + return { id: node.id, process: child, rpcUrl: fiberRpcUrl(node.id), dir: paths.dir, logFile: paths.logFile }; +} + +async function waitForAllNodeInfo(nodes: FnnProcessHandle[], timeoutMs: number): Promise> { + const start = Date.now(); + const infos = new Map(); + const pending = new Set(nodes.map((n) => n.id)); + const exited = new Map(); + for (const node of nodes) { + node.process.once('exit', (code, signal) => exited.set(node.id, { code, signal })); + node.process.once('error', () => exited.set(node.id, { code: null, signal: null })); + } + + while (pending.size > 0 && Date.now() - start < timeoutMs) { + for (const id of [...pending]) { + if (exited.has(id)) { + const node = nodes.find((n) => n.id === id)!; + throw new FiberStartupError( + `FNN node ${id} exited during startup (code=${exited.get(id)!.code ?? 'null'}, signal=${exited.get(id)!.signal ?? 'none'}). ` + + `See its log: ${node.logFile}`, + nodes, + ); + } + const node = nodes.find((n) => n.id === id)!; + try { + const info = await fnnNodeInfo(node.rpcUrl, 2000); + infos.set(id, info); + pending.delete(id); + } catch { + // RPC not up yet; keep polling while the child is alive. + } + } + if (pending.size > 0) { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + if (pending.size > 0) { + const idList = [...pending].join(', '); + const logs = nodes + .filter((n) => pending.has(n.id)) + .map((n) => n.logFile) + .join(', '); + throw new FiberStartupError(`Timed out waiting for FNN RPC of node(s) ${idList}. See log(s): ${logs}`, nodes); + } + return infos; +} + +async function assertChainConsistency( + nodes: FnnProcessHandle[], + nodeInfos: Map, + expectedGenesisHash: string, + settings: Settings, +) { + let ckbGenesis: string; + try { + ckbGenesis = String(await callJsonRpc(settings.devnet.rpcUrl, 'get_block_hash', ['0x0'], 5000)).toLowerCase(); + } catch (error) { + throw new FiberStartupError( + `Failed to read the genesis block hash from CKB RPC ${settings.devnet.rpcUrl}: ${(error as Error).message}`, + nodes, + ); + } + const expected = expectedGenesisHash.toLowerCase(); + if (ckbGenesis !== expected) { + throw new FiberStartupError( + `Chain mismatch: the running CKB node's genesis (${ckbGenesis}) differs from the devnet spec's (${expected}). ` + + 'The devnet chain data and its spec are out of sync; stop CKB and the FNNs, run `offckb clean`, and start again.', + nodes, + ); + } + for (const node of nodes) { + const info = nodeInfos.get(node.id)!; + if (String(info.chain_hash).toLowerCase() !== expected) { + throw new FiberStartupError( + `Chain mismatch: FNN node ${node.id} reports chain_hash ${info.chain_hash}, expected ${expected}.`, + nodes, + ); + } + } +} + +async function assertNodeIdentitiesAndFunds( + nodes: FnnProcessHandle[], + nodeInfos: Map, + settings: Settings, +) { + const client = new ccc.ClientPublicTestnet({ url: settings.devnet.rpcUrl, fallbacks: [] }); + for (const node of nodes) { + const info = nodeInfos.get(node.id)!; + + const secret = readFiberNodeSecretKey(node.id, settings); + if (secret == null) { + throw new FiberStartupError(`Fiber node ${node.id} has no usable fiber/sk identity key after startup.`, nodes); + } + const expectedPubkey = fiberPublicKeyFromSecret(secret); + if (String(info.pubkey).toLowerCase() !== expectedPubkey) { + throw new FiberStartupError( + `Fiber node ${node.id} reports an unexpected network identity (${info.pubkey}); ` + + 'the process answering on its RPC port is not the node OffCKB started.', + nodes, + ); + } + + const account = fiberNodeAccount(node.id); + const expectedLock = account.lockScript; + const actualLock = info.default_funding_lock_script; + if (!lockMatches(actualLock, expectedLock)) { + throw new FiberStartupError( + `Fiber node ${node.id} funds account mismatch: expected built-in account #${fiberAccountIndex(node.id)} ` + + `(lock args ${expectedLock.args}) but the node reports ${JSON.stringify(actualLock)}.`, + nodes, + ); + } + + let balance: bigint; + try { + balance = await client.getBalanceSingle(ccc.Script.from(account.lockScript as ccc.ScriptLike)); + } catch (error) { + throw new FiberStartupError( + `Failed to query the CKB balance of fiber node ${node.id}'s account: ${(error as Error).message}`, + nodes, + ); + } + if (balance <= BigInt(0)) { + throw new FiberStartupError( + `Fiber node ${node.id}'s CKB account #${fiberAccountIndex(node.id)} has no available CKB. ` + + 'Fund the account before starting Fiber.', + nodes, + ); + } + logger.info( + `Fiber node ${node.id}: account #${fiberAccountIndex(node.id)} balance ${ccc.fixedPointToString(balance)} CKB.`, + ); + } +} + +async function connectFiberPeers(nodes: FnnProcessHandle[], nodeInfos: Map) { + if (nodes.length < 2) return; + const [first, ...rest] = nodes; + for (const peer of rest) { + const info = nodeInfos.get(peer.id)!; + const address = info.addresses.find((addr) => addr.includes('/p2p/')); + if (!address) { + throw new FiberStartupError(`FNN node ${peer.id} did not announce a connectable address.`, nodes); + } + try { + await fnnConnectPeer(first.rpcUrl, address); + } catch (error) { + throw new FiberStartupError( + `Failed to connect fiber node ${first.id} to node ${peer.id} at ${address}: ${(error as Error).message}`, + nodes, + ); + } + } + // connect_peer returns once dialing starts; give the P2P handshake a moment + // to settle, then verify once with list_peers. + await new Promise((resolve) => setTimeout(resolve, 3000)); + const peers = await fnnListPeers(first.rpcUrl); + if (peers.length < rest.length) { + throw new FiberStartupError( + `Fiber node ${first.id} has ${peers.length} peer(s) after connect_peer, expected at least ${rest.length}.`, + nodes, + ); + } + logger.info(`Fiber node ${first.id} connected to ${rest.length} peer(s).`); +} + +export interface StartFiberEnvironmentOptions { + fnnPath: string; + testnetConfigPath: string; + chainScripts: FiberChainScripts; + nodeCount?: number; + settings?: Settings; +} + +/** + * The shared Fiber startup flow used by both `offckb fiber start` and + * `offckb node --fiber`: regenerate node configs from the current chain + * spec, spawn all FNNs with their own logs, wait for their RPCs, verify the + * chain/identity/account checks and interconnect the nodes. On any failure + * the FNNs started here are stopped again. + */ +export async function startFiberEnvironment(options: StartFiberEnvironmentOptions): Promise { + const settings = options.settings ?? readSettings(); + assertNoLiveFiberManager(settings); + + const nodes = ensureNodesYml(options.nodeCount, settings); + for (const node of nodes) { + const { created } = ensureNodeKeyMaterial(node.id, settings); + if (created) { + logger.info(`Fiber node ${node.id}: provisioned new CKB key and password.`); + } + generateNodeConfig({ + node, + chainScripts: options.chainScripts, + testnetConfigPath: options.testnetConfigPath, + settings, + }); + } + await assertFiberPortsFree(nodes); + + // Spawn incrementally: if a later spawn fails (e.g. mkdir/open EACCES or + // ENOSPC), the children started so far must not be left running without a + // runtime record — OffCKB would refuse to touch those orphans. + const handles: FnnProcessHandle[] = []; + try { + for (const node of nodes) { + handles.push(spawnFnn(node, options.fnnPath, settings)); + } + } catch (error) { + await stopFiberNodes(handles, settings); + throw error; + } + const runtime: FiberRuntime = { + managerPid: process.pid, + startedAt: new Date().toISOString(), + status: 'starting', + nodes: handles.map((handle) => ({ + id: handle.id, + pid: handle.process.pid ?? 0, + dir: handle.dir, + rpcUrl: handle.rpcUrl, + })), + }; + writeRuntime(runtime, settings); + + try { + const nodeInfos = await waitForAllNodeInfo(handles, FNN_RPC_TIMEOUT_MS); + await assertChainConsistency(handles, nodeInfos, options.chainScripts.genesisHash, settings); + await assertNodeIdentitiesAndFunds(handles, nodeInfos, settings); + await connectFiberPeers(handles, nodeInfos); + writeRuntime({ ...runtime, status: 'running' }, settings); + return { nodes: handles, nodeInfos, genesisHash: options.chainScripts.genesisHash }; + } catch (error) { + await stopFiberNodes(handles, settings); + if (error instanceof FiberStartupError) { + throw new FiberStartupError(error.message, []); + } + throw error; + } +} + +/** + * Stop the given FNN child processes: one SIGTERM, wait for exit, a single + * SIGKILL if the grace period expires. Removes runtime.json when this process + * is the recorded manager. Never touches processes it was not handed. + */ +export async function stopFiberNodes(nodes: FnnProcessHandle[], settings: Settings = readSettings()): Promise { + for (const node of nodes) { + if (node.process.exitCode == null && node.process.signalCode == null && !node.process.killed) { + try { + node.process.kill('SIGTERM'); + } catch { + // already gone + } + } + } + const deadline = Date.now() + STOP_GRACE_TIMEOUT_MS; + for (const node of nodes) { + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + await waitForChildExit(node.process, remaining); + } + for (const node of nodes) { + if (node.process.exitCode == null && node.process.signalCode == null) { + try { + node.process.kill('SIGKILL'); + } catch { + // already gone + } + } + } + removeRuntimeFileIfManager(settings); +} + +function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise { + // A signal-terminated child has exitCode === null with signalCode set; both + // mean "exited", and the exit event may already have fired. + if (child.exitCode != null || child.signalCode != null) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(), timeoutMs); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +function removeRuntimeFileIfManager(settings: Settings) { + try { + const raw = fs.readFileSync(runtimeJsonPath(settings), 'utf8'); + const parsed = JSON.parse(raw) as { managerPid?: number }; + if (parsed.managerPid === process.pid) { + removeRuntimeFile(settings); + } + } catch { + // no runtime file or unreadable — nothing to do + } +} diff --git a/src/fiber/nodes-yml.ts b/src/fiber/nodes-yml.ts new file mode 100644 index 00000000..9cc7f8ab --- /dev/null +++ b/src/fiber/nodes-yml.ts @@ -0,0 +1,149 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import yaml from 'js-yaml'; +import { nodesYmlPath, fiberNodeDir, MIN_FIBER_NODES, MAX_FIBER_NODES, DEFAULT_FIBER_NODES } from './paths'; +import { readSettings, Settings } from '../cfg/setting'; +import { logger } from '../util/logger'; + +export interface FiberNodeEntry { + id: number; + // Per-node FNN config overrides, merged on top of the generated config. + // Objects merge recursively, lists replace. + config: Record; +} + +// These fields are owned by offckb; setting them per node would break the +// environment in ways the startup checks cannot recover from. +export const MANAGED_CONFIG_PATHS = [ + 'fiber.chain', + 'fiber.scripts', + 'fiber.listening_addr', + 'fiber.bootnode_addrs', + 'rpc.listening_addr', + 'ckb.rpc_url', + 'ckb.udt_whitelist', + 'services', +]; + +function isPlainObject(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +export function validateNodeCount(count: number): number { + if (!Number.isInteger(count) || count < MIN_FIBER_NODES || count > MAX_FIBER_NODES) { + throw new Error(`--nodes must be an integer between ${MIN_FIBER_NODES} and ${MAX_FIBER_NODES}, got: ${count}`); + } + return count; +} + +function assertNoManagedFields(config: Record, nodeId: number) { + for (const dottedPath of MANAGED_CONFIG_PATHS) { + const segments = dottedPath.split('.'); + let current: unknown = config; + for (const segment of segments) { + if (!isPlainObject(current)) { + current = undefined; + break; + } + current = current[segment]; + } + if (current !== undefined) { + throw new Error( + `nodes.yml: node ${nodeId} sets "${dottedPath}", which is managed by offckb and cannot be overridden. ` + + `Managed fields: ${MANAGED_CONFIG_PATHS.join(', ')}.`, + ); + } + } +} + +export function readNodesYml(settings: Settings = readSettings()): FiberNodeEntry[] | null { + const file = nodesYmlPath(settings); + if (!fs.existsSync(file)) return null; + let parsed: unknown; + try { + parsed = yaml.load(fs.readFileSync(file, 'utf8')); + } catch (error) { + throw new Error(`Failed to parse ${file}: ${(error as Error).message}`); + } + if (!isPlainObject(parsed) || !Array.isArray(parsed.nodes)) { + throw new Error(`Invalid ${file}: expected a "nodes" list. Fix the file or remove the fiber environment.`); + } + const entries: FiberNodeEntry[] = parsed.nodes.map((raw: unknown, index: number) => { + if (!isPlainObject(raw) || !Number.isInteger(raw.id) || (raw.id as number) <= 0) { + throw new Error(`Invalid ${file}: nodes[${index}] must have a positive integer "id".`); + } + const config = raw.config == null ? {} : raw.config; + if (!isPlainObject(config)) { + throw new Error(`Invalid ${file}: nodes[${index}].config must be a mapping of FNN config fields.`); + } + return { id: raw.id as number, config: config as Record }; + }); + const ids = new Set(); + for (const entry of entries) { + if (ids.has(entry.id)) { + throw new Error(`Invalid ${file}: duplicate node id ${entry.id}.`); + } + if (entry.id > MAX_FIBER_NODES) { + throw new Error(`Invalid ${file}: node id ${entry.id} exceeds the maximum of ${MAX_FIBER_NODES}.`); + } + ids.add(entry.id); + assertNoManagedFields(entry.config, entry.id); + } + if (entries.length > MAX_FIBER_NODES) { + throw new Error(`Invalid ${file}: ${entries.length} nodes configured, at most ${MAX_FIBER_NODES} are supported.`); + } + return entries.sort((a, b) => a.id - b.id); +} + +export function writeNodesYml(entries: FiberNodeEntry[], settings: Settings = readSettings()) { + const file = nodesYmlPath(settings); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const doc = { + nodes: entries.map((entry) => ({ id: entry.id, config: entry.config })), + }; + fs.writeFileSync(file, yaml.dump(doc, { noRefs: true })); +} + +/** + * Resolve the node list for this start. Without an existing nodes.yml one is + * created from the requested count (default 2). With an existing file the + * stored list wins unless --nodes asks for a different count: surviving ids + * keep their per-node config, new ids start empty, and removed ids are + * reported (their directories are never deleted automatically). + */ +export function ensureNodesYml( + requestedCount: number | undefined, + settings: Settings = readSettings(), +): FiberNodeEntry[] { + const existing = readNodesYml(settings); + if (existing == null) { + const count = requestedCount == null ? DEFAULT_FIBER_NODES : validateNodeCount(requestedCount); + const entries = Array.from({ length: count }, (_, i) => ({ id: i + 1, config: {} })); + writeNodesYml(entries, settings); + logger.debug(`Created ${nodesYmlPath(settings)} with ${count} node(s).`); + return entries; + } + + if (requestedCount == null) return existing; + + const count = validateNodeCount(requestedCount); + if (count === existing.length && existing.every((entry, i) => entry.id === i + 1)) { + return existing; + } + + const byId = new Map(existing.map((entry) => [entry.id, entry])); + const next: FiberNodeEntry[] = []; + for (let id = 1; id <= count; id++) { + next.push(byId.get(id) ?? { id, config: {} }); + } + const removed = existing.filter((entry) => entry.id > count); + for (const entry of removed) { + logger.warn( + `Node ${entry.id} is removed from nodes.yml; its per-node config overrides are discarded. ` + + `Its directory ${fiberNodeDir(entry.id, settings)} is kept; ` + + 'delete it manually or run `offckb fiber clean` to remove it.', + ); + } + writeNodesYml(next, settings); + return next; +} diff --git a/src/fiber/paths.ts b/src/fiber/paths.ts new file mode 100644 index 00000000..4a93da5d --- /dev/null +++ b/src/fiber/paths.ts @@ -0,0 +1,138 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { packageRootPath, readSettings, Settings } from '../cfg/setting'; +import { isFolderExists } from '../util/fs'; + +// Directory layout of a Fiber devnet environment (see docs/fiber.md): +// +// /devnet/fiber/ +// ├── nodes.yml node list and per-node FNN config overrides +// ├── runtime.json current manager process and node addresses +// ├── logs/ fiber start --daemon manager log + pid +// └── nodes// +// ├── config.yml generated on every start, do not hand-edit +// ├── ckb/key CKB secret key (FNN encrypts it on first start) +// ├── fiber/sk Fiber network identity key, generated by FNN +// ├── fiber/store/ FNN database +// ├── fnn.log node stdout/stderr +// └── password random key-encryption password for this node +// +// The environment lock lives next to the devnet directory (not inside it) so +// `offckb clean`/`fiber clean` can hold it while deleting the whole tree. + +export const FIBER_DIR_NAME = 'fiber'; +export const NODES_YML_FILE = 'nodes.yml'; +export const RUNTIME_JSON_FILE = 'runtime.json'; +export const FIBER_LOGS_DIR = 'logs'; +export const FIBER_DAEMON_LOG_FILE = 'daemon.log'; +export const FIBER_DAEMON_PID_FILE = 'daemon.pid'; +export const ENV_LOCK_FILE = '.offckb-devnet.lock'; +export const NODE_CONFIG_FILE = 'config.yml'; +export const NODE_LOG_FILE = 'fnn.log'; +export const NODE_PASSWORD_FILE = 'password'; +export const NODE_CKB_DIR = 'ckb'; +export const NODE_CKB_KEY_FILE = 'key'; +export const NODE_FIBER_DIR = 'fiber'; +export const NODE_FIBER_SK_FILE = 'sk'; +export const NODE_FIBER_STORE_DIR = 'store'; +export const ROCKSDB_LOCK_FILE = 'LOCK'; + +export const MIN_FIBER_NODES = 1; +export const MAX_FIBER_NODES = 16; +export const DEFAULT_FIBER_NODES = 2; + +// Node N uses CKB account N+2 (accounts 3-18 are reserved for Fiber; account +// 19 deploys contracts and issues the test UDTs), RPC port 21713+N and P2P +// port 8343+N. +export const FIBER_ACCOUNT_OFFSET = 2; +export const FIBER_RPC_PORT_BASE = 21713; +export const FIBER_P2P_PORT_BASE = 8343; +export const UDT_ISSUER_ACCOUNT_INDEX = 19; + +export function fiberRootPath(settings: Settings = readSettings()): string { + return path.join(settings.devnet.configPath, FIBER_DIR_NAME); +} + +export function nodesYmlPath(settings: Settings = readSettings()): string { + return path.join(fiberRootPath(settings), NODES_YML_FILE); +} + +export function runtimeJsonPath(settings: Settings = readSettings()): string { + return path.join(fiberRootPath(settings), RUNTIME_JSON_FILE); +} + +export function fiberLogsPath(settings: Settings = readSettings()): string { + return path.join(fiberRootPath(settings), FIBER_LOGS_DIR); +} + +export function fiberDaemonPaths(settings: Settings = readSettings()) { + const logDir = fiberLogsPath(settings); + return { + logDir, + logFile: path.join(logDir, FIBER_DAEMON_LOG_FILE), + pidFile: path.join(logDir, FIBER_DAEMON_PID_FILE), + }; +} + +export function envLockPath(settings: Settings = readSettings()): string { + // Sibling of the devnet directory: /.offckb-devnet.lock + return path.join(path.dirname(settings.devnet.configPath), ENV_LOCK_FILE); +} + +export function fiberNodeDir(id: number, settings: Settings = readSettings()): string { + return path.join(fiberRootPath(settings), 'nodes', String(id)); +} + +// Ids of the node directories present under /nodes, in numeric order. +export function fiberNodeIds(settings: Settings = readSettings()): number[] { + const nodesDir = path.join(fiberRootPath(settings), 'nodes'); + if (!isFolderExists(nodesDir)) return []; + return fs + .readdirSync(nodesDir) + .filter((entry) => /^\d+$/.test(entry)) + .map((entry) => Number(entry)) + .sort((a, b) => a - b); +} + +export function fiberNodePaths(id: number, settings: Settings = readSettings()) { + const dir = fiberNodeDir(id, settings); + return { + dir, + configFile: path.join(dir, NODE_CONFIG_FILE), + logFile: path.join(dir, NODE_LOG_FILE), + passwordFile: path.join(dir, NODE_PASSWORD_FILE), + ckbDir: path.join(dir, NODE_CKB_DIR), + ckbKeyFile: path.join(dir, NODE_CKB_DIR, NODE_CKB_KEY_FILE), + fiberDir: path.join(dir, NODE_FIBER_DIR), + fiberSkFile: path.join(dir, NODE_FIBER_DIR, NODE_FIBER_SK_FILE), + fiberStoreDir: path.join(dir, NODE_FIBER_DIR, NODE_FIBER_STORE_DIR), + storeLockFile: path.join(dir, NODE_FIBER_DIR, NODE_FIBER_STORE_DIR, ROCKSDB_LOCK_FILE), + }; +} + +export function fiberAccountIndex(nodeId: number): number { + return nodeId + FIBER_ACCOUNT_OFFSET; +} + +export function fiberRpcPort(nodeId: number): number { + return FIBER_RPC_PORT_BASE + nodeId; +} + +export function fiberP2pPort(nodeId: number): number { + return FIBER_P2P_PORT_BASE + nodeId; +} + +export function fiberRpcUrl(nodeId: number): string { + return `http://127.0.0.1:${fiberRpcPort(nodeId)}`; +} + +export function fiberP2pAddr(nodeId: number): string { + return `/ip4/127.0.0.1/tcp/${fiberP2pPort(nodeId)}`; +} + +// Fallback FNN testnet config shipped with offckb, used when a local FNN +// binary has no sibling config/testnet/config.yml. The Makefile copies it +// from the pinned ckb/fiber submodule into the devnet specs. +export function bundledFiberTestnetConfigPath(): string { + return path.join(packageRootPath, 'ckb', 'devnet', 'specs', 'fiber', 'testnet-config.yml'); +} diff --git a/src/fiber/rpc.ts b/src/fiber/rpc.ts new file mode 100644 index 00000000..9f51f8eb --- /dev/null +++ b/src/fiber/rpc.ts @@ -0,0 +1,47 @@ +import * as net from 'net'; +import { callJsonRpc } from '../util/json-rpc'; + +export interface FnnNodeInfo { + version: string; + commit_hash: string; + pubkey: string; + node_name?: string | null; + addresses: string[]; + chain_hash: string; + default_funding_lock_script: { + code_hash: string; + hash_type: string; + args: string; + }; + peers_count: string | number; + channel_count: string | number; +} + +export interface FnnPeerInfo { + pubkey: string; + address: string; +} + +export async function fnnNodeInfo(rpcUrl: string, timeoutMs = 3000): Promise { + return (await callJsonRpc(rpcUrl, 'node_info', [], timeoutMs)) as FnnNodeInfo; +} + +export async function fnnConnectPeer(rpcUrl: string, address: string, save = true, timeoutMs = 10000): Promise { + await callJsonRpc(rpcUrl, 'connect_peer', [{ address, save }], timeoutMs); +} + +export async function fnnListPeers(rpcUrl: string, timeoutMs = 3000): Promise { + const result = (await callJsonRpc(rpcUrl, 'list_peers', [], timeoutMs)) as { peers?: FnnPeerInfo[] }; + return result?.peers ?? []; +} + +export function checkPortFree(port: number, host = '127.0.0.1'): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.unref(); + server.once('error', () => resolve(false)); + server.listen(port, host, () => { + server.close(() => resolve(true)); + }); + }); +} diff --git a/src/fiber/runtime.ts b/src/fiber/runtime.ts new file mode 100644 index 00000000..4c7df963 --- /dev/null +++ b/src/fiber/runtime.ts @@ -0,0 +1,93 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { runtimeJsonPath } from './paths'; +import { readSettings, Settings } from '../cfg/setting'; +import { isProcessAlive } from '../util/daemon'; +import { logger } from '../util/logger'; + +export interface RuntimeNodeInfo { + id: number; + pid: number; + dir: string; + rpcUrl: string; +} + +export interface FiberRuntime { + managerPid: number; + startedAt: string; + status: 'starting' | 'running'; + nodes: RuntimeNodeInfo[]; +} + +export function writeRuntime(runtime: FiberRuntime, settings: Settings = readSettings()) { + const file = runtimeJsonPath(settings); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(runtime, null, 2)); +} + +export function readRuntime(settings: Settings = readSettings()): FiberRuntime | null { + const file = runtimeJsonPath(settings); + let raw: string; + try { + raw = fs.readFileSync(file, 'utf8'); + } catch { + return null; + } + try { + const parsed = JSON.parse(raw) as Partial; + if (!Number.isInteger(parsed.managerPid) || !Array.isArray(parsed.nodes)) return null; + return { + managerPid: parsed.managerPid as number, + startedAt: String(parsed.startedAt ?? ''), + status: parsed.status === 'running' ? 'running' : 'starting', + nodes: (parsed.nodes as RuntimeNodeInfo[]).map((node) => ({ + id: Number(node.id), + pid: Number(node.pid), + dir: String(node.dir), + rpcUrl: String(node.rpcUrl), + })), + }; + } catch { + return null; + } +} + +/** + * A runtime record is only meaningful while its manager process exists. Once + * the manager is gone the record is stale — no further inspection of program + * paths, ports or versions (per the Fiber design: leftovers are discarded, + * never used to hunt processes). + */ +export function isRuntimeStale(runtime: FiberRuntime): boolean { + try { + return !isProcessAlive(runtime.managerPid); + } catch { + return true; + } +} + +export function readLiveRuntime(settings: Settings = readSettings()): FiberRuntime | null { + const runtime = readRuntime(settings); + if (runtime == null) return null; + if (isRuntimeStale(runtime)) return null; + return runtime; +} + +export function removeRuntimeFile(settings: Settings = readSettings()) { + try { + fs.unlinkSync(runtimeJsonPath(settings)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn(`Failed to remove ${runtimeJsonPath(settings)}: ${(error as Error).message}`); + } + } +} + +// Remove the runtime file only when its manager is confirmed dead — used by +// clean commands to discard records that can never be acted on again. +export function removeRuntimeFileIfStale(settings: Settings = readSettings()) { + const runtime = readRuntime(settings); + if (runtime != null && isRuntimeStale(runtime)) { + removeRuntimeFile(settings); + } +} diff --git a/src/fiber/scripts.ts b/src/fiber/scripts.ts new file mode 100644 index 00000000..b09829b8 --- /dev/null +++ b/src/fiber/scripts.ts @@ -0,0 +1,152 @@ +import { resolveDevnetSystemScripts } from '../scripts/private'; +import { SystemScript, SystemScriptName, SystemScriptsRecord } from '../scripts/type'; +import { udtIssuerLockHash } from './accounts'; + +export interface FnnCellDep { + cell_dep: { + out_point: { + tx_hash: string; + // ckb_jsonrpc_types serializes uint32 as hex strings. + index: string; + }; + dep_type: 'code'; + }; +} + +export interface FnnFiberScript { + name: 'FundingLock' | 'CommitmentLock'; + script: { + code_hash: string; + hash_type: string; + args: string; + }; + cell_deps: FnnCellDep[]; +} + +export interface FnnUdtInfo { + name: string; + script: { + code_hash: string; + hash_type: string; + // FNN treats UDT args as a regex matched against the candidate cell's + // type args; anchor the full issuer-derived args, never a 0x.* wildcard. + args: string; + }; + cell_deps: FnnCellDep[]; +} + +export interface FiberChainScripts { + genesisHash: string; + fiberScripts: FnnFiberScript[]; + udtWhitelist: FnnUdtInfo[]; +} + +function codeCellDep(script: SystemScript): FnnCellDep { + const dep = script.script.cellDeps.find((d) => d.cellDep.depType === 'code'); + if (!dep) { + throw new Error(`System script ${script.name} has no code cell dep in list-hashes output.`); + } + return { + cell_dep: { + out_point: { + tx_hash: dep.cellDep.outPoint.txHash, + index: `0x${dep.cellDep.outPoint.index.toString(16)}`, + }, + dep_type: 'code', + }, + }; +} + +function requireScript(scripts: SystemScriptsRecord, name: SystemScriptName): SystemScript { + const script = scripts[name]; + if (script == null) { + throw new Error( + `The devnet chain spec does not include the system script "${name}". Run \`offckb clean\` to rebuild the devnet.`, + ); + } + return script; +} + +export class FiberContractsMissingError extends Error { + public readonly missing: string[]; + constructor(missing: string[]) { + super(`The devnet chain spec does not include the Fiber contracts: ${missing.join(', ')}.`); + this.name = 'FiberContractsMissingError'; + this.missing = missing; + } +} + +/** + * Build the FNN `fiber.scripts` and `ckb.udt_whitelist` sections from one + * `ckb list-hashes` run against the actual devnet directory. FundingLock and + * CommitmentLock each depend on their own contract cell plus the shared auth + * cell; both test UDTs are issued by built-in account 19, so their whitelist + * args anchor to that account's lock hash. + */ +export function resolveFiberChainScripts(): FiberChainScripts { + const resolved = resolveDevnetSystemScripts(); + if (resolved == null) { + throw new Error( + 'Failed to read the devnet chain spec hashes (ckb list-hashes). Is the CKB binary installed and the devnet initialized?', + ); + } + const scripts = resolved.scripts; + + const required = [SystemScriptName.auth, SystemScriptName.funding_lock, SystemScriptName.commitment_lock]; + const missing = required.filter((name) => scripts[name] == null); + if (missing.length > 0) { + throw new FiberContractsMissingError(missing); + } + + const auth = requireScript(scripts, SystemScriptName.auth); + const fundingLock = requireScript(scripts, SystemScriptName.funding_lock); + const commitmentLock = requireScript(scripts, SystemScriptName.commitment_lock); + const sudt = requireScript(scripts, SystemScriptName.sudt); + const xudt = requireScript(scripts, SystemScriptName.xudt); + + const authDep = codeCellDep(auth); + const fiberScripts: FnnFiberScript[] = [ + { + name: 'FundingLock', + script: { + code_hash: fundingLock.script.codeHash, + hash_type: fundingLock.script.hashType, + args: '0x', + }, + cell_deps: [codeCellDep(fundingLock), authDep], + }, + { + name: 'CommitmentLock', + script: { + code_hash: commitmentLock.script.codeHash, + hash_type: commitmentLock.script.hashType, + args: '0x', + }, + cell_deps: [codeCellDep(commitmentLock), authDep], + }, + ]; + + const issuerArgsPattern = `^${udtIssuerLockHash()}$`; + const udtWhitelist: FnnUdtInfo[] = [ + { + name: 'sudt', + script: { + code_hash: sudt.script.codeHash, + hash_type: sudt.script.hashType, + args: issuerArgsPattern, + }, + cell_deps: [codeCellDep(sudt)], + }, + { + name: 'xudt', + script: { + code_hash: xudt.script.codeHash, + hash_type: xudt.script.hashType, + args: issuerArgsPattern, + }, + cell_deps: [codeCellDep(xudt)], + }, + ]; + + return { genesisHash: resolved.genesisHash, fiberScripts, udtWhitelist }; +} diff --git a/src/fiber/status.ts b/src/fiber/status.ts new file mode 100644 index 00000000..e9eba12d --- /dev/null +++ b/src/fiber/status.ts @@ -0,0 +1,237 @@ +import { checkNodeReadiness } from '../devnet/readiness'; +import { getProcessCommandLine, isProcessAlive, nodeDaemonPaths, readPidFile } from '../util/daemon'; +import { readSettings, Settings } from '../cfg/setting'; +import { fiberAccountIndex, fiberDaemonPaths, fiberP2pAddr, fiberRpcUrl } from './paths'; +import { readNodesYml } from './nodes-yml'; +import { fiberNodeAccount, fiberPublicKeyFromSecret, readFiberNodeSecretKey } from './accounts'; +import { readRuntime, FiberRuntime } from './runtime'; +import { fnnNodeInfo, FnnNodeInfo } from './rpc'; +import { logger } from '../util/logger'; + +export type FiberNodeStatus = 'starting' | 'running' | 'stopped' | 'unknown' | 'conflict'; +export type OffckbManaged = 'yes' | 'no' | 'unknown'; + +export interface FiberNodeStatusEntry { + id: number; + status: FiberNodeStatus; + offckb: OffckbManaged; + rpcUrl: string; + p2pAddr: string; + accountIndex: number; + reasons: string[]; + version?: string; + commitHash?: string; + chainHash?: string; + pubkey?: string; +} + +export interface FiberStatusReport { + ckb: { + status: 'running' | 'stopped'; + rpcUrl: string; + proxyUrl: string; + error?: string; + }; + nodes: FiberNodeStatusEntry[]; +} + +async function resolveOffckbManaged(runtime: FiberRuntime | null, settings: Settings): Promise { + if (runtime == null) return 'no'; + let alive: boolean; + try { + alive = isProcessAlive(runtime.managerPid); + } catch { + return 'unknown'; + } + if (!alive) return 'no'; + + const cmdline = await getProcessCommandLine(runtime.managerPid); + if (cmdline == null) return 'unknown'; + if (!cmdline.includes('offckb')) return 'no'; + + // A daemon PID file that claims fiber management must agree with the + // runtime record: the fiber daemon PID file always claims it, the node + // daemon PID file only when the fiber manager IS the node daemon + // (node --fiber --daemon). An unrelated CKB daemon does not disqualify. + const fiberPid = readPidFile(fiberDaemonPaths(settings).pidFile); + if (fiberPid != null && fiberPid.pid !== runtime.managerPid) return 'no'; + if (fiberPid == null) { + const nodePid = readPidFile(nodeDaemonPaths(settings).pidFile); + if (nodePid != null && nodePid.pid === runtime.managerPid) return 'yes'; + } + return 'yes'; +} + +// Case-insensitive comparison of an FNN-reported funding lock against the +// expected CKB account lock. Shared by the status report and the manager's +// startup validation so the comparison rules cannot diverge. +export function lockMatches( + actual: { code_hash: string; hash_type: string; args: string } | undefined, + expected: { codeHash: string; hashType: string; args: string }, +): boolean { + return ( + actual != null && + actual.code_hash.toLowerCase() === expected.codeHash.toLowerCase() && + actual.hash_type.toLowerCase() === expected.hashType.toLowerCase() && + actual.args.toLowerCase() === expected.args.toLowerCase() + ); +} + +/** + * Check the live state of the devnet and every configured FNN. Status is + * derived only from this moment's RPC answers and key material — no + * list-hashes, no genesis comparison, no port/PID/process inspection (the + * OFFCKB column is the single exception, and it never changes the status). + */ +export async function collectFiberStatus(settings: Settings = readSettings()): Promise { + const ckbReadiness = await checkNodeReadiness(settings.devnet.rpcUrl, 2000); + const report: FiberStatusReport = { + ckb: { + status: ckbReadiness.ready ? 'running' : 'stopped', + rpcUrl: settings.devnet.rpcUrl, + proxyUrl: `http://127.0.0.1:${settings.devnet.rpcProxyPort}`, + ...(ckbReadiness.ready ? {} : { error: ckbReadiness.error ?? 'unavailable' }), + }, + nodes: [], + }; + + const entries = readNodesYml(settings); + if (entries == null) return report; + + const runtime = readRuntime(settings); + const offckb = await resolveOffckbManaged(runtime, settings); + const managerStarting = runtime != null && offckb === 'yes' && runtime.status === 'starting'; + + for (const entry of entries) { + const statusEntry: FiberNodeStatusEntry = { + id: entry.id, + status: 'unknown', + offckb, + rpcUrl: fiberRpcUrl(entry.id), + p2pAddr: fiberP2pAddr(entry.id), + accountIndex: fiberAccountIndex(entry.id), + reasons: [], + }; + report.nodes.push(statusEntry); + + let info: FnnNodeInfo | null = null; + try { + info = await fnnNodeInfo(statusEntry.rpcUrl, 2000); + } catch { + info = null; + } + + if (info == null) { + statusEntry.status = managerStarting ? 'starting' : 'stopped'; + continue; + } + + statusEntry.version = info.version; + statusEntry.commitHash = info.commit_hash; + statusEntry.chainHash = info.chain_hash; + statusEntry.pubkey = typeof info.pubkey === 'string' ? info.pubkey : undefined; + + const secret = readFiberNodeSecretKey(entry.id, settings); + if (secret == null) { + statusEntry.status = 'unknown'; + statusEntry.reasons.push('cannot read the node identity key (fiber/sk); node may not have started yet'); + continue; + } + const expectedPubkey = fiberPublicKeyFromSecret(secret); + const account = fiberNodeAccount(entry.id); + + let conflict = false; + if (typeof info.pubkey !== 'string' || info.pubkey.length === 0) { + statusEntry.reasons.push('node_info did not return a node public key'); + } else if (info.pubkey.toLowerCase() !== expectedPubkey) { + conflict = true; + statusEntry.reasons.push( + `node public key mismatch: expected ${expectedPubkey} (from fiber/sk), got ${info.pubkey}`, + ); + } + if (!info.default_funding_lock_script) { + statusEntry.reasons.push('node_info did not return default_funding_lock_script'); + } else if (!lockMatches(info.default_funding_lock_script, account.lockScript)) { + conflict = true; + statusEntry.reasons.push( + `CKB account mismatch: expected account #${statusEntry.accountIndex} (lock args ${account.lockScript.args}), ` + + `got ${JSON.stringify(info.default_funding_lock_script)}`, + ); + } + + if (conflict) { + statusEntry.status = 'conflict'; + } else if (statusEntry.reasons.length > 0) { + statusEntry.status = 'unknown'; + } else { + statusEntry.status = 'running'; + } + } + + return report; +} + +function pad(value: string, width: number): string { + return value.length >= width ? value : value + ' '.repeat(width - value.length); +} + +export function printFiberStatus(report: FiberStatusReport) { + const ckbLine = [ + pad('CKB', 10), + pad(report.ckb.status, 9), + `RPC ${report.ckb.rpcUrl}`, + `PROXY ${report.ckb.proxyUrl}`, + ].join(' '); + logger.info(ckbLine); + if (report.ckb.error) { + logger.info(` ${report.ckb.error}`); + } + logger.info(''); + + if (report.nodes.length === 0) { + logger.info('No fiber environment found (no fiber/nodes.yml). Start one with: offckb fiber start'); + return; + } + + const header = ['NODE', 'STATUS', 'OFFCKB', 'RPC', 'P2P', 'ACCOUNT', 'VERSION', 'COMMIT']; + const widths = [6, 9, 8, 26, 26, 9, 12, 10]; + logger.info(header.map((cell, i) => pad(cell, widths[i])).join(' ')); + for (const node of report.nodes) { + const row = [ + pad(String(node.id), widths[0]), + pad(node.status, widths[1]), + pad(node.offckb, widths[2]), + pad(node.rpcUrl, widths[3]), + pad(node.p2pAddr, widths[4]), + pad(String(node.accountIndex), widths[5]), + pad(node.version ?? '-', widths[6]), + pad(node.commitHash ? node.commitHash.slice(0, 7) : '-', widths[7]), + ].join(' '); + logger.info(row); + for (const reason of node.reasons) { + logger.info(` ! ${reason}`); + } + } +} + +export async function fiberStatus(settings: Settings = readSettings()) { + const report = await collectFiberStatus(settings); + printFiberStatus(report); + logger.result({ + command: 'fiber.status', + ckb: report.ckb, + nodes: report.nodes.map((node) => ({ + id: node.id, + status: node.status, + offckbManaged: node.offckb, + rpcUrl: node.rpcUrl, + p2pAddr: node.p2pAddr, + accountIndex: node.accountIndex, + version: node.version, + commitHash: node.commitHash, + chainHash: node.chainHash, + pubkey: node.pubkey, + reasons: node.reasons, + })), + }); +} diff --git a/src/fiber/store-lock.ts b/src/fiber/store-lock.ts new file mode 100644 index 00000000..ed89f696 --- /dev/null +++ b/src/fiber/store-lock.ts @@ -0,0 +1,70 @@ +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; + +/** + * Whether a RocksDB LOCK file is still held by a process. Returns null when + * the check cannot be performed (missing lsof, inspection error), so callers + * can refuse instead of guessing. + * + * RocksDB keeps the LOCK file open (and fcntl-locked) for the store's whole + * lifetime, so "held open by a process" is the signal. Windows has no lsof; + * there a self-rename fails while a process holds the file. + */ +export function isStoreLockHeld(lockFile: string): boolean | null { + if (!fs.existsSync(lockFile)) { + // No lock file means no store was ever opened (or it was removed); + // nothing is holding it. + return false; + } + if (process.platform === 'win32') { + try { + fs.renameSync(lockFile, lockFile); + return false; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === 'EPERM' || err.code === 'EBUSY') return true; + return null; + } + } + try { + const stdout = execFileSync('lsof', ['--', lockFile], { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + encoding: 'utf8', + }); + // Exit 0: lsof printed every process holding the file on stdout. + return stdout.trim().length > 0; + } catch (error) { + const err = error as NodeJS.ErrnoException & { + stdout?: Buffer | string; + status?: number | null; + signal?: NodeJS.Signals | null; + }; + if (err.code === 'ENOENT' || err.code === 'ETIMEDOUT') return null; + // Only exit 1 is a genuine "no holder" answer — matches are printed on + // stdout; stderr may carry unrelated warnings (e.g. an un-stat-able fuse + // mount), so only stdout decides. A timeout kill (signal set) or any + // other exit status is an inspection failure: report "unknown" (null) + // rather than "free" (false), so cleanup refuses instead of deleting a + // live store's metadata. + if (err.signal != null || err.status !== 1) return null; + const stdout = typeof err.stdout === 'string' ? err.stdout.trim() : null; + return stdout == null ? null : stdout.length > 0; + } +} + +export async function waitForStoreLocksReleased(lockFiles: string[], timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + let allReleased = true; + for (const lockFile of lockFiles) { + if (isStoreLockHeld(lockFile) !== false) { + allReleased = false; + break; + } + } + if (allReleased) return true; + await new Promise((resolve) => setTimeout(resolve, 300)); + } + return false; +} diff --git a/src/scripts/public.ts b/src/scripts/public.ts index b92057bf..fbb2fa8b 100644 --- a/src/scripts/public.ts +++ b/src/scripts/public.ts @@ -196,6 +196,9 @@ export const TESTNET_SYSTEM_SCRIPTS: SystemScriptsRecord = { ], }, }, + auth: undefined, + funding_lock: undefined, + commitment_lock: undefined, }; export const MAINNET_SYSTEM_SCRIPTS: SystemScriptsRecord = { @@ -321,6 +324,9 @@ export const MAINNET_SYSTEM_SCRIPTS: SystemScriptsRecord = { ], }, }, + auth: undefined, + funding_lock: undefined, + commitment_lock: undefined, }; export default { diff --git a/src/scripts/type.ts b/src/scripts/type.ts index 36be5286..7299a413 100644 --- a/src/scripts/type.ts +++ b/src/scripts/type.ts @@ -20,6 +20,9 @@ export enum SystemScriptName { secp256k1_keccak256_sighash_all = 'secp256k1_keccak256_sighash_all', secp256k1_keccak256_sighash_all_acpl = 'secp256k1_keccak256_sighash_all_acpl', secp256k1_blake160_multisig_all_v2 = 'secp256k1_blake160_multisig_all_v2', + auth = 'auth', + funding_lock = 'funding_lock', + commitment_lock = 'commitment_lock', } export interface ScriptInfo { diff --git a/src/util/daemon.ts b/src/util/daemon.ts new file mode 100644 index 00000000..f79d1449 --- /dev/null +++ b/src/util/daemon.ts @@ -0,0 +1,266 @@ +import { execFile, spawn } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { logger } from './logger'; +import { readSettings, Settings } from '../cfg/setting'; + +// Layout of the CKB devnet daemon's log/PID files under the devnet data dir. +export const NODE_DAEMON_LOG_DIR = 'logs'; +export const NODE_DAEMON_LOG_FILE = 'daemon.log'; +export const NODE_DAEMON_PID_FILE = 'daemon.pid'; + +export function nodeDaemonPaths(settings: Settings = readSettings()) { + const logDir = path.join(settings.devnet.dataPath, NODE_DAEMON_LOG_DIR); + return { + logDir, + logFile: path.join(logDir, NODE_DAEMON_LOG_FILE), + pidFile: path.join(logDir, NODE_DAEMON_PID_FILE), + }; +} + +export interface PidMetadata { + pid: number; + scriptPath: string; + startedAt: string; + status?: 'starting' | 'running'; +} + +export function readPidFile(pidFile: string): PidMetadata | null { + let raw: string; + try { + raw = fs.readFileSync(pidFile, 'utf8').trim(); + } catch { + // Treat a missing or unreadable PID file as "no daemon". + return null; + } + + if (!raw) { + return null; + } + + // Backward compatibility: plain integer PID written by older versions. + const plainPid = Number(raw); + if (Number.isInteger(plainPid) && plainPid > 0) { + return { pid: plainPid, scriptPath: resolveCliEntry() ?? '', startedAt: new Date(0).toISOString() }; + } + + try { + const parsed = JSON.parse(raw) as Partial; + const pid = Number(parsed.pid); + if (Number.isInteger(pid) && pid > 0 && typeof parsed.scriptPath === 'string') { + return { + pid, + scriptPath: parsed.scriptPath, + startedAt: parsed.startedAt ?? new Date(0).toISOString(), + status: parsed.status, + }; + } + } catch { + // fall through to sentinel below + } + + // Content exists but is neither a valid plain PID nor valid metadata. + // Return a sentinel so stop commands can report an invalid PID and clean up. + return { pid: NaN, scriptPath: '', startedAt: new Date(0).toISOString() }; +} + +export function writePidFile(pidFile: string, metadata: PidMetadata) { + fs.writeFileSync(pidFile, JSON.stringify(metadata, null, 2)); +} + +export function reservePidFile(pidFile: string, scriptPath: string): void { + let fd: number; + try { + fd = fs.openSync(pidFile, 'wx'); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === 'EEXIST') { + throw new Error('A daemon startup is already in progress. Try again after it completes.'); + } + throw new Error(`Failed to reserve daemon PID file ${pidFile}: ${err.message}`); + } + + let writeError: Error | undefined; + try { + const reservation: PidMetadata = { + pid: process.pid, + scriptPath, + startedAt: new Date().toISOString(), + status: 'starting', + }; + fs.writeFileSync(fd, JSON.stringify(reservation, null, 2)); + } catch (error) { + writeError = error as Error; + } finally { + fs.closeSync(fd); + } + if (writeError) { + cleanupPidFile(pidFile); + throw new Error(`Failed to initialize daemon PID reservation ${pidFile}: ${writeError.message}`); + } +} + +export function resolveCliEntry(): string | null { + // In priority order. process.argv[1] is the most reliable for a Node CLI. + // OFFCKB_CLI_PATH is an escape hatch for packaged/npx/weird environments. + // require.main?.filename is a final fallback when argv is unavailable. + const candidates = [process.env.OFFCKB_CLI_PATH, process.argv[1], require.main?.filename].filter( + (c): c is string => typeof c === 'string' && c.length > 0, + ); + + for (const candidate of candidates) { + try { + const resolved = path.resolve(candidate); + const stats = fs.statSync(resolved); + if (stats.isFile()) { + return resolved; + } + } catch { + // Candidate is missing or not a file; try the next one. + } + } + + return null; +} + +export function isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === 'ESRCH') return false; + if (err.code === 'EPERM') throw new Error(`Permission denied when checking daemon process ${pid}.`); + throw error; + } +} + +export function cleanupPidFile(pidFile: string) { + try { + fs.unlinkSync(pidFile); + } catch (error) { + // Already gone (e.g. the manager removed it before the stopper could) is + // the goal state, not a problem. + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + logger.warn(`Failed to remove PID file:`, error); + } +} + +export function waitForProcessExit(pid: number, timeoutMs: number): Promise { + const start = Date.now(); + return new Promise((resolve, reject) => { + const check = () => { + try { + if (!isProcessAlive(pid)) { + resolve(true); + return; + } + } catch (error) { + reject(error); + return; + } + if (Date.now() - start >= timeoutMs) { + resolve(false); + return; + } + setTimeout(check, 100); + }; + check(); + }); +} + +export function getProcessCommandLine(pid: number): Promise { + return new Promise((resolve) => { + if (!Number.isInteger(pid) || pid <= 0) { + resolve(null); + return; + } + // Argument arrays, never an interpolated shell string: pid is validated as + // a positive integer above, and execFile keeps that true after any future + // refactor. + if (process.platform === 'win32') { + // wmic is deprecated and absent from recent Windows builds; the + // PowerShell CIM cmdlets ship with every supported Windows version. + execFile( + 'powershell', + ['-NoProfile', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`], + (error, stdout) => { + if (error) { + resolve(null); + return; + } + const cmdline = stdout.trim(); + resolve(cmdline.length > 0 ? cmdline : null); + }, + ); + return; + } + execFile('ps', ['-p', String(pid), '-o', 'args='], (error, stdout) => { + if (error) { + resolve(null); + return; + } + resolve(stdout.trim()); + }); + }); +} + +export async function verifyDaemonIdentity(pid: number, metadata: PidMetadata): Promise { + const cmdline = await getProcessCommandLine(pid); + if (!cmdline) { + return false; + } + + // The daemon child re-runs the same CLI entry point, so its command line + // should reference the same script and should be a Node process. + const scriptName = path.basename(metadata.scriptPath); + const scriptDir = path.dirname(metadata.scriptPath); + const looksLikeNode = cmdline.includes('node') || cmdline.includes('nodejs'); + const looksLikeOurScript = + cmdline.includes(metadata.scriptPath) || (scriptName !== '' && cmdline.includes(scriptName)); + const looksLikeOffckb = cmdline.includes('offckb') || scriptDir.includes('offckb'); + + return looksLikeNode && (looksLikeOurScript || looksLikeOffckb); +} + +export function terminateProcess(pid: number, signal: 'SIGTERM' | 'SIGKILL'): Promise { + return new Promise((resolve, reject) => { + if (process.platform === 'win32') { + // Windows has no POSIX signals and process.kill(pid) only terminates the + // single process. Use taskkill to terminate the whole tree. + // /T kills the process and all child processes. + // /F forces termination when SIGKILL is requested. + const args = signal === 'SIGKILL' ? ['/T', '/F', '/PID', String(pid)] : ['/T', '/PID', String(pid)]; + const taskkill = spawn('taskkill', args, { stdio: 'ignore' }); + taskkill.on('error', reject); + taskkill.on('exit', () => { + // taskkill may return non-zero if the process is already gone, which + // is acceptable for our purposes. + resolve(); + }); + return; + } + + // On POSIX, detached: true makes the child a session/process group leader. + // A negative pid sends the signal to the entire process group, ensuring + // the managed child processes all receive it. + try { + process.kill(-pid, signal); + resolve(); + } catch (error) { + reject(error); + } + }); +} + +export function closeFileDescriptors(...fds: (number | undefined)[]) { + for (const fd of fds) { + if (fd === undefined) continue; + try { + fs.closeSync(fd); + } catch { + // ignore + } + } +} diff --git a/tests/fiber-accounts.test.ts b/tests/fiber-accounts.test.ts new file mode 100644 index 00000000..5e23aa64 --- /dev/null +++ b/tests/fiber-accounts.test.ts @@ -0,0 +1,97 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { defaultSettings, Settings } from '../src/cfg/setting'; +import { fiberNodePaths } from '../src/fiber/paths'; +import { + ensureNodeKeyMaterial, + fiberNodeAccount, + fiberPublicKeyFromSecret, + readFiberNodeSecretKey, + readNodePassword, + udtIssuerAccount, + udtIssuerLockHash, +} from '../src/fiber/accounts'; + +const tempRoots: string[] = []; +afterEach(() => { + while (tempRoots.length) fs.rmSync(tempRoots.pop() as string, { recursive: true, force: true }); +}); + +function fixture(): Settings { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fiber-keys-')); + tempRoots.push(root); + const settings = JSON.parse(JSON.stringify(defaultSettings)) as Settings; + settings.devnet.configPath = path.join(root, 'devnet'); + return settings; +} + +describe('fiber accounts', () => { + it('maps node N to built-in account N+2', () => { + expect(fiberNodeAccount(1).args).toBe('0xe65f823bc5a48a38515690604e503dba4eb15d61'); // account #3 + expect(fiberNodeAccount(2).args).toBe('0x9665e6bc1966ec2bfcca4f11782d2b906f38438f'); // account #4 + }); + + it('uses account 19 as the UDT issuer with a stable lock hash', () => { + expect(udtIssuerAccount().args).toBe('0x4118c8c16749bf126b22468d030bf9de7da3717b'); + expect(udtIssuerLockHash()).toMatch(/^0x[0-9a-f]{64}$/); + expect(udtIssuerLockHash()).toBe('0x4472b33b4e1845ebe82f2ce5f511bbe012f144c5f3d7b539909adffc83ccda61'); + }); +}); + +describe('ensureNodeKeyMaterial', () => { + it('writes the CKB key (hex, no 0x) and a random password, both owner-only', () => { + const settings = fixture(); + const { created } = ensureNodeKeyMaterial(1, settings); + expect(created).toBe(true); + + const paths = fiberNodePaths(1, settings); + const key = fs.readFileSync(paths.ckbKeyFile, 'utf8'); + expect(key).toBe(fiberNodeAccount(1).privkey.replace(/^0x/, '')); + expect(key.startsWith('0x')).toBe(false); + + const password = readNodePassword(1, settings); + expect(password.length).toBeGreaterThan(16); + + if (process.platform !== 'win32') { + expect(fs.statSync(paths.ckbKeyFile).mode & 0o777).toBe(0o600); + expect(fs.statSync(paths.passwordFile).mode & 0o777).toBe(0o600); + } + }); + + it('keeps existing key material on later starts', () => { + const settings = fixture(); + ensureNodeKeyMaterial(1, settings); + const paths = fiberNodePaths(1, settings); + const password = fs.readFileSync(paths.passwordFile, 'utf8'); + + const { created } = ensureNodeKeyMaterial(1, settings); + expect(created).toBe(false); + expect(fs.readFileSync(paths.passwordFile, 'utf8')).toBe(password); + }); + + it('refuses to provision a node with half-missing key material', () => { + const settings = fixture(); + ensureNodeKeyMaterial(1, settings); + fs.unlinkSync(fiberNodePaths(1, settings).passwordFile); + expect(() => ensureNodeKeyMaterial(1, settings)).toThrow('incomplete key material'); + }); +}); + +describe('fiber identity key', () => { + it('derives the compressed pubkey from a raw 32-byte secret', () => { + // account #3's known privkey/pubkey pair doubles as a test vector. + const secret = Buffer.from(fiberNodeAccount(1).privkey.replace(/^0x/, ''), 'hex'); + expect(fiberPublicKeyFromSecret(secret)).toBe(fiberNodeAccount(1).pubkey.replace(/^0x/, '').toLowerCase()); + }); + + it('reads fiber/sk as raw bytes and tolerates a missing file', () => { + const settings = fixture(); + expect(readFiberNodeSecretKey(1, settings)).toBeNull(); + + const paths = fiberNodePaths(1, settings); + fs.mkdirSync(paths.fiberDir, { recursive: true }); + fs.writeFileSync(paths.fiberSkFile, Buffer.alloc(32, 7)); + expect(readFiberNodeSecretKey(1, settings)).toEqual(Buffer.alloc(32, 7)); + }); +}); diff --git a/tests/fiber-config-gen.test.ts b/tests/fiber-config-gen.test.ts new file mode 100644 index 00000000..1ef448e9 --- /dev/null +++ b/tests/fiber-config-gen.test.ts @@ -0,0 +1,152 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import yaml from 'js-yaml'; +import { defaultSettings, Settings } from '../src/cfg/setting'; +import { fiberNodePaths } from '../src/fiber/paths'; +import { generateNodeConfig, mergeNodeConfig } from '../src/fiber/config-gen'; +import { FiberChainScripts } from '../src/fiber/scripts'; + +const tempRoots: string[] = []; +afterEach(() => { + while (tempRoots.length) fs.rmSync(tempRoots.pop() as string, { recursive: true, force: true }); +}); + +function fixture(): { settings: Settings; testnetConfigPath: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fiber-config-')); + tempRoots.push(root); + const settings = JSON.parse(JSON.stringify(defaultSettings)) as Settings; + settings.devnet.configPath = path.join(root, 'devnet'); + settings.devnet.rpcUrl = 'http://127.0.0.1:8114'; + + const testnetConfigPath = path.join(root, 'testnet-config.yml'); + fs.writeFileSync( + testnetConfigPath, + [ + 'fiber:', + ' listening_addr: "/ip4/0.0.0.0/tcp/8228"', + ' bootnode_addrs:', + ' - "/ip4/54.179.226.154/tcp/8228/p2p/Qmes1EBD4yNo9Ywkfe6eRw9tG1nVNGLDmMud1xJMsoYFKy"', + ' chain: testnet', + ' tlc_expiry_delta: 86400000', + ' future_field_from_new_fnn: keep-me', + 'rpc:', + ' listening_addr: "127.0.0.1:8227"', + 'ckb:', + ' rpc_url: "https://testnet.ckbapp.dev/"', + ' udt_whitelist:', + ' - name: RUSD', + ' script:', + ' code_hash: 0x1142', + ' hash_type: type', + ' args: 0x878f', + 'services:', + ' - fiber', + ' - rpc', + ' - ckb', + ' - cch', + '', + ].join('\n'), + ); + return { settings, testnetConfigPath }; +} + +function chainScripts(): FiberChainScripts { + const dep = (index: number) => ({ + cell_dep: { out_point: { tx_hash: '0xgenesis', index: `0x${index.toString(16)}` }, dep_type: 'code' as const }, + }); + return { + genesisHash: '0xgenesis', + fiberScripts: [ + { + name: 'FundingLock', + script: { code_hash: '0xfunding', hash_type: 'data2', args: '0x' }, + cell_deps: [dep(21), dep(20)], + }, + { + name: 'CommitmentLock', + script: { code_hash: '0xcommitment', hash_type: 'data2', args: '0x' }, + cell_deps: [dep(22), dep(20)], + }, + ], + udtWhitelist: [ + { + name: 'sudt', + script: { code_hash: '0xsudt', hash_type: 'type', args: '^0xissuer$' }, + cell_deps: [dep(5)], + }, + ], + }; +} + +describe('generateNodeConfig', () => { + it('replaces chain-related fields and keeps unknown ones', () => { + const { settings, testnetConfigPath } = fixture(); + const configFile = generateNodeConfig({ + node: { id: 2, config: {} }, + chainScripts: chainScripts(), + testnetConfigPath, + settings, + }); + expect(configFile).toBe(fiberNodePaths(2, settings).configFile); + + const config = yaml.load(fs.readFileSync(configFile, 'utf8')) as Record; + expect(config.fiber.chain).toBe('../../../specs/dev.toml'); + expect(config.fiber.listening_addr).toBe('/ip4/127.0.0.1/tcp/8345'); + expect(config.fiber.bootnode_addrs).toEqual([]); + expect(config.fiber.announce_listening_addr).toBe(true); + expect(config.fiber.announce_private_addr).toBe(true); + expect(config.fiber.gossip_network_maintenance_interval_ms).toBe(1000); + expect(config.fiber.gossip_store_maintenance_interval_ms).toBe(1000); + expect(config.fiber.announced_node_name).toBe('offckb-fnn-2'); + expect(config.fiber.scripts).toHaveLength(2); + expect(config.fiber.scripts[0].name).toBe('FundingLock'); + expect(config.fiber.scripts[0].cell_deps[1].cell_dep.out_point.index).toBe('0x14'); + // unknown fields survive + expect(config.fiber.tlc_expiry_delta).toBe(86400000); + expect(config.fiber.future_field_from_new_fnn).toBe('keep-me'); + + expect(config.rpc.listening_addr).toBe('127.0.0.1:21715'); + expect(config.rpc.enabled_modules).toEqual(['channel', 'payment', 'graph', 'info', 'invoice', 'peer', 'watchtower']); + expect(config.rpc.cors_enabled).toBe(false); + + expect(config.ckb.rpc_url).toBe('http://127.0.0.1:8114'); + expect(config.ckb.udt_whitelist).toHaveLength(1); + expect(config.ckb.udt_whitelist[0].script.args).toBe('^0xissuer$'); + + expect(config.services).toEqual(['fiber', 'rpc', 'ckb']); + }); + + it('merges per-node config recursively and replaces lists', () => { + const { settings, testnetConfigPath } = fixture(); + const configFile = generateNodeConfig({ + node: { + id: 1, + config: { + fiber: { auto_accept_channel_ckb_funding_amount: 99, announced_node_name: 'custom-name' }, + rpc: { enabled_modules: ['info'] }, + }, + }, + chainScripts: chainScripts(), + testnetConfigPath, + settings, + }); + const config = yaml.load(fs.readFileSync(configFile, 'utf8')) as Record; + expect(config.fiber.auto_accept_channel_ckb_funding_amount).toBe(99); + expect(config.fiber.announced_node_name).toBe('custom-name'); + // managed values merged around the override stay + expect(config.fiber.chain).toBe('../../../specs/dev.toml'); + // lists replace + expect(config.rpc.enabled_modules).toEqual(['info']); + }); +}); + +describe('mergeNodeConfig', () => { + it('merges objects deeply and replaces scalars and arrays', () => { + const merged = mergeNodeConfig( + { a: { b: 1, c: [1, 2], d: { e: 1 } }, x: 1 }, + { a: { c: [3], d: { f: 2 } }, y: 2 }, + ); + expect(merged).toEqual({ a: { b: 1, c: [3], d: { e: 1, f: 2 } }, x: 1, y: 2 }); + }); +}); diff --git a/tests/fiber-env-lock.test.ts b/tests/fiber-env-lock.test.ts new file mode 100644 index 00000000..f76acaab --- /dev/null +++ b/tests/fiber-env-lock.test.ts @@ -0,0 +1,60 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { acquireEnvLock, isEnvLockHeld, releaseEnvLock } from '../src/fiber/env-lock'; + +const tempRoots: string[] = []; +afterEach(() => { + while (tempRoots.length) fs.rmSync(tempRoots.pop() as string, { recursive: true, force: true }); +}); + +function lockFile(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fiber-lock-')); + tempRoots.push(root); + return path.join(root, '.offckb-devnet.lock'); +} + +describe('env lock', () => { + it('acquires, records the holder, and releases', () => { + const file = lockFile(); + const handle = acquireEnvLock('test', file); + expect(fs.existsSync(file)).toBe(true); + const record = JSON.parse(fs.readFileSync(file, 'utf8')); + expect(record.pid).toBe(process.pid); + expect(isEnvLockHeld(file)).toBe(true); + + handle.release(); + expect(fs.existsSync(file)).toBe(false); + expect(isEnvLockHeld(file)).toBe(false); + }); + + it('refuses a second acquire while held by a live process', () => { + const file = lockFile(); + acquireEnvLock('first', file); + try { + expect(() => acquireEnvLock('second', file)).toThrow('Another OffCKB process'); + } finally { + releaseEnvLock(file); + } + }); + + it('re-acquires a lock whose holder is dead', () => { + const file = lockFile(); + fs.writeFileSync(file, JSON.stringify({ pid: 99999999, acquiredAt: new Date().toISOString() })); + const handle = acquireEnvLock('test', file); + const record = JSON.parse(fs.readFileSync(file, 'utf8')); + expect(record.pid).toBe(process.pid); + handle.release(); + }); + + it('does not remove a lock re-acquired by someone else on release', () => { + const file = lockFile(); + const handle = acquireEnvLock('test', file); + // Simulate another holder taking over (content replaced). + fs.writeFileSync(file, JSON.stringify({ pid: 99999999, acquiredAt: 'later' })); + handle.release(); + expect(fs.existsSync(file)).toBe(true); + // Clean up the foreign record for the temp-dir removal. + fs.unlinkSync(file); + }); +}); diff --git a/tests/fiber-nodes-yml.test.ts b/tests/fiber-nodes-yml.test.ts new file mode 100644 index 00000000..a8ebf6d4 --- /dev/null +++ b/tests/fiber-nodes-yml.test.ts @@ -0,0 +1,109 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import yaml from 'js-yaml'; +import { defaultSettings, Settings } from '../src/cfg/setting'; +import { nodesYmlPath } from '../src/fiber/paths'; +import { ensureNodesYml, readNodesYml, validateNodeCount } from '../src/fiber/nodes-yml'; + +const tempRoots: string[] = []; +afterEach(() => { + while (tempRoots.length) fs.rmSync(tempRoots.pop() as string, { recursive: true, force: true }); +}); + +function fixture(): Settings { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fiber-nodes-')); + tempRoots.push(root); + const settings = JSON.parse(JSON.stringify(defaultSettings)) as Settings; + settings.devnet.configPath = path.join(root, 'devnet'); + return settings; +} + +describe('validateNodeCount', () => { + it('accepts 1-16 and rejects everything else', () => { + expect(validateNodeCount(1)).toBe(1); + expect(validateNodeCount(16)).toBe(16); + expect(() => validateNodeCount(0)).toThrow('between 1 and 16'); + expect(() => validateNodeCount(17)).toThrow('between 1 and 16'); + expect(() => validateNodeCount(2.5)).toThrow('between 1 and 16'); + }); +}); + +describe('ensureNodesYml', () => { + it('creates two nodes by default', () => { + const settings = fixture(); + const entries = ensureNodesYml(undefined, settings); + expect(entries).toEqual([ + { id: 1, config: {} }, + { id: 2, config: {} }, + ]); + expect(fs.existsSync(nodesYmlPath(settings))).toBe(true); + }); + + it('creates the requested number of nodes on first start', () => { + const settings = fixture(); + const entries = ensureNodesYml(4, settings); + expect(entries.map((e) => e.id)).toEqual([1, 2, 3, 4]); + }); + + it('keeps per-node config when growing and warns when shrinking', () => { + const settings = fixture(); + ensureNodesYml(3, settings); + const file = nodesYmlPath(settings); + const doc = yaml.load(fs.readFileSync(file, 'utf8')) as { nodes: { id: number; config: object }[] }; + doc.nodes[1].config = { fiber: { auto_accept_channel_ckb_funding_amount: 99 } }; + fs.writeFileSync(file, yaml.dump(doc)); + + const grown = ensureNodesYml(4, settings); + expect(grown.map((e) => e.id)).toEqual([1, 2, 3, 4]); + expect(grown[1].config).toEqual({ fiber: { auto_accept_channel_ckb_funding_amount: 99 } }); + expect(grown[3].config).toEqual({}); + + const shrunk = ensureNodesYml(2, settings); + expect(shrunk.map((e) => e.id)).toEqual([1, 2]); + expect(shrunk[1].config).toEqual({ fiber: { auto_accept_channel_ckb_funding_amount: 99 } }); + }); + + it('uses the stored list when no count is requested', () => { + const settings = fixture(); + ensureNodesYml(5, settings); + const entries = ensureNodesYml(undefined, settings); + expect(entries.map((e) => e.id)).toEqual([1, 2, 3, 4, 5]); + }); +}); + +describe('readNodesYml', () => { + it('returns null when the file does not exist', () => { + expect(readNodesYml(fixture())).toBeNull(); + }); + + it('rejects duplicate ids', () => { + const settings = fixture(); + fs.mkdirSync(path.dirname(nodesYmlPath(settings)), { recursive: true }); + fs.writeFileSync(nodesYmlPath(settings), yaml.dump({ nodes: [{ id: 1 }, { id: 1 }] })); + expect(() => readNodesYml(settings)).toThrow('duplicate node id 1'); + }); + + it('rejects managed config fields', () => { + const settings = fixture(); + fs.mkdirSync(path.dirname(nodesYmlPath(settings)), { recursive: true }); + fs.writeFileSync( + nodesYmlPath(settings), + yaml.dump({ nodes: [{ id: 1, config: { fiber: { chain: 'evil.toml' } } }] }), + ); + expect(() => readNodesYml(settings)).toThrow('fiber.chain'); + }); + + it('rejects managed rpc and ckb fields', () => { + const settings = fixture(); + fs.mkdirSync(path.dirname(nodesYmlPath(settings)), { recursive: true }); + fs.writeFileSync( + nodesYmlPath(settings), + yaml.dump({ nodes: [{ id: 1, config: { rpc: { listening_addr: '0.0.0.0:1' } } }] }), + ); + expect(() => readNodesYml(settings)).toThrow('rpc.listening_addr'); + + fs.writeFileSync(nodesYmlPath(settings), yaml.dump({ nodes: [{ id: 1, config: { services: ['cch'] } }] })); + expect(() => readNodesYml(settings)).toThrow('services'); + }); +}); diff --git a/tests/fiber-scripts.test.ts b/tests/fiber-scripts.test.ts new file mode 100644 index 00000000..df8178f9 --- /dev/null +++ b/tests/fiber-scripts.test.ts @@ -0,0 +1,93 @@ +import { FiberContractsMissingError, resolveFiberChainScripts } from '../src/fiber/scripts'; +import { SystemScript } from '../src/scripts/type'; + +const mockResolve = jest.fn(); +jest.mock('../src/scripts/private', () => ({ + resolveDevnetSystemScripts: () => mockResolve(), +})); + +function script(name: string, txHash: string, index: number, codeHash: string, hashType: 'type' | 'data2'): SystemScript { + return { + name, + script: { + codeHash: codeHash as `0x${string}`, + hashType, + cellDeps: [ + { + cellDep: { + outPoint: { txHash: txHash as `0x${string}`, index }, + depType: 'code', + }, + }, + ], + }, + }; +} + +const GENESIS_TX = '0xaaaa'; + +function fullRecord(): Record { + return { + auth: script('auth', GENESIS_TX, 20, '0xauth', 'data2'), + funding_lock: script('funding_lock', GENESIS_TX, 21, '0xfunding', 'data2'), + commitment_lock: script('commitment_lock', GENESIS_TX, 22, '0xcommitment', 'data2'), + sudt: script('sudt', GENESIS_TX, 5, '0xsudt', 'type'), + xudt: script('xudt', GENESIS_TX, 6, '0xxudt', 'type'), + }; +} + +describe('resolveFiberChainScripts', () => { + beforeEach(() => mockResolve.mockReset()); + + it('builds FundingLock/CommitmentLock with their own cell plus the auth cell', () => { + mockResolve.mockReturnValue({ scripts: fullRecord(), forkedFrom: null, genesisHash: '0xgenesis' }); + const result = resolveFiberChainScripts(); + + expect(result.genesisHash).toBe('0xgenesis'); + expect(result.fiberScripts).toHaveLength(2); + + const [funding, commitment] = result.fiberScripts; + expect(funding.name).toBe('FundingLock'); + expect(funding.script).toEqual({ code_hash: '0xfunding', hash_type: 'data2', args: '0x' }); + expect(funding.cell_deps).toEqual([ + { cell_dep: { out_point: { tx_hash: GENESIS_TX, index: '0x15' }, dep_type: 'code' } }, + { cell_dep: { out_point: { tx_hash: GENESIS_TX, index: '0x14' }, dep_type: 'code' } }, + ]); + expect(commitment.name).toBe('CommitmentLock'); + expect(commitment.cell_deps[0].cell_dep.out_point.index).toBe('0x16'); + expect(commitment.cell_deps[1].cell_dep.out_point.index).toBe('0x14'); + }); + + it('anchors the UDT whitelist to the issuer lock hash with ^ and $', () => { + mockResolve.mockReturnValue({ scripts: fullRecord(), forkedFrom: null, genesisHash: '0xgenesis' }); + const result = resolveFiberChainScripts(); + + expect(result.udtWhitelist).toHaveLength(2); + for (const udt of result.udtWhitelist) { + expect(udt.script.args).toMatch(/^\^0x[0-9a-f]{64}\$$/); + } + expect(result.udtWhitelist[0].name).toBe('sudt'); + expect(result.udtWhitelist[1].name).toBe('xudt'); + expect(result.udtWhitelist[0].cell_deps[0].cell_dep.out_point).toEqual({ tx_hash: GENESIS_TX, index: '0x5' }); + }); + + it('reports missing fiber contracts', () => { + const record = fullRecord(); + delete (record as Record).funding_lock; + delete (record as Record).commitment_lock; + mockResolve.mockReturnValue({ scripts: record, forkedFrom: null, genesisHash: '0xgenesis' }); + + try { + resolveFiberChainScripts(); + throw new Error('should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(FiberContractsMissingError); + expect((error as FiberContractsMissingError).missing).toEqual(['funding_lock', 'commitment_lock']); + } + }); + + it('throws when list-hashes is unavailable', () => { + mockResolve.mockReturnValue(null); + expect(() => resolveFiberChainScripts()).toThrow('list-hashes'); + }); +}); diff --git a/tests/node-command.test.ts b/tests/node-command.test.ts index c3e189ba..d5185927 100644 --- a/tests/node-command.test.ts +++ b/tests/node-command.test.ts @@ -402,6 +402,18 @@ describe('node command stop', () => { const scriptPath = '/path/to/offckb'; const originalPlatform = process.platform; + // Serve the given content only for the CKB daemon PID file; other files + // (fiber daemon PID, fiber runtime.json) read as absent, matching a + // machine with no fiber environment. + function mockPidFileContent(content: string) { + mockReadFileSync.mockImplementation((file: string) => { + if (file === pidFile) return content; + const err = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + }); + } + function setPlatform(value: string) { Object.defineProperty(process, 'platform', { value }); } @@ -412,7 +424,7 @@ describe('node command stop', () => { processAlive = true; mockExecFile.mockReset(); mockStatSync.mockReturnValue({ isFile: () => true }); - mockReadFileSync.mockReturnValue(JSON.stringify({ pid: 12345, scriptPath, startedAt: new Date().toISOString() })); + mockPidFileContent(JSON.stringify({ pid: 12345, scriptPath, startedAt: new Date().toISOString() })); mockDaemonCommandLine(scriptPath); // Normalize to POSIX for deterministic signal-based assertions. The @@ -456,7 +468,7 @@ describe('node command stop', () => { }); it('errors when the PID file contains an invalid PID', async () => { - mockReadFileSync.mockReturnValue('not-a-number'); + mockPidFileContent('not-a-number'); await expect(stopNode()).rejects.toThrow('Invalid PID'); expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); }); @@ -469,7 +481,7 @@ describe('node command stop', () => { }); it('does not signal the CLI process while daemon startup is in progress', async () => { - mockReadFileSync.mockReturnValue( + mockPidFileContent( JSON.stringify({ pid: 12345, scriptPath, startedAt: new Date().toISOString(), status: 'starting' }), );