From 917608538165a2c1920a4689c3f28be37e900995 Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Fri, 31 Jul 2026 05:06:45 +0000 Subject: [PATCH 1/2] feat: add Fiber (FNN) support to the local devnet Add a local Fiber development environment to offckb: - Genesis: the devnet now carries the Fiber contracts auth, funding_lock and commitment_lock, copied from the new ckb/fiber submodule pinned to the FNN v0.9.0-rc7 commit (bc361aa). They are appended after the existing system cells so existing script type ids (accounts, sudt, xudt, ...) stay unchanged; the genesis tx hash changes, so cell dep out points are always read from a fresh `ckb list-hashes` at start. - FNN install: download/cache of the tested FNN release (0.9.0-rc7, portable tarballs), keeping the bundled config/testnet/config.yml as the devnet config template; --binary-path/--fnn-binary-path run a locally built FNN, using its sibling testnet config or the shipped fallback. - offckb node --fiber: start CKB, miner, RPC proxy and FNN nodes with one command (daemon mode included). Plain local devnet only: mainnet/testnet and forked devnets (any fork.json) are rejected before any daemon respawn. - offckb fiber start/stop/status/logs/clean: manage FNNs on an already-running devnet. Node N uses built-in account N+2, RPC port 21713+N and P2P port 8343+N (1-16 nodes). Each node writes only to its own fnn.log; per-node FNN config overrides live in fiber/nodes.yml; config.yml is regenerated every start from the FNN testnet config with unknown fields preserved. - Startup checks: genesis hash agreement between list-hashes, CKB RPC and every FNN node_info, node identity key vs fiber/sk, funding account vs the expected built-in account, and available balance; then node 1 connects to the other nodes (verified via list_peers). - Process management: a shared .offckb-devnet.lock, runtime.json for manager/node records, daemon PID files with identity checks, stop only ever signals recorded managers (never per-FNN kills), store LOCK verification before cleans; offckb clean removes fiber stores with --data and refuses while a daemon or live store lock is confirmed. - fiber status reports CKB and per-node state (starting/running/stopped/unknown/conflict) plus an OFFCKB-managed column, as a table or --json. Co-Authored-By: Claude Fable 5 --- .changeset/fiber-devnet.md | 5 + .gitmodules | 3 + Makefile | 12 +- README.md | 39 ++ ckb/devnet/specs/dev.toml | 9 + ckb/devnet/specs/fiber/auth | Bin 0 -> 150904 bytes ckb/devnet/specs/fiber/commitment_lock | Bin 0 -> 111880 bytes ckb/devnet/specs/fiber/funding_lock | Bin 0 -> 74800 bytes ckb/devnet/specs/fiber/testnet-config.yml | 104 +++++ ckb/fiber | 1 + package.json | 2 + pnpm-lock.yaml | 11 + src/cfg/setting.ts | 2 + src/cli.ts | 90 ++++- src/cmd/clean.ts | 93 +++-- src/cmd/config.ts | 19 + src/cmd/fiber.ts | 199 ++++++++++ src/cmd/node.ts | 456 ++++++++++------------ src/fiber/accounts.ts | 113 ++++++ src/fiber/ckb-env.ts | 69 ++++ src/fiber/clean.ts | 119 ++++++ src/fiber/config-gen.ts | 104 +++++ src/fiber/daemon.ts | 311 +++++++++++++++ src/fiber/env-lock.ts | 115 ++++++ src/fiber/install.ts | 197 ++++++++++ src/fiber/manager.ts | 397 +++++++++++++++++++ src/fiber/nodes-yml.ts | 147 +++++++ src/fiber/paths.ts | 125 ++++++ src/fiber/rpc.ts | 47 +++ src/fiber/runtime.ts | 93 +++++ src/fiber/scripts.ts | 149 +++++++ src/fiber/status.ts | 235 +++++++++++ src/fiber/store-lock.ts | 62 +++ src/scripts/public.ts | 6 + src/scripts/type.ts | 3 + src/util/daemon.ts | 239 ++++++++++++ tests/fiber-accounts.test.ts | 97 +++++ tests/fiber-config-gen.test.ts | 152 ++++++++ tests/fiber-env-lock.test.ts | 60 +++ tests/fiber-nodes-yml.test.ts | 109 ++++++ tests/fiber-scripts.test.ts | 93 +++++ tests/node-command.test.ts | 18 +- 42 files changed, 3832 insertions(+), 273 deletions(-) create mode 100644 .changeset/fiber-devnet.md create mode 100755 ckb/devnet/specs/fiber/auth create mode 100755 ckb/devnet/specs/fiber/commitment_lock create mode 100755 ckb/devnet/specs/fiber/funding_lock create mode 100644 ckb/devnet/specs/fiber/testnet-config.yml create mode 160000 ckb/fiber create mode 100644 src/cmd/fiber.ts create mode 100644 src/fiber/accounts.ts create mode 100644 src/fiber/ckb-env.ts create mode 100644 src/fiber/clean.ts create mode 100644 src/fiber/config-gen.ts create mode 100644 src/fiber/daemon.ts create mode 100644 src/fiber/env-lock.ts create mode 100644 src/fiber/install.ts create mode 100644 src/fiber/manager.ts create mode 100644 src/fiber/nodes-yml.ts create mode 100644 src/fiber/paths.ts create mode 100644 src/fiber/rpc.ts create mode 100644 src/fiber/runtime.ts create mode 100644 src/fiber/scripts.ts create mode 100644 src/fiber/status.ts create mode 100644 src/fiber/store-lock.ts create mode 100644 src/util/daemon.ts create mode 100644 tests/fiber-accounts.test.ts create mode 100644 tests/fiber-config-gen.test.ts create mode 100644 tests/fiber-env-lock.test.ts create mode 100644 tests/fiber-nodes-yml.test.ts create mode 100644 tests/fiber-scripts.test.ts 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..a7a041cd 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,14 @@ 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: + @echo "Copying Fiber contracts via submodule" + 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 + 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 0000000000000000000000000000000000000000..4c4b885b8a07cd5c8e73fe56ee84181d71497837 GIT binary patch literal 150904 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

7t@vMHfqmr8fKkO8jo~o^6-)DJ889NvB zdUG5VVsQqmpL0;b&U2Itde_DJ`3L2iS#MLVTe+NN)MRLiZ=)YF*y1!H{XCTtqFK}{ z%Zt!>y-gK{xOH#`m1D#~o85P2>jJySTdZc$+f;Q3XGnmp;Ueq`Z!=dubng^oYLz%I z0;6>YaoZw%a3T`{kE*DA3tS%`uy(XS@nL@P2W%N4w>q$|SlCveAw@XZ#l`E{qIA9( zqsREM?ch)67yAL*FMc^1QMAig@F5)%7cXP&;|~lvyOHqMmsKO9l=$@n;0)mZeEJftTzu zyW^PX{ZG2p!ha1E2#|xqEhS6hGAoqd>P6#u%qP$EBKY`Pr9Dt>1B9TH}C(@p)F% zgO7e*2I5M?*dpLE!`SpRv1k=g)j7ZmVm~#lAS{;D8^|Ij&Ml^Jm45?01-v101K4Mj zaL6A%vg2e9+on)Cc;61>BLL0><0DdOcppv+{pXD6odyL-%XSI;VlA>Ih~6#~u+qA> zfR7Z#x7eb?zm+L=G@(4~6XcW{E{lIgkjkpi@+ORcIg*J&ujkXl<1k2`;CTUd<@Ib< zW@Tp3G0$UB$WQ=JrG6iz39Ng03@}GxQ5fvneu#u^gbjBeQ+Q7^=L}Bz7%}uAO86@l#veGyKy@BZUD<9U!iYoN%{MBp@7+ZEyycfQ)+kxdP75KT^aoVm2B5xec zW7ZS^5lCi@uZ8zg(xr!a@LzL3+2;=CHIx3RVyl>1$##Jk&l7xf!q^Lv98(|0tqOpy z@;25}C2Zo@JAe%o7UQx-;OE1bT$kpxGOQKBHE1{oWS5#Q? zc4+4G%G@H#25!V?=3G}yvi8MHYR-w6bhj*GlI>>1q=$JX!6@r-HwKwF8EkHKL-z!O zOl2K=a8MC0$kmHIjka003Zb`5XXnt z*X}7J_O=2}6z3>fz&HPn(gK1U(Vqigvyi z2`g|Eti4gN_C~=H91SaQB&@*reGB%L_z3(S0V{BXX3^im_y{Fk(C67uH0XUm>x49t zpPQ8PlC%@lYxi#vl<~_84|oH+7nUb3oQ?#+|_zhnzCNuTy#ew;_F3NqXA)Jtm*>d%f~G}Av`e(V*&2urB(Pt$G3 zo&L_wM>gv@$ZoyxAnZ;`KTZDsSgxO$u$-NFVfkdm%5<^0*`XHYifuDT*g7s}vEDK- zb|Erh^9C=ymuvy+Wmv$@l`YiIH7sQ3$zImaGrY{smo3uIH!RZ3shX+rigMR1nlMu{ z%i(#=oC08)N`YyrdQLOzhKFWOtB1y`&x4aYJa3z|q!nc^M!Abot~bi{M!8G$r;>qr;DQ9P3^|D_&jBQ9;^);O}1H^hKP-Vlbd0IvCT>yT=Z{vZeU=% z=DA)Ov6cm!IJLPC<@H#z)qQypfEh`dP7E5Zo+rA3)_b!wJ6F%@=d*In$?pd>r(5v%{eb2x)qwbQ zANI=^Y0XzXgTk+WW;I`aHV9UBIiHZu2$oBf*k(b_xmR&s?1vV#A2I3t{3du5(;42_ zg*anh@U0(0EXX0I0Fm#X!q*EnzSFyysO&Y!kg^LIGWf68GP85|dDTGD@dy4&_V&|% zWxTV+`N(&ku$J*fOgx|5I2_ry6?se=*(eLY_)~b#;E4wxCVwi0DN5rLQ^-5yUjyd| zT?LUzh+`^_Lq0eD<@NLpWI&5!iY#LCJ)C*#!6_%Fy)$@I8#spBhR-;AEeJC-_t%ju z;k1H(o^Cp}QPM&@r4vQ`Du=m5_ykUMuzYd{+l2S#psj0|s0@DbXSC-r@vAkcGh(pl z@MA>l!S7!mV4%gE`s4|6@Mr)f_Wzkgp+#rYQ5n8zki?Kp6z9ajLH0^2^f^;D!=KJ( z!l50cqkZsYCMS`eILd#W#1tLli!V{_7V+y6n&eHrBD5Y2$9cKYe&!`UW~CY$vk)op zn6+zMg;IRC*iY^zI$>@0T9VD`nU`^$yjgl{;tsHP?HFze7fiIO=V;AI)noqUm)YQK z@jx)fO_=Zl*te^Iz z>JyVqOab1%CL=0?gZ@o2+Yyg3Z~6-&^i0ggba8PpA|n2Re%%WhtsU~yUP!A_mfZbq zBtC0oE%Y0VonkD-pUS6-B@SDzmPo@Mn5%C+7WX%^P0%*bXX#v8CG|=Qq?D3o#N>&y zT**wsEag`DBbwEn)_nPWzi4b^%hJSzFR5aSH_pPhlXVduIamz?)?=X1XU2dsE7|dN4|O)(U7SMqdtf{C?kzFm$cP==nd``x>|@>t0}CMV;N@xIxH(76%>$0hXZw zS8;2vol)Y>OSIs08Ql8S)ZT$Ho5jISk`?m+&x)bB&|QMqv9CQ7BurV$R@eDY7RJBJ z`cL6ZUknM)Y#O>go^vW3@@QFeFZgzKZ_(|(`i+D7#Rish1$roD1siVk4fp7QEzO~X ztx0AV%4F>=V)-%_ZFqvpSP+13G8u{asU~q+NnZ^|n~4W?1cB}J9m99^VdiDg!u3E_ zSktwvwjV8)iC>=vwvjJ=4ICG%+4`u`WBmM6jBnV+9C7gpW+Qkm#_bl1biidaGdB-6 z9Tu{IWV!RQgk*`ch>MyD%kO~{pqlZQ47}mlJ&-|3tl|K*v7f{#wzrYC@Mpsi+b{9T zC*2^-=?8v61U9lI+6L+TjnazLhXFD4H5-g*)i-Eh1X1e7oN(~$nSpmO17+6*Y#D4~ z1R2!d53TtV#$kIEq5{Q%&#A2z7mF!`cX$J9_c~C4udo%7yczH1qtJ(~$ixb*ed9F= z{UM%NfXtj#x%|RcXbzG1{F#@)QD9($awi~4{|jKooR&>^l(!FaOGaYHeZxeZj$o_7 zq`D?cWL^P-(^F%MAifXcNub!1%;RQo7yz z8BYnX2tEWv@i0;Dh^iJ-zhVOIky40wl9;E=K*OEa8-8W@mVNJtr8j{Ky2^m9^>EhVs>38!iZD7#IL}fr93Vy)I34v>prrYv40eZ< zZ8gwRn?q_s@~ZM5bb|>9{d+nCC|iDE5bEcbDy)GGa~m{4*su5lz_OEUKv5jn+l60Y zCyQE(9D{spH!x5UG|8DN{1s=S@(b*vSD8|TgU+t>8}@9g zcX!hKlrI?W7xa}^AR7|ppSv)Z1HZCpLJ1+O?YJQ&*eE3K5^~-m&oM0R3p*4_F%h$= zfu9fC2J&NyW$Y8Sv%`F;FYrVVymP-`x-zRXe7E;yFq_1Y6Tk)u%Mw~rn#(72(S+^Q z9svmpX?rCtq(g^RH?s3OMOzR2X$Cflap?u_MxkemGlRfMdmS}Ge=P8(rQFCmjuoG) zo<>^c-=ka4^Lj*mjb&GYm8}$vY}ctVyE&2tn8Z*4VI<9q`6@O&gIO;Y!SW~~rXRDr zRPc?+Ib4}poeS?cSk%W$^I1HzgchMs^A0Llg%H=2F@;KU8hlic>Kj&*&X@XwJ6=CqmJu?rC1YpBd-TSQ~`TVc@u3P0|60+qzCt;VgyQ)u+9U4 zLN^HmggP%Z&C&o>a zaJV|~*4$Y^2bu`;NY%jPNAA1ac2-0SUti(SJ=g|;Vr(w1G6B~2@xRsNr!I?}p ze}lO2G=p_1ghU2si3@gN9rF&m*pTSzBf3alJ#%S$qq@k6OtVAg)HYfmc5dwBCGnO(s7# zlCZIZ1&uGo9tKoiQ%3#nnhZoW4@dlPHfeKm%x>Gf~h_to|ywI-7oA7 z%%ApHmmK~8*4T)Ph-WsjqN9m*$#&RrBcmzA9y`RFcT<%|29Y&46In?w0`c|=E4%@# zkNGI%%w*Q1PqkS<4r78g6COUapTB35fnGMfAD%@R_6P!1cndW#=vZN;gXGB&)t@nl z2HIXlId9I-UYi@7>+!%hOcyPtehogFHX!0*1z$gfZrwnz^5V&_8LTR@k`{i?`0n%! z=fTX9pCuaJrbYD!ELICvin#DoFy{op&vFM?TyMJeh%f=W{>vXgn#-a^>l<`_t`wgs zLslECut3;i@1U=+E;e4aTl~O$k`LYzA$tX4owLF4HWE=5TX7y`A^-DR_6#sD*D}tV z>v#7dqcP@+Ig8?^T%<(vB{0TKv1crHD#1J}VS;kS1*q{!TyZ$TAhrrQxKA*B=#y3& z%n%D_RJu%?!jsJbe(vxvWv)p?Jnh{t^tF~3b2azNGlt&j77ZG zZ#k)*E}$0UY13|!m0D!tz|bP@83a#*2yUS}$Q47-ik^rJ#(m~mcg=-E9S_#OEqfz8jU&Jkd7=WXYu{kLzRtCa~CRWn%3+myp# zk6JWgbyo~L#N3h-WL-u#kd}gUAJI}bzlk6Qc zoI|RFKA|Y{x9?o;cT{y7f95D#l!i#CSb_>Am_5h3`Y_3=dDxc?iQ|`?XQ~dnhlfOV z9TvbtC`CU&0ualv+7|hJOCHg%QtFbC$1VjnQ`^=EJgWx4_nf68(_q(^o#Mlsqo&d&$XFv@T9yCsV zke^Tun>8) zzqpQ+w6%>R_U5oKV1W!Qe5$8Qnc01gyFI#i#U{pG%craXXVtZ8M}!T*9N1#=cd z?Co2@nDrBUyXDM_>n3F2ZfK*vIR#-nzl;q^3y%^P%%!t(x&(i%-rJx6h8w8ijSSu- zb4Tv)48&zbF+>k3$|3#%>{D!2T%3@V>8@oqAve0jRul+1C6~=Q5(tUhpzx2Z+Q2W+ z;A|3$-lYO__%n@6)%LCFYf{9dD57VuHCcRVAiX9tFpG)yjpbPZJJ1WP2Q1YmC`d1d z4{P}myMQBy-H4Yde6xA45wsoM&OtX4ku#xoK)BJjEVxy_(4Y|Yu+M*$1s{M|_7mNd zY1#l*RJ4sph7u8bZk)t{`r>90JBDsI7=Ci=V|7vDL3k)i7qV56gGa^ken^#b*;?c= zm+cOMZ)0@0m~@h9iWTRWSoa-SS?C1{8bRF6BNe-_+hR7(V}(q}@^_x6!RD0Px)r*l zc_+Ko&RlmOnZ1$8pN*vXBpu>KcENkQ6_Ikp#{Pgx?YZt<(H&|jWu9)j<7;0ci93N0wdsS2xxf3$wFOqsNum!D-1@9E3AY0urD z-Dl?}zJu&S5*fM_c9G7<~N1rxdC3{}+_6-)SHh%5>JFm`=+ zg6c=^5Q<5yEJWv#5HH%ArfIdb}Gyh$D8TfXi zk<1m`tRWl4)B|i#Mt(HEFaaM9vkNzhd@AGwjPUg^QGp zjbDO@!(C$P3oK^%VbOqiU-2pfIj6i6R=pQk&)E81z*jik!TOLX10ySP-FCKy@htqR z>JiWlctnWcd`;&j`cwR_v%qG+&vWm6TK_Wcj^5Z?xQCp$`~s4BPJDS6YhPhE5A$d5 zA^xCgk6WC0_Ei>=b?Ipq4A8mQomuu}gCZPq2{)dM%tfjK){|)Tsc#e{7lJI1WkH(- z?-h}2;!5G4eTBYh#*+%7(~^_D0$HB~HA z={vHNc`=k%gWUt&z5~4>dS9oR^`zA71o#L4@h(9Aefc*!3Aja9HD&DD7YH3rfJG2{NH0D@)1s!gd^tXkjh-~4#oaneT zehK_|+aF;U_PkdSh8dhO85v{e<2s3Z(J#TQl4cp&j+r1uwW)eoJ?srG>EgC7`0$`- zz2nd3_fHQM;VDFRcBX=GIB$n`f10I9e52A;u?NU_CDZJy6|N6j4e*S#6 z5AYOj<71<{kQgQg89N~=G;utij88UNmb#QG3E0Xj-fA>%q1 zl9zl&YH7OaJ-!%7DqxNVQefRuTwTh~)iMP+MZ?&a@NhnLqNFqO3RvZp6WO?Q9w#)n z)}P&zQj|WXGe4X+I&R7KEI?2a~q8W|BDP4G@)s+wJs2p_h%gDS`!gw6X4imQdsvHTG|kZh8R@{*;B z7;#QE=Ma#wko+k z4(A{v&e>~c1wqW)gEYsUg0Md1tz(zKs$>phE4C+X^0m0z^Ac-!QcWQkN%Re$a)zng zo)^oXwz2;E3O0#Gczl=ad%}fbS^zv6GTM}HVyn{mGww7GOjLfP?=D?*R&G^pcqCsQ z$u`B3wa)Ak=e!NRBGF)GQzDCxxTo_A(kZbREJJUSF;yr;-AY)~FD|W&f^@3j^-;r2 z>yU_Wnl{DqDPhnp%CO6rk+G~ItvwF*PWWA-VOy_C_lOk;cThtPFnKAFygr)U8jYQt zSO_GvmrE|OCT0;1GI0~^OE@jc9|DQ?5IH}c`;$I+0J}S1Rt=jzM&A1>y(gJRd<*7O z2S3-$&d!Y!7Q!&5MB)L7+{CE_+!C)!v`YX-rq@+%Qz&_*ZK+st5PsVV zFj7{+&T=(bOb{uPl20|Ti+aTQN#NOy0}DiJ%{#{Rxko2eBo!Td+>>o@&D*B zk)5{a_aqM_bMK}ydw@8pw(DB94zSn#1dbEn7~a6%3N6zwS!`^i`S4oI6czoTd+=XA z*Y~D^Cu5ps5!yKGZ)hLd=tXOQHgY?{K55^S6U8ywcLp4*BW6d47n-LYT_&|H;`ePk z6mdjqn_fiQij=Ir3~eh{QW+oETgFXB%W_TNT|&!nZgOclT1X25!y9a_$nZ`qfYT0> z+TPa@ZAJJCY@leJ@RObqYAR?+?*gm2*zykww$Y6_QtSBj@HvEUN1Xa9Abv*DeHgpJ z;jtrayN$8K82)SQ?z{cIZSSU4N3@6USv}x--=*x%#AGE^d~eaeX=V4QO?9@0_`6*( zhgiX=M9h%Qd0z%l7STXiL<40J1(XHWe0(HO7Lh<%tOv?sJx~@A>>@fszbHPOT}+4T z7srPIAF)BVUtE4{D*Ng3=~gG@uXzziH)&Vbjr{+6UcUQ-@hr*WO=iD5fy{lu1<@id zrIv#+hjwM`)0Zn{n2ECgY(0DyZJCMO-w87(AJy`vN}%|gmWsxbr2-;L?z@VnF2p1? z`}~KMvgjks9;}o_-(ogll}M}QzT5RP|J_<#82jI^MdPXeE?z&fjAT4FCXkh!xQpx@ z88L^*oM#V4U==g$86`MNh?h-pHo`)+f+2HO7z5k;6cZwmJE&=pG!Q$gyYGUCYDVG- z00R|S0Ys8>eF?aOB~D7=@Of(BAX(+eDCx5$o=<67N+Np5iYKc?fPL|!5BV18?Tsg- zRj|dCPgMAbF}=t{RYQx7<*<2NCH!?NCex)JsO z4Ai~7Z3So>=G?#9_U)6l{cE>YpOLbTm&vi?%dz954(q?i-+HhXj-$1Xsu#nyRCghw zh$=RZ39oH0O&S+cP~oM7u)sQf`ivzxW5ujawcruzDUt^E2P%H_i%gLagP zq5X^@E+3Yzn-JQyEjYF~)+MehDmc10+GWGrh=$!0E8ifDP`3$6$1yi8;(?izN77oO zr@=PWb{vsuo#gid3vZlB-NX(v<+>2_0(JfZ`L)Wzn`ct``RXo2mp!_k`t1-qU;U_L zwI3}c%pYxSZ0^yzz;8Uy+(}ldz9ga5diPqF_GlO4C)LBhUASYteXYxy+SO?cABYi5 ziZNy2lyygBjIvW-oX}~RfhSKjHhZSqKb>zb9RBo4v!_LKy1A3XeBR@#`HFmkH`-P6 z<%Leo$!ndOFPpmtcA;&?5mFlpEu3*guZVWoTg2^{NRd|A_NQiBo#eyqx3|bVz#SOT zj`)-@`j^y$>=Jb^IE8vyZ*?EUx<0T2J!ZYkkI}Mzw0Ho~as4;a_yaSjHO4$dZuT;W z^_tqN)Xcdvr182wq*-(kGawRv{PDELOHFWf46ZTw9dTg2_^+1XcSOG8Zp#Jh1a7$5)ZhMZ-U`~qSC_QlbAS@g4CLLAV@GB z7!-^Ly2y1-fj{(#D4R)DSmvdB0`D>tkq_>O{hC45Cag(tM_y#joQvHWugEUVqVe5m zO|NE-xySb7P(l#!X|~Nye`9B;M{;w=Ql~`PPq+VQnEte!dVdQG3{L()q2b{BVzVnP zHY;gGlUsdNZ}p>^`?dFO+_~9Ue>;32s(0g~fvuNPT|UhGVN!@=_&oENs8aK|jh9ld zre04yX0^}sqIIJ~+qVV97RHW@YmW+wE{q=oQo`OBElli{Q_&zs6)+Ho7<6!Tj zB4a@kdTHI|&xM5KbG*ZSDhR!aT+UO!_?s|7)JuZ$Xk@0$9One?`F{R=@Y>m%2z zEh5%J5TY(|J%JlxP>^3Yn(S{7lnRGD5d5fR`WMx5c99x5EJS|sYFaxS|Q66Ai?qsj&$!Cc8Ds4J5!4wj?ZaoX#t)-=aVcM z+JEgMP0jV#M;we1LV^-kj%qFL*b|ELKe68Fs?&aW&A}Khm`vxA(vO6=;tD&@^s#kA z?M-V^^u>EMgU^FTS4v$kNRC=ZjxH=%S#tdqb742KMFO8pjoI)^kxR9Wb z>ZzaSKz&{QTkVp|7i@9sckc=84C+|f7TmI?VQrnzJ(toZD3?Z76eNs44n(wvzEVyZ zhthwB^XM}_>LcYxa?QWH4rvONL!&Na_Qef&Z1x@-w*x`@dze6eC+xuT%NoE?}f_^7`j;E*(1-+B|5MFF3_@&!?=W-*jmEf97&i*zMq%8*=KH@hZi}B9w~tPdH6WynqdXH???m`sRFn~{ z59q^L5tNV0M`>-W9r|s)(`Thwh+eR_0DGtbvr(rRo`JugeUNiLi9jnpMJtq;ea&cv z3ayZ%6$-RMh1rMCwfH<)`M=qU1y8l&6|o>eedgIMMG1K=`3;^1PC?1aj0FYgy?*#gGI!m?@H-w+LI%8p z8L2ckb{R9;6)a`YwB6}FsS*|lNIIg;?tvalFiJW`xOJJnl)^{E*yaAPN66Uv99Gj? zc5S`6l6tOAGpm7iY;bNF*M_Ke7M@a^5{QdlB*W)WndUi}?0Tx)FY`jvI{(o8P(|bn z>;}K?Zd>O%me?x8e$iQ;T%rBQwe4G~I~9#?xAXg}dTKff%x_m}W+|v5gQwxj#d&7U zezM2NnG@uHyqr3bivRdKK|ZI*fwOmJ;{6~e{pFTf>!4O}4)IhVV%~#{wvK&wN8O?Q z3f4JYy@K~O1w85|r_$vV)i|5;rW8&-hQitHY^$hjsgwI@ zM}CU_o=JJ9=`Sd-9^BMp>iEcB%9@!!Sehm(@vLoTx1j7$S|88s787RBWhQHQR~OmU zBdz*`v6S|ECG&ZC#_i1Vh34mf5lxMhHN3Wyt(-}Xt<orA(V4P2J>h2-OI_as-nR3xIgT7uO_qWm) z`t=F8>zuNkT$A3{-P~76=O`N|IwUO>e%(zWw!nGqQ6b5pLzpj9d1oH46OBn2 zpXqX3QK1KyIvuxih9RS4jw5-8zEn0cJWDjk`ox!$&!3kp+5)Mi$GtPKV`U51+082Y zA%`(RZNUv|>I4}jTXaAEAQ>s}v^vNcZIeY-kR+%nh8xkrx`TItmnTuSj?@mkuRkxd z26~HoCiKW0dO3O8y6NQsZLXTC-|V|BZyOU^AL$#F7sW`@gIg9U$9G%(=Dcm>w;+^O zAIZe7H2OwOdv8<6Yj+oI8N0=2Jy&~;&bw1}+rz-AR2^K}d}{tk*dS_GGZQV4kkp%% zVi)#hSU9+!RU0 zC?O?1ag~vltMeO!23Gb8=mXQe;74ojue}#?C%iH0c6NM&V_vZ_$ll0=k+ zr>&d*G@x~Hn4o{o07-B;B*Al3_O(RX``pBL^z_6DdYX@Vm3DhywIch?MB4rP?94=3 zj9g6nzW?VH*^`a*j=xVFpGfnOf1pF&cm8wsAtSB$G5bv;EruSWm_2X)IeVgsUi)+Q zkN9QXvy|JO?4u?+{2$qWHPK@99@=*g>nWqMEhftQ*X&qx9VdL2lnci=Oam~EscPBOu+Y(?I%df1q3IPGk( zCH+iKT$_S38BhKLPePiIY*imTM5gf`JEo+)5Wd~J6cfhpZbRI79L2}nLloW`AfxxGCP@b2@2qBt&6x{7Z^U-H1e#76XobS`C z2LIky-SwjwagF+qIQ@evvUl95ssFjG*4FXGH|^_!LJLDjMz+QV#udal>}rh;+)%K= z;r*sPf53Xr&$N#!nH}WJ`r_*|r-F=Re6jw&`sZ0PW9+ji@QZsTmOo-X#ecMz@q3Y? z{cUwO2L#oR@((DADnLG{BI7($smVK`B7ywkM0Ggn_WO(~Fw!d^b_T@z)Bc?NM>*Nk z-W|Z6Hp9NB9q+|%DL>SEy__CJG;+5VPeXxG9_F3M$;awS6XqopnPyRnHh(;Kt2ZyA z9#2lH^9_}w_T)aFQBRjYHc=|S`$_rFZsFK?#)edKq0&i7(`PhZ)BH4Z z{^;jnE;J`nf}C2anHBF3_7VI#${%bb%V!&Ge>v_WF|*87GvHB^_W6}2=E&Q(VZWha zyP@@dW{L$b(p2io3b#bEm#s|DWfmA-xvihCX5A9Esiqy5tF$Bj!LT^D*#O9uN~@}A zP^(k0g5oa*yHY}~eKUORlh^FmX1r?aSaLV4WWko8HvQYiah!6$ z%B|;Y!bR$Ab2CHsX+{6amf!{tGm)M&V;+surl}Qza^S~2TgT-5 z>;ywMVm4K0&nZ>fo=irF*+!ArwMRd3iv23cM;%USHAIW=SbO5>d#!Dm{hv4de$OWQ zp5S0~^UUparKA(m3%YhCmKG+|TeW{16ca{%i8w}mGC@(+L*5RGA^2ph9YSpol zJR{lT?K{7_3DI(no?lAY5`^+Pzml@FA)-~$x=mKQPbpxDy6vfoboHf*a zifLk6nRey@LgxFJL0!GBN!O}t|Igw~v0odu_$LvWqa(M4tnDi4^Q~t6>i7wyw>WkD z$Y*n^EDI_$`;~}RyFi_AQVyE^wLO)PTa`qgr8M~E#7N~}roVQe64EPv!MAAv+8q}t zZ)3b4Vlw|p%B!-l7nEQIb2oAkt`yO(^#QQRH!FKiDDS)2I`$@wKS$~y@!i$k>ML6} zIka!vnpoN|7NY=ZhT@1cW^BYA!n8^_U*4YBT8+#3Sxp3ULZ zMn^@PhoR8$-yZ+3^{Wlzo{YcSQ{&$`#ytA}9{k|TZ z<$sp{{x?7TcI-dgCzt6g=JF2%h{jh$4t7*|1- zw$UT0_lH6=cB%?(f_h|elJ=ki7Vvl(?T@-qn{G^nmQOK6!nM_v|Bva;bDcQLSO8@6l@2upqe6tN*5qozND@ub7MPR$lI}JEMEd`_#fTKO7ba1PE&TS zpSL*g>hNdzVuJnP!<0UEk}suRN#SE!mWth7OW#SQXO91Y_G{l9w5@R4$k_JCps2#A zQDofI@rqzmHL-8k4K>96GccjrvcLy z?nZz*syLbnWt*DfD@^~D9q zIlu(Sm>~FLik91`)XJiOe;!Pe&MgvcZ>!6-kp8QY#uELPF89+=)8$WFNt&XFodj8S z$R0q+WKU)`ndKTXyD_uL42SInGaD=d4`{z{pK8sFpL7=5Z_iV|$faMlJk`Rmo8+EI z%!cQ;1U^soBT#G7ySWFT1!4T{ zGZDV9x_wFP4>H-uSi!VE*;`&K!RR>LNN~rP1lwnL-Q~}pJ^!(@WI0&6WDsQ`3SXK} zCb{2(%`3MRZ5ti?AaZ3?QPk+@KmM!r|L^PCw)C}nwDqZ$`JE>%+x}GRKe-^a^{IR0 ze<#1Z_tZUMh2)oG<(K@lU1P^WLc9uj7CZ){JRr-0+h7_v4*vK&#f_4Faxd(~Btq{iRL11`dLCL>BdcEb0O2^Lfal&qE%4 z4)W-8c+U&E{b_SyE!um^(fX&c1K*Zdx`&2@gl2E=Zu_TCy}gI98XfGIi->N+__E$g zll@!5JR+$uk9Ah2zH9(C%ByY>e>jhwgb9QuBK3C1lzL-;hgmz}kqmQFp$@1v%~I9a zC8#v*lo>3>Vtl-xyHR&t<4L=tKQD!Z3-VD4cjKOUFZ(N0I8(0J?|(P(b^kVJGHS2g zrTylX{FkkgZ&uh_M%s?{dEGlT={@z9ygt)lepl6l>h_w}+NK-zYX?L62DZ9<2&Pt} z(zDR4O_F=gPSQFlJh2BE6tf1l$v1FD8Sx==c0ahSqpU@K+wN=Z+8n&Cc$-UXS7dNh zagb@2(Nk%-Y0xFMSMu z>0|gy`{6I`hre`SExgq5j+zV#%_1|j96UP*j2a3@4$S+Km->m1`bNUa1lZNQE{>1~ zX{L@JshJb20ymBd9(c88)}0ZWIoC(PBR}GIzUx_LTvt27JMReZydxwe{C~S!GiO1s z?aT74o8+AQ%jNGd4mI#hE(5paP1NB>UVa}Ip2?q+&%|SCJYdaK{z@1>T`@-N{N@_M zKw6r{VdNYv&RQ};4wfQI1ZQeE(1Wf(Jt=+Q*PU}F4sDIoEV>?t*6jv|&u-M{Bsr5l5~VZx z5e4nwe4Nhm?AhPWY`;W&w_lgeEb@K4{kn9&Unstd^e7_vD%v~^Cq~FeYVdc$0srv_ zsn{vrufTxw;6qm@v~|f#-?)y%fA7L?EGo>;)6actKY3?-?TYi7otOXh@VQ-PVQ{_3 z?JIj9%`DS=312k0W zNnJ{m%J~=q@=26+PAcnXlvOcO@7I752B{FV5s?mvb0=;5?muax zD}A&L^E!`W4F=sa{|&@kLpo48IX&vFI;`l%g9awydW}e~qJm>#xsK zk9MF|D9M0QK1L}grBco@tl!V1Bo?*8^AD+d6>7zhG8m~0x3PSf93@>$CjI`m)T5oK z5kkaetbWCGmR&K2W&M`3c}l(Cn_xVtP_h1@V2bRaLQu{&mehigO2|VAzAmq()Vp3u zJ(YSC67UgL?{7c}AEShmc>g(+!utP=+{wNwy?+CqMJaOB1~DdbT8HP|@O*{K6_jx` z^-Stf%q1B`bf*4J+AmMCX1%keL`lzIZoejJ=X`XsVH zcf5N!f1M)qJ?#3wd3>0s?j%-<`;ASMZ#6t!TiTD*_32+YNE+lf(hlAJSC^6h+Bj|mq>rD$8wS~J zlt-yS;{{1>*0dMEME1PK%LkI&>(67X725PByR}B&%s`fT0TS8^kkC9Jp?N?;dma+n z^FZ$sDec84eH8IY*#mnY&HMy%F@qkHdZ_uG*=BG1r~z>Y`R!=DAG{?7@TLti1Kz#N z&$=4htlSca;hyUc>gM7Y(+J>Yw2r0fbv0EprF+nt z)6mbU<+3W+Rw&cVLidl>qb@$mzdb%yj?Z4?^GDtPll;Dfd`=rWLw=W@H9j_% zC%-cOGqyxUvE?e-sJyMx%xb2s%)?H$B$2Yp<_|r;DzfCsducyn`69?d!SrYdRuonn zeT{i%OJM@B_a+(P(RVgAoST5$b#6vGHbKHAL?920vv;Q{#=9pU>&sU6 zrEFDW9@)?8oMnHw?W}qc<&#?5>ZlK5@A*auWS))bcE}4O>k5cn#`a@*rkl~(`9&2$ z9N6miZ}u=m-eF>Jr%XGgn*lGRTrHDk|Bq8Fzkk2IwjIN@4YS#6+j!nqXS15i1qJmC z^nI1REyjEHtMYTUI;VP0Mm;kYcaYW|fAaj@;pe}_^S9Kv+JUS4mBY{98Gar;>2#Bm zb^M3Eu$Q%Y`0h`d?03)j+E(|{FV}%RB5$f2zIwjFe%0?R&gJW2r|e@lev93HXX?k; zHI%lLwH>Equi!UB&9ZVeEc%d@^=*%YQ$1-#lsW&Efim>!n+gW6ZwZb_K{n+e!=fXZr$qPahuSsu#X#TTtB`xII#N9 z7+voQjM*~nGP%QOzs2{Seuury>(mx$Uoc)BuFt=tK2@YX#^L^V9=;>&C;J_ZpQHaT zkUP$l2#RmeZ$A#-;djM;$N5h&AJ0E|7bpM5QL;-a>TvH;hvD~C55Mmdyze{g&W>p) zjodMH_>Qo@55F%D@B4<_VJ?}r#qIHaRx#w>9r!TF7Tmn@?iy>M0oc~20%L)>C@IkB zZUUmT3CPkW;D?&9s$N8I)E$56!vL+zBlweKEs}k*j(Sr&K|UIcig$LkdGTd+G!kvo%T_HP7+_AdEU_l}m6kH-CX zYk`oqKWn&ae^S4ZQ!8o3BH#v~;vI=TL8@!GT zv6e15^WiynQxZ8le|+~QS&M?vK}D`yzPU2~nd5@uOEOZ^!_K;zZ|}9!u5|&4L|+k@YS$F`_)DR z#y5{V{dtMc7E^cL!+cxS-RkBVE9AgfPL30knQcU_CR3@w+u)l7pYg2%^QwD+Cc0t9 zXL7~Z@7W8`-(WxAGsY;h{$I4c30M@zx;I`mJu@sSV20H}R0wW~#*>bcq!R%czVRIK$7E#piSKUKm z&OP^j_kQ>Pe|&JdySBI9t*YMltt#i%M>c3ko%R#oESame>NIY7LsjBdn~MRr zKYc=S*oduqw(I8}4eN;75^eHoR)0pH9JdpX=XA{9PW32KEXJ|~Hf9$6HA=S2o@yvQ zJUd|iqu9yu0n^7?4#zK(F$`M~EKpf!731w%z22F zL~38^sq5^b8gP$BZrO7PtBBP8VeAVHZhV-xHrjMI_1QUiA3=KEYdeBgAEx#OWS#qw zUX5*eT(k@M9#m)zG-=cf`lKfBwb3=Jo9=Hx%N-mSyjE#>rxD}QW539UpO{Cvz*C|2 zUmI=f-1J!2*$*?8CO1g?wkFS@ua>x&s+azy+A`UyvrOU|PBnbtgSoBFsn3@56uj3gd~hWVz3tgW;f%=TN^c9tW8Ki`^80G(KgM{i?4ybP z+ddXyXtF;DkVo-*S>3C_zaBU}cO=!RYaar>ZNRpw#!vn8fkQT@rpII8D{l4uYm`mn zMpH(fYkbug2O~$L%5*RFDa2Ym1QofO?&|a1_YFAC=#lTLKo~sVL66~} zN4;O!{;Ou~Zt^$ScQTeG>b&Z$0n(@@2PD)-&qLNn=(Qv9qwI;GF zqWpB)uN*b)oTa1&hc}#h4Uvey9QqqlJsf2VXDF$Wp-fwLjr{G9T-Hgcxh`HwjT_o< zYQ@i*$XRk~Pe-a|nv!aH1?z^3n#ehFDrOb3^af8+Qg^A8=!OB^uA#VDzDL$G7zM@?j!oI1#nYB4COn!)Jr51L3XIo06k(ayoWXkV`=CK92lh|t#iCsX!b9*ZJOl&aAs7G;!GH#jp37dJHaZq-X#W$| z29Mc&_oM`0^||~Qv*M{o%_Ab{%MHrg<@}dhnaxe-ba}Bo_3Gn;w-t$VI&iQVXKX)6 zZu|2rIT^baTHQ6#rsb{bZGiI=H>Ho3u?n1;AmHcdwurxc&f-^kI#PFzR#Jm|0Y68u z(jADE?m(<^2Vj-^ELOSC%5gCR(68sPmPU-5P4gyYV|B&t5x?lH7an63@2nf*jW}J5 zs-dst|MmOvIAB&uOG<@h=#~1Z<1IsPU>@Cwc{En7f2>!b z)pNQRzJLf}#dDXKH=vF6qo=#l4^6lNsl^-k{&ri$-;S`ki8?{Y>ViA zcB){@#7;v3zsV(HFUFVu|m(3d3XgD6DVL=*p&9uX2wC!}N z&gFQMOX1beElwtD9}OE3xk4wx#>2WtrY>zNWz1>pra-mRU1>MC3-I@Mn;PR0Ro~t_ zySK${{;k-t@q6Rlrq9Ay28=bX_;pW8zdFCj29K)u0Kx6kpo0h2-whrf&M(yk>9lXFN;JLSNeWuTI# zf!6Nv=~~B_d8z|`PDu@>fPXuKe`VhLb9%QwO>(OGr}Varyf?OKe518?VA?lnL(&^O z^52?(7zf#x=h4&9Kc(MItbsfX3H?*NuCho__V&r#{wXirl&w7;Js0**d9_ad`ev>n z(4vj4o*%wAXR&GtThFmRgsPQ3XdWn#SgJ)e@EhF*o`S}Lp~t(a5Z+LPi{miMjfjI#N!D)S^rM&oPO_N2t$)@dSWkSn~k?)bxRP$&GcXE*$@XZ-#p3O1LXk2n5n zjizm9SnPpnPpXbtwKmpuerHZjW0G&~jfz@p(}^pms}SYd_D#xA%h*(%R{b!m9`hS( zz1|$HVU`$;t+|!#-v~=gp$4ZZ%a)jp7TDHhOH4q0JMH=#;x29zW0a$R{aAcfL;m)E z*sxmN(fKxET%0t>HNE?s{%P@1r!AOy=O6hy!z_zL_>?x}U)sYk^ob^2j$(LxkzQr* zEIJD7h{nekASZ3Pv$gZEFr!ANu$B|G9si9R2&-pZe$dFa68>Q~xsl z)W5_3(!WFhRsY_f!?cHAWbbw+L3>lIwa=$988UQHA~8V!16{8&xd`oiLgor47e~Hh ze6HvRCN~&!E@3R~iE*@N?&A{=Dq2t9J=<{cdU>P{ddhUqi?hLsJ@Cfw9`&{7R7}e9 zJr%C4+owonoY-(G@q3JIJ(;%?cUjDzy!i0qfF+NnPo9A`dL<{pZ%@`n7o^y&6CUZB zTii{zce&ZEUdr>MUBqrZ^HBc&V3&v8y78e6d1|ib-CNmvX4k*FE;LS5L2+O;R6+mkdKT4mbQ{1muP zCDU$gi%h9dokPFa6-pIuZa7uB|G%Qt!4wWU?D@2{QFf=d9_dt6hmP+adZ(<1whvp~Yp%82vum9Qu7q|_YghlF*A;3_?N3}ltu90FI_OiQ!;SUGZVo+=izud2%OuEzlxgDZbUCDXR@bCvrk zW*x^UoS*nTjfj+M)&Li04viVc=0Go74_m`iP4jg$vGG!kPPUBuT)_(0x$jrzp|J6? zoqIvf)cp2v$_<}J8KrkPeUI^qVrN;`o(r4i-s|>rP(W8sp?ebPkk;h?G7fle`t#|} zdb}UCCq?xTeyo3q3077A_v2Sp4~C{4OjB;10T1nBeOG)K?(*|#**7pQ*M=AXO%(HO zWbS;{87niV6V`#NOt^Ky=G^F{tJmHOA|+&7vc|dQ+Ihqx`I0Uffrz#`Pvm%)Q_tlv z|C12qg7s_Sr-)_shvk$-8GHP%ql|kdX0iJIU6ko)~e&V-G zTDU)HHASc4|8t;5-y|1U*n{`c6;T*>PDx7h6^W7qw5&xZV=d&f@mjP;%7Kl51m1M#4KI9bQcwL?7CAgEPe>tUAJ!H;tTNd zso~>WH#-LUM$7D>W33JA2F2hGW{(_p;=w0Xiz3^whHzc!d=YjTl>cO1f8@pL0p})s zMSOkE$u_Db7gMoQ!l@?ttu`}OGF%i^Gfp+D3Lj-59pl!L8q4Jt)I!FmUbF@xR+wq0 z4}NC5;uuUG@3Q}Ak71`nq~(p6V{OT~lY8sLwTgNhY^=>IsaqOpnWhbDSNf5MRhZhmPgq5%f z;|pq5V`iD$6Mbg10BlvynHlfH2b`aovVXSs{Fc~>@%!Tu*9S~0tFx+JS!bBduyC*s zes!8iz9IjE?{&~?f8_roccUYB-SbNBxIxgK6)bltEcah&0Yi|ZzCn)q2BN;QKkCwH zzS90tpCM4mT{pl{-!rJMl+`yPkJ%!Xy{F^vS#Rt~p%D!8h{~hb8Kznrt%=UDQp`8U zT1M8gk+}QSoT;YxHWlI}Q*F*+%3hb@jDjYg5*4Vr+2?VG@1I$-H1fXPC%-5=d2Oi? zv}Voac8z+@5%8-NG-zc6^g%c@Lj-ifFzAF~uulxb{lHf%Jp%V}79RkIj%aC^RcEzb5h z$|x;I)16vT`zuy35e2RucFaX3$Ylig0*!!| zei-C)800e?_KR?M>BDfwRv6CM3gup*q2^aszQ(;uUo*eDawsste3DKNh^^JB2eSwS z!3QZi87YgicZp6*X0q z;{Nx`%|lWSO|f2+-@iO&@(b?74N=1q>7lxY_(jY%UT8YpkpEncDInMd@_p(x!~%E# z=XlPqJjv{xazyW`6=k^N>z_1IT(yUoi{YV8>f zxkoI2@S*QDrFkPJXbw%7FnC|&gy4M?_Pl-*H2ZX#*B|yfq|?lP*cJbWKE!zk8pFe0 z``(}PyqT@k`&R|2zb4(T47u@g%jqiY)KE1#Ps3&Ce8T z23w~%bdDpZ<(86DgPC-X^b61+te$*F>dqTVYVfs&{MS~+IZ{tKQVXvssgYNpU##&+ z4Mo|WuVu;FtnP}E8dnc}lA=?GYWE!hMb##cmLWbx;4*D**dwqOvDD>*7xP!7_D|_9 z+mJ$EQEGrcR?&yms6Xk|BR(yxloeh_Rw}L^E7c&TPEyNAgjNvKvvj;wUBrpukdZs$3rT<{ri^WA-pa#P^>WIc-X zXXP4NP|itH&B_@%+WXPb-uGy)!HY?T+7!^@?{k)6Bt`gne2*4y1v9yxO8b_Pa`{_w`HQT4zJn6MWsdTtD1Ui^QocLN%kAfGbF}X# zm%k>LKg-G|b(Al0lrKd2nvIfzuiPJ%c6WZo$|YV#xlWqXtlTLFMRp!n$^{nyUsZ!8 z2fVOpv;G#A%k9;moV%uim0Ra17kt#w9<;`nKX(-!{02W3E2mi@w}&-9=u^hZ89LfK zw5J&&xA(K$zYjpU!Qc2^D5jL?HAn~!q$SO zgmVu4HuY&qK5>)5&_GCE=hz01ue_Y6buINbm*jB0?Rah#~i_qMzh9^#T;vI zu>E%ZQx+F)jr5D|{9AKK&7t^MPwtkpY4Wu!&+9JEwSMK;HSASG5OZGmBXPmkMknVh#x^#OymV{U z0-Z*4<@-F8(6ADNw9d5)&H!rX8Yh?g@D)40f2DS=opS-baH{F#a_{{kHcgU}pL4FA z@Jj>v#IwfTrDgFExxU7Yj^E^@e!%;IV^83E);X19Ea|HB$~lhp3RE(DG6}b~ab5Q$ z4w|j9L?*ueNhf@LJ+u>^4OMR|H76%|mHQhll~2?-rK85;E>G5&Xn1{C;_IgtB!z4UkVG;l@&8^&bf-Vpv2JAI>mJJzL)X2QIEV~QB(E>*sAEs6vOoRb_b%00sYmWx zRHC7AVd5aen~5Hw&WWLo6D|1Cv)fAipYO+>Jr;D-3SFgP9GiI5rI6t`(IY7_IVrDC zBu*Ic8RTD>msj3kOU?T3y~4c92R9=sveLga@2%%OY^hUmP9OLuP($7;Wt@*$+NJ+! zQ~LFCb{QnTvx7cMP-BlaVCQi7S~vCS(>kUC4~VJzRH*&9@Wg z&vCf78TZY2u2OM04a&?;gQ~0}zpODAH)CcQNp+rDE~j~J?ZPWdZcMBi+Gs9XKwfS# zmsxN&(*$a+jHBjb3n*7wNX?~ZsQF|a<;t3=xm-iJaxV%9X#GQTS)?lJ-tzYBsuQ&p zSAM-w-I!24y?*GmJ*&BL3(KqGj^R|QlAUM~r&8q)rd-8Fl--GX_tE+$bHxM|R~e_` z3Kyuj6K7QB(q@&pvQT9%t5b1hksKi4%mrt-mV5Wx%@uJRS6RsQ|84wLu5bd!ooMF9 z-Zq!kam(wb+>B}N-?s2!_HXy=%*VV`o32*eG#3P`PTw}47_8!sTR8L!HEOuP7Vh{) z&Rn?>?e0XI`&8x%FODk==FFvoxxo9}@tvHxa37b$i8UfEQ0=Y8?LDMuLA9%Ra}uea zmpeDmjZfH1U1#NJ#JUM2$KAG`!(DFMan{#2B;4Kh)LQvz%{Zam*29$sIT{48`z+`#!(Q=oGJ?o9riMy}!h(TVClHdFC( zF*WZPg&X9oocXiWIMF&LM}3uZSbp;{{!;e(nBeTwv4O7q^hu;ZU5zur zBdA!rj;NO8c`R)fPgGKSQsaI7#1?~~EuvD=IwIabLBtdHsg&7BZ64{=np|KIhgXo0 zW2w=)kCRA_LC@`o5x9A{=c*f`@CEJ|4c8P9u_}~?Ts(AC?|SW)SoIVUTqCI9+6}*x zsD9EOgW!sK?+vG7NeSx4sT^aYD8qo1IgXt#jrR)d;?z)9U&!!5pNqqR4V}rTcYe_* zaCfQTjGJceO(SB-a6Fgb_Ql;qaQ3Ak8>1eIC5c3@@p@hV_C15(JdFsN!8o7V8ExI8 zf+iAwUNmoSQam5UQF}(lTK$Bb20^2tQpRB#pTS2>AyUR8$_+f~DP>d;aroy%${uKx zGWMdy^HfqLpjBroB|k;qS;0r0q0;&oqu#xTBqg7wg8O#LFPupFWeD!>c&jA(4;~x% zqRB*X{{Uyv)Kfm{EZx2B>unB|M}S(=fc z^Kzqn9PTgQoPkq#KPRUbeY4ztW~V{2)KPxXcZ45~^So@Uh@jQtys{r?-lEM9?=0ts zA0dKwZ=-$Hi+%&L`9&o}TJ<^Z3q)PRuh2~i(yF)d1wGh2%{X=jZ%RN~2Hl&$CnS)N zVT*HhzN?9zt9g;(9%s|iTL}VJL)}(LtAOXqLY&;vgBG}nm5`$=Zn&>96Z=@)K&iVl z#P{0~{5Wr_clJ%zzi}7bpu>sUiJ;7R%8#qW?|1mSLhTbiH-z*LXpt;&=z~b~uKg$S zI1N#7y@+>E4BR2Ox>Bzwq1y>V$akUng6l!F9f_KqU%j0;<$Pm@<#u)E1!dg zDiQC2S2$?&k`nx)6#APt{QhJ6s=gbfRf}-?&@lt!a|SUq>+t*;j48 z&zBYg%FW=qHvl#6Qvt>qhlRW?wMeV_0%qs^{0Lyp`9;8d&!|udT+iSwJaU*(#%|FR zGs;Ppjk4s(5QAd?t<~O_QAMwva)ZI!FWY`*ql3d7&<1^aO@VgD3I*CXK%I$CLi=LF zj|#MJJ1fxo0@}OAKSA4fm0E%J?NbW0_hk9_6SQZ3yr4k4kyD_B4tRUB;R$GSf2voY zotUj)*9v}`@Mn08`dgn!LGXA$W7?~zv zqf}n-hQIUmiJ^RyH{Bi1zSu1bUY0C{pupSc(>qV1et3z(JNFtMLyDsX=gkg2heidzD7|7Dit&1i#cuKXW#2ZzcbZHC zamu~0ueXUs-ZaGedE&lpdTmRspxux2xc14giuGP$Om@X0EghVlSO4rIyH%SYS^XK4}X`sM)@VNr# zzO^!xmN!}xV2 z<()m(-)`Oi%zoj2u(!Z?Toh@yeV$j)C*d=MhnVi^uGQYskJH9Ah zymEzvoUvZlWhJR>>rzyEmWWjbHV=7eis>KOy;rcAhb_QGRWzUN`egGBTUMHizk*v2 zh6DP`=csMHtBQT!J3VGvxK6c%?ZZ^{NS``(#-79zi}}POG{+P746)f=Z7r={G-ZX& z=Bf(!y)~Vk9k z_+Fc-U5axg!Arj6+6t>dyp>JF>H=!pkjiCDbz82Vu+K1fo2{Eh)o-g#pTUh?FmSnU zduOho2cL-1u;Dd~s1?MvMWYhjet|}}*^;`1@^f>e%r3oOsprNF(zAG;%eMQ`>DWjU4uKrNDpnFwGIEG;TBE!rB*{xs6zf zG>VgStm9Ej!_Ug;EQy;YwDIZ}!dMLz8)~Xj+sB-ukt2?=VyXdsYZ`%jtbW$crUQOPJLoGh@VW{t-b*O~gOKel8WU)=k9x)@O*f#&7huh`DYe z??nV9v;XCEJel9B#ks>Qy@~9=umA9%6!yQIjwkb5PtzdmZ8b-~fHRL#a(IxH{V%8E z$^2F<&Oc`9^XU%!K54DdA$m%3Y7OlgEv4e@vOAa6I8U|pvMQ}X_USfen&aD8|5iqh z#90KDN4)ZauxD0Dt6ceh+Zp+N2fnrWxM*&lEC2nS?W<)wX>a?uM0eHHT)BMz!V$Iy zEUTDcA8a&EiDPHfYU?VRRJB8|JfG(H{&=8u4?N35Z(t7#I}H#g{NoQhZSk&>PfKT~ zrsMQ<7Lm3mrE7Vhh1n_^NPa9kb%mWw+uKq%P8oY1sam# zc1A->pRpTFrgKmInSzO52Sz?0ZTB8b^f*mfx3I-MP-|n3PplD+K{Qpr_(l}k>r z8gsnt6P@VQ%<@cION)Dfw{D`=$V%zoO2r=a7@tqg7c!Zb{qPN(C2<`Qj&0#lr=wz` z??l_*u@d_mT52C}rS?lW@%a*-f3@Na$5i=TW&7KyRDT}NXPht!q|zW#N_LQ!Xb@ct zn!H1TR2Rq&RT~ZBuF@Uc1sbHjN_OCu_8?6>*`dMh3feZZL+e6=oOY8PP6x=GL;u1j z4`n_9w`{yD#{uB1i#;hddQH8mG3Z+toLP&%f8y^e7Zu)Bcvmgh$2dpH{TkwGYdkfz zrAOGe4(|Eor?%hU|Al*QUcPtXh|gt0xR5?Cy;|&V@+rdekb1^fNeFAys8sg$Nqai8F1OMl{e1HQ9BKhNf;Q*q?eKkLd}|k7}}7 z?!%r+HQg4^8F|I$Dty*1X#;W2A<;H5tv9yTw9?6g3vr>QE>3QU3!BsIkp=j1u84lC z33X<1A?kL8=TlF?OW1DGx61wCLQ)<(deWuUQD&;|?`0ms`|i?8l)5f^E8op8q~P`d zbvrj${pd{o)@g+`sHJ^t?5ak1C)EeDw$^q%s=f(s#0f))Rv_cdt7iYT)Ia{{_1rX% zIGpT7eCL?=r>VZlLiC$D=-W0>?yAb1=fYhr*n0nO$2PQjF7RI*^z8*uvQ72NiJ za4dDpv7m25IKN{-uY^#g_J+pnhvq&msd11L2vQH(d_o;rleETg^U6!k@^##&9t^7LoZ5pMu@t>1L@09q* z*4*s#*mv558Zos`n9gaCy7PR`MW&BhU3E?_>Oh9acIVk4XPrOvzpPNX&o}-xHXm=q zZ%o}3^r90X4bFG{A9p?Kd$V(^=Yzu1&263^{jFegi+Pue$`a*yCFq5xsc&P4_Oq_E9^*g@B8(e{Ue1{vXz3Ue8GdQRzkboR9e>=_Wp|!}#Xe06ulUC*&@#9^7uCeK zHd&(muWT#b(B`@5Zv|`Q_Wx-0d(h%Y$vfXW_^m;yW80CSe_kbN`2PY<=3--JZ}>L$ zrub-B>_+glEd1h*hYD6I=sTzO|J$$jMgvK5vH^>xMx3qr;R|GUO>g+;*@&5a-Uqe7 zAAgTYx%x7>PHNaGy;;xz=KV!mtxoD%&nkvh|8~-#jBOg=y|<#$K_vFMkc4io~_Mimhr}4E16|HUz6DQ z_~C!oGX7p^-sRKBzz;n-Y~=okDngx<{s0^KvxwK|k9dw}5X!Gcg}4q~`oHuw>U!*rHLh zM#Y`nN2fP(<&m&p!;XGZL(Ruw_bv&h+;P~xOEyxja3}rv>Ybaoe*(7fouuV9B)?)K z(N>SFAAjwe8`E!1y2qV_RlF!p1?x8~)UbvZ*Qw0;u#A^Sa$G4a;zeF6uKbK@(w*m9 zxRcEsS2TeOxIg}p`4}wZCkna4_10_V;xn8%zmB_g>zg~-Ex4KK(a;+0h#i}5kFPwzPLTktoK!3mdv*ok)9) z%5k%;Pr{iw*{}uQB)67g?PC;H@2BF;t8@xh{-wm`4a;A~L;Czp!EGQN5QSCGjrDZk z4I!YyAgOLqA+wI|iHmXF9sPVfKYa<2KE7{|l0vb1m=K0_HR^4ow-RlBE|kA@mWo%m z(9;POGpp5N?QvM$-Ra)MJto_x3~I|>gR`86pxh&REq?rOy{}YX5s$rrb8{Zij1_ny zFO3k~R}jg373;=kgJg!Sr|dM*ZOx=YkLQfcO0<53Sat(e-WO1+j0TUqW05klsgQlg zaA)Q>a|1cvG>=N_LXD?Cl9C4DeC(e>4~-Cyd%`=xkFKPW*#=x~r&9J%qiw4S^-QLc z=L3V3e30twt0_PFXDY3Rm9n87*HR2K7C1}hH>jBSfPOtvSiKsnELi+*tP7QrVRtWE zN@CP}tS9Vkj}4OVyGFh;0ju!yL`q&y#Zf(|_l)ID()t3}9a~^&K21KwN_io@^HE@@ zy;b~-kLdmx$qS@)jY!8S+6}r;e%uGdwzU)NeGTw&|b6SnxXGm91^v@=_J z&f4%rgCo2~3+|U;56nc_?L@NOHAvZyU@_ZH#FMZ=*3J!;Hd&40@g^!+i)eLc@y1Ll zp8SajZiiuG+l~``;3L7?a@f(c=%d{h*d&?wfbVQ0vr^|Q8x3eJm& z;5FDNXcmy%dD5m9!<2i1PG_+BcS4yTC_m~~B3_s9I|F`|eljg@G?K0oq@Q%rVwnTJ0>U^JM$JWVO=% zwZOkYN&)`YKLzf(1D>BrA2kV`T0z|+nlpu;wJ3}q^F9$aqP~>RsCW#XiDP%Er2Umh z8$9u?mJ0s&3__>tz<)9<7YhkLd^34u5;}FFlc(vj%_MvUpWsFV7BSlIy%7$Yme8Tg z#j;CO;_@Ity^WtnLQ{ZKe+C_EsGye&)p0_nj{wyjaElG{FptK#iPzr@6;IB9p8$Hm z#_&9k@)fW=%3Sd!(Jm85-=b2dJa7y=EVg5Yz&IwGTgFKnnhX-;LU7Bb{7ic&KYA(J zZIQo;qc4Gr9vR{BqUqCw&bt6d86<8mmF&+LwX>wv?}9fVd#d-qUr;6s}j>(K9pj;zyzUDAWfo5LU-RB8P$dD;SRv zzATpH&KI1V(EoRhJF|sOeRlwnl02zbv|h7ui!4uB z^#*>-4^(ivO!;LuiO|_X`7ysxp))klh9+a)*)i`)Df1EY5cQNe8jy7Uf~uCuxXpxq zZGVqhOATV#OxUzn5W(NyAP$>~TxEENm-McgN}YFt@+Kl=;G5fWQZP^GF%FWr0+d+@ zeAUVnlJt*^+U0`&fkA9XT|TYA?@!Q-TM0kr2DGIO&@H3ZnSxs(-~*h%xMVYfj&I<3 zFTO#i2DBR(wS+aBkb4{Xbc|T`6U&X>0M3BJMybm_gqOE`8Ie}cz=$QSmOx#-p?aCH+79RvNbU@2>l%Z! z<_7f6K7y9f<}5rz;}ypMN^t+${gnUkLwx%ZJhlM(_a`c)Yyhcfo5w!2_GK=<2P}P4IEM<^Bkj_cno~FdJ6Cj%z*y_SBT}nV<+%@=cNkYcaoqn z-ZP40ib2z*GGzq!I#8#a@gM$D?-h)<7*EMEY}0|iFq(#c1kdVNqmWrorLFC#0b|ge^`Kw{El3ax zVgOUSp+1f;hn8U8Q}CXoI)j#bAa5cO$F7AIE&yejeqtl>X|y;CG~F*tDL-Zz;Vb4V zG-hKdMqYvQf`56k3k>1`)@gCvNax&ttqLq-uc&a3{lHIk={LJrSI; zK%oGG;0Ax}GT?@T$IdB_CNJa10JqR40*5h&X~;F;%rWr#GMebjpvzRG&4c8Dw##Ob z0o#PmGpTk4;~S>S#dh$9W&zE6a3R6IT7QtqjG);`a~24lw^G4tf>ByCg9dL4oWb;z zv}P-$sNE>W#)UE(ipP(mwN!8cxQ1~>3OH&Rv<)On+FFME;11A8rUJAMqrF)DMJRAg z*a*VjV`fQaJ@B|1R6dCo*&GVK!3294KXxw>*5=C52-1cZAR)C>3#Au&q2tr*|YQ<-+(zP=m?FBm}^EqY1;s!w00YM zT!bD&yRi|*3>shlA}lzQJx!U)uvfHzue%v0MiqiHFk&WOm${J5+1RWhX1d@5{e&?} za2Ijo^I?qsG4w!&(ETA`&IHGu##bA>1;B`96mngHo=C>|gNzrYZ4V9F8IpDws0XhO z`pMrq9mY7DAA4Bla*QHK;S*Mb@?*1UQ7oHf$@83y)#RmZ!iGoSm0Lt|Ga=6e%*BQn z`LP-3cdnxGn67au#%#GBbXiWMBxtD&Xd9nCM#hOTOJ(kJvmxaxa5iQeQ+9>Q<73oB zc~&E9o+R9sTmLKkQ1xsE=T<>_&l0c~T^^6+*&AwJy&9g$g+yBS6=v*7MA*<`5U2ba z_GyOX*=!h`-7iwm?=pyWRiVt2wsWnZtp{W~XkOcegux^2X?_P~PFxL&voSESD2yM6 z*@bmC^xi1IiaDs|U7}r)vy{(0?_!o+94b~;f)zGS~kA4ez1E2$r(e4o}euQb4!6V>N02MNz1A@R^%V}Qp>{<5I zkOslU1h{q>#Hz{gIxMFAvDxs{VV+@zZ+w)bUtx?%;HQ>`3ObvCkN%MP@u@_9K%NE? zZVD;ZXX>4b%LO;+>rBiG5E;ZD3m`dDr0v@=um1qvwOoU=KF1(leGc6B0ToM22|w=* za$qDs45I;7L;o0lC|j1=9v>KG9k1KXBn>wOJr0A8Gs<3k%qt{oZ$tv*qZfM(l1mol z7hj;sOA&>Dy&8*&uo_w?tskfrWrVM8`+q<&cK82xxpZ)YoV{u zizLhlF|ImMSd@hC1w_aM zHn?3>Y?(+tMXXSz%sI+PR6%1BdX3_Xz^P2PKmsu1WxP`K6e5TgLW&bXvF8v;1kAQT z>N1MqU-f1+fD;Bn7yk-vKhG#xccBd)V@?9rkBp`mw@CPWnMyA3!L?(h%6J504L|w= z#L2uwgQKK0+`PH8jH>1fE?e*&BZprpD6klufI08zAZWEOv5wh`8PY9zhV#-^cxus_ zH_2VZkDiIK>}MjVac*xJ=DKNqc*cy%Gu9}jZ2=FnXtlitA;kytAjr~|ckmrtvh)=y zsNW>kx%}ud@QYFkGacOy%<|vDtx;GrF%CEu1U%nCecu|z$_uC)^W5aQh)H6aAQkZ& zz?iuP{VIUo*u`iod>QCkW(F@D0WT;Nk}{l7BWi&}GQ2XpiDhf>j`<}}UG0#eG+=DMOc!wkIKm~31}8lLW3&@XlQ7o>)Gk+PU?QV1A|NT>egR+o ziC8)pSY{dxvXFThP$R+!{Rp2amI5ZsECs(U;7zmy42mNFGaE0Qzz1!93I81OkYR@H0!b5z#pHBe9y^p$0J%)O6p1Zs~C?*fCuml zYX~$5 zaWNv&U=_@K4Y++wa;LF)IHAW8@QoQ?fVro@b;J)*!IR|~k&HXYwm5=~K;RY3mSU!e zrES1NfXrz!UEC3OReBk7#{{Eb#rO+rD4V5ems#VPtV-+F$@3RF<(U>?KTf$eOxlXX}@5iGVnLdA*-jBIzn`Kjn7B*p11NX$HN1RL*@7&tb%GXV6>p#fc_pPl17K|8}a3d@&p|u3jMBSJ+ zYk|ysMNXTgq!Q*j$HHaZmWwB_7CC`9mKu)!n&G`j<(cd;`A0rBmZZ;k zYMxl-0XWCeP1D4gXTxkyby3*@GE`3`belDONuVzx-eDD6L~Q!Csy*|!`uu!Y(7BPbyibW&OQ|(nhnzXS;}`6SW3fF44LUzH zHzby=yjV|lItw>3Zu)r^`8Qxpfrn(VP_b$@b(?9kt5h~mHCGTmWWGFhWbHEM%!(Pq zaAWgDsRfL~!#(+%52>xk8cyf0RSDJt-0*mf^08{f2tR*ZhkTRba>lDBLR&C7R<)4u zi7{TbZCadBKa_r*oxV6QO1!ceG5M>A7z^)E>`Il{u#&3uF+Eg1V%1l`>X%qCVAgtT zEaIAj=;!*d%%I#-67<;t%=bDw2YtR6(u&BEJ%}gx0wYVB#+k*C@bPJgD*6x+MM+vK zP8aCa3*Ic8An+Sb7`TlS29Dx{f!jfN4+?tm`yjjr1r0JHy=yQ|7zoCxiX~=vyto(W zv)C8>4E6;-gMGpMu`jqk_67IDzTke?7u*;7g8O1$a3Aao?t^{7z2&&00smjb9nI;8 zJ4&kD@;{0@VrRMj9(PpQ5qFf7sNDQl_1w|_JnqN?u~Kh`*}aF5-q>NTKVv1j1ek3Q@7}zyH4ZL815XSvA=y5n0CVVj<^#RmsYK1F(x{V5_w{K zTZ70G{oAQTZ1th|j`A$(1UI)DAs_h0YH-A#Owbrv*(c*q0vm_cYMT%P!{O#b4)=A{ z6{~EBoDrgg-nLir2oc_pFSkRHHNBx|Ky&U(}eNbm? z+Zw^{3)`whJK@QE4$)8)zr~1#!tc)!K`QpY9Lojb=5yd#Czd{+?!fQX(Lv7ae>ojbXrBhTu=H=~4*dSn zqd9Z3cCiSK)NccC_r5vusBs*nnknd67SZ&->@Nd*WTE*Ds0ssJG=>>=^PjmRaz~(pJGK|? zN$Gx@QJ#7Aj}+o0g@@IVbEA?cKJ`}4Tl$|Y#C}mj^%pHvj&6D#5lwGcsr|H-*b&{N zzl3KN-GqDD^>3si?ibJJ@hnF-9Y73GJffT4p*!dZ8l*zBlM2yJoF6!3F$q#%Ky(wd zl%@?FaTU=`h<4H<>dC1T91u@}oZH9_XGA?=AHoio-6RaBi7LE1_=v;ve`@F{7sc~` z>VrrBThG5{&cxP9cPBSYxomC7x!!TZL(KK*HMbtgyUWT46Rvy#{PNgsR@O|o6B_K1 zgN`l@Cg##ewyTYpndkpFJoEX5gey7&FMk~|S88bg#+T1?g&VQA4qo|^ILe*GF150K z@X%YZTdkQE9KTq`mF&d#LRw#9E;~c-6cto(74Z0%)X|Hl>Q8egz2Ng7tUCOoxftH{ zlYb~XgGCnHxj5-){m(;>6`bcvA`vMt0r?le=N_jDFPv1&l~`Ex0QTx_ zR81)zT5c}eiL^phc;%$;xso$TYgWxYGvyptF&KN<>QqB7g#TnNh7Z5Qi<|TdcQTke ze5|1KPT|Fq^+(NY=Ug#%$dxSMIJRrf=c&;9$ zn^EUZ?(hY3VI7zJVNg$FSL{pkSQE|t9Z_&kAMHA@wk(?48;$cV(x`p2GcCAuv5NcL zi4JVncT0!w(2weM9G!auUPO#~(_xW~S&|bUvO0N^zLR|mKemL1U$LJFHt@?aDk5fB zJDoR)RO&K|Cj7`RdXeVLDu~}0Ex1mn{8)GyN?fSk4{N|J4-Hb<2zeZ}Z%BdFrj+tG zH{&;+Icgr>;VvNvkw`XF7-Da?F(o)Fm6e@)JwS08Fmp%9M-9E zfGz;OvxRt%q`6U3A|%AW>w9FM={8o?j?0J9^kBv(zN6DK1=pGaC*hOu*spNDz*suIEvyNgx0 zH?aq(Y5nYO5&E znM{$2-wfgwA#gu|dhVdHVnq^bVWrVZ#NH!?1fUJ9R6$?1*WlVBc#{Ct8<^)WM6Xlm z*a-d4Xcsd?28URZ1lj|Zi|kty_T6CziaUH*GK@oLLe$MDomt~m!y=M(8`^w%CSZk!U@T8W0EL$?! zt~hD^T!a2+$n9GA!cVNBG+1mqfRey6{1dWgVEYOK9VnK*LuntO%QDI@=?05`BJ|4V zgg(9UX@2C#@L;|QkHEY5Rs#Qr{X$OzeNK)!Sq-m8m+pig`3lljk)0WW?;?YJ(f|Vu z67%1IMw*5*uJ|Q;V3hlBacriBK6WIEv8rg$XVXET2ftml1bUL!nTNgnWg zy(0Hl=!BS>$Y%PNOu=gbdh-nSjCv8i9Hp7xw38jV0CzMrMkQ9oJ|7zF=rKR$5In&? zg!L0QSi`d=!7j8Fd9J{U9SkhRW0&C`rC;NqmtZpzdaMBS&%h@wz~lKI__v4bnc_$6 z#xBI^u;Es~&a~K|f4@IGfnz9tat{@{-G^2Mr5G-7`ngy!ne1H4kNgN$vjh0D6{T)L zGc+@+lR^Izpzk~y9>!TzNQW&5Jz(o>+44UY7Q&4S06iq8oMGHcmT0z9mhp}_(hiO3 zM!#MomZk&tgEA)hu>q9#kOwfopg}zTBXXw0tAO1}8|MEGxv&P7J{j_O z8FJPWe%KDuE8|JX*>1X*GGIaeGu*DwJb;^zDw5$AOAsaFz3>mXJ3(VY%AbT=ECJSG zae5MRu>>o>2w3ViGoE<@_B3A^c0M8&SjTt>{)ctp57;~ZN7y^$LH3S!;2qZd-(}z; z!#m=a#*6{KJ*D8CNdfb?fYV{k{{#NjummyqJH6|``^OH>m!StAv6z7;`TYslT@=`5 zewXArY;0T6! z@VX57I9PKSzyH)vhE^Pn8eDw{;~st-dWT-fdpg1YQWx-l*=Q=IgVHj;r@;cW!k~Y* zzd=fe#wZ0eU3x$c!0X@)Asv|N0>66q#jvIUT0h3;FH`$_$nQD;mR&m)P`{J}O$E7- z();7vD@;3qkk^>g`XTJcb(+6S>@-lY3)=~Y>s2Tjv;yYw% zyAP>d9gI1RcpasuzmNVy*0(l-vpz+7L8T9fR%0I;S6wFwl1;xdB zc(u^eYmnEgyU{xI&@kA)wgMA~4UtkJ@J(>YjkKl7An!iK7e!|57y1LzAn5$34dM;d zIrCk3YKma#gzj7RF6{eD(F?TG^aAvJZ&<`3TWBBIL)7ebY zewU;Y?5~aRB$5Y%2ER;-UWe{l)0Z-u@~mf&An|%E6}rziz}^m<{Q&R{bT#W;dn^?n z=&?Hsy}J%ts^63)P5QVAbcXH*hr)M*Z^}p`e2g(25+$f3Fn%Z_3*$Tck57Yt0x5rE z3+h6CCRLCoap`>YXeRgqZMAKIFX1_~`!NyY(f;(0(L;kQd4eA8rB6q`mt`rF(n2xD zTub7I`x3EeKdh&W@BGO0^K5L<|J2_Q_`d$lr-7NiXfvINRq^uZ!lX=Wk4Kvft7zwb zIA!aQLElR11DiCpk=KsNb^ zLxf3&EQ=AK5oA%83gA<4#jd&=ACrD-Sbv%B6R#VZ~@o5 zc*_zdmOq4khP;nq5(5sySS6OPm8DGo9_XJ2eX!aWvye5IDInI3jZuE!s|L zlFPZEen^-8easP9SvFRI_H1UxmwmyaQ&{aT{ZSgY82v0-fz}!R9o?Cs8OfkxQp7Zw zIO=^^<(Z641pn7Lpznv#D$2-u$ZD6;Fp{&#K+Md^ZM46E+F4mA%+A=X64D@!Tt)`05&Ytf3^USd zJFM;KLu#rrRz-XZ`4}g|CELU?FB4CgX}>kfjEt26E<7WQ_IFV?D~UM*gN^Y&KV~UK zREk`8A3*$$LRG}nb-4q2KDCcX#cgB%upN+LLpW6Wyi&K?H^)jr?Ty!W=B=fma z>H*dweZcFi=EWF$5N!c(J{v`(&fs;%=W^}$6**zGGt4;n-F~qTo44Q_Xj%3y=4!)@ za(*+`YEEs0VTIK#d_2Y|nR)0!=#f(3-+>R=SB4n@!xr-79I%k&he&-VRrTWC+hgK{)r*PBS8@rassL>qPDyZc4tDjU{$AeMij5J zSf2~~0(^sNwX>9>cIIP$#Mq1ZCU(!_yb_ygl zhrqfqgxKFY$E>?=TZw)n?593?g!u!cVkc*2{kEUC!p>_y3v25HD`*7k>O?IK>in5H z$kkJ`!}UjXkiH3bNgP)Pb+1*!id(f^9P=C7waoT2vt7(=H?zNNsb~9{{Ug|JX0~gY z?Pq4Yn0Mg!Jtf`Dp4e_?wriQ~XJ)&Yci>mq*Ua`a`wwTknc1#owx5~pV%~vYWnVMf z&-~tSwwoF4)1Xx>{WH1)zy7i%KPZho?ZE&4gWb+m+jF1T?VNwBu~go(D(`l#y7bxq zbhq>1Ia#T)zu+%{S9{lu{7rkaqW$!}vrQKpT~mhEDrwP!WPiaG*_S5~-e)kvTAK7rtFfw*eZ2jV>CAmwqAC0uQ#&-e#Cp-2*AEp0c zAGBNXeO<>s=>NmqyT?^kZGGc&uC*_Kh~!c*F;li8d6LYf;H8K5CIKZy^MXo++)+|A z#rvJb-UxCj7O;V>P>|&DeX?>LrSv(LB%oAODxhYD+(eH78Y-HX_dEB7Q=Rub@9+0~ zKEFSHf9$>1nrqGLnsco29b?QfmNhv4X&>~L9gSRIHsTQc``N>04x2%Jv&Ze6b@nWV zoSw}f%p5j@;ECNKLRISU1Ln>xmCo@gb*bY?%%7{O&c-jY#-_e_tHggoKm{}!O@Yt> zfMZuP9RRV+MLA0#`J4~=|5=>dbN~c80PJ$c%$(2WE6!~?0L(zlj-3mrZr5~Lx(hQh zX%J=`W|owQJ@coCE_ngL?7}|z7VMEfPjsg9m`$g!FFu--ra+^#dk|)iit17^i!p1~ z9r>3X&%a&MU}t>SHO>3IJDz`*#-_sYemhsIE5T0Z5AZ9)?*M)W@%vBw4&nD6c0ZTk z_W^!o_#MFSpgs5IA9h2(@B}3dS2w-+&)v^#Fa4RH|J&Wro!9POWSR*L_9T9pS!LBs zXjXmsad*fnPgU z-o5b$!7g7GG%kv4a(PYAZg?Q41U~57rKlTKNp&7EMCUn}>c*<6^S2AW9rD8)KRjQ@ zb}#yL*vI=QamUMh5?=D@nX}&~#Oby2^=gaHHR;*%hQ`+gr|q!Q-!=B&RWIgUfs@rnVmAnSV8nTT*HbrnBz8g~!t;0o zc0;~~-H;L34f!_P4LJt)p(}oWG9ODVeIIas^M&n~rP&h+KRXez?-me#)^x%Lst7-G5RrmL5&qR^ z#IVmHd{8psgU=Ib`YFP{GKfkuRVa4>&HGZC)lH>WlBuNs8*Teunx*2TS3jVL+fLgq zOS5ti$$b!4bSm}-X;w04I(_Av*am6VApX$#gBRMq#?y$jo`bySQN}6C`!C=mzv+kv zf1Z=(APRiI0v;_#6!}xU^y+!O4W;DpZo|?cjn~k&A5kW+w3#SBYa*u`_3l&BtVAyI zBtL5uv@O!CXfCpnpY=B`_FJ@(i@eCs`haVzlxDSXkv|~^--c%r`KE92Jl|G_=lP~{ z?2FuX5zq5(ZhHvRis(kLa$9&eS8#!G<(LQWK7~?wY1Rk4--4!}#GA2JiEJ5c2zHZz z1#Z|oXnBV~VvcWh+!*R+!HR{{R%u&JEQM*1Cad5JBZbB;V3q;OTZ?F9pxe{Ql<8a? z7FcyL@QlVZFP5RuLBird6X*xcib$I_L0`foz0eTg31~>HHWx7qiDjUQPF)}-1Vf5E z86~9<(wnk~pNuBikNq2aqfwc&*P#SM{ zv|C(0mRd|4q&s$Y)Kj?u%$>@i+yY`r?uY%CokVln_hDO4O;g!JznlCR5aQ9hbi9W_ zCyjv<#?c1%qg^Ed(^3c634>s>^G<*6=uL}h0A%^8B!1&G`vG=GTUh@rn*QWyyQ#yX z89Ue{Ja3v`Z1AI~6? z$7$$UzQQ%+uH1acw%WkQvOK^x!BzMw9S;8pp1C3+Nxnn*kD$$X4nC?(kTexF0mD?% zkBW}(Tu;P~4R~hrlg=1#WGK5EBR4yAN3V#Fla(nYwVdE6_|LO}UTnG7p`2 z`m}v@5n?55mV*ybaosxT)2@KiMh_Ao`9eFV6j^?bf>d)Me1$0C%6A_V{*WEYt0Qhd z)rTK1XpZ1V?*Qc7j|+e|6BudyP{m+7VIHYZhwxnFT$1kxR2cyZgs1|rxjrwm@WdkQ@#|D zYet>M*mT0Suog)92((TUV1HLjo-6)*pZF~_b*>AY4rutdyI`G;=HZ(+qnq>@rZb z!D3td>I>r5a5h{C4}&)t_wXs1N>>+|mhxBB8HU}TXCuV5=iq5lX?zd3@K-vrXqt8d z`Vt#%Id>Eit#ue}7b59qaMF9HVgDBgYx_29AD=6Bn_rKe4;931A;uNG{=8VZk2UsH z9rQIA*Thr6_xtITed5|*QKNvhF_Dhk$I$2TcQ9YI_o(GuT)5@ua#&^QA6C|{n@(n zMf)q2ciVBzF3Y}+?3&jdk{*_(fp6Trp8t4t-|XjRA53lAb2#m}12wUayC?K`x7>&= zym_$2yD=^HXxG8mqwVg?rc6KXk{W(wmRrcC@JX2!Cb4oMA+O~0;1~XrSbp`#WX_?aIL*1^7 z3Fi@ZPr2&?ZEpiSq7KKZ`!IreGvyK@cZfYc$Qy&&urs$!Ltliyw1fy)eWOOa3H8U= zk9&b?c2sTWOJ7wTH(em`D+d3fC9B2C8_-H5V>L|#GQSSn6UcCx_p)8nkPSkYyBPH41SGWhY6VxLwMW94uR;svzw%Wn zQMz1-_yk<~f~k1+H1OVq@R~HsJkRiHPmhhTu9W$ku^Nw{4jt?ZKmna#FVNXYOE!wH zNSIFtA|e+kUk)T?k^KIzRY^OJO9ScgfjJS!cQ;bY+z+7Nh>oyYb))p-NoSa(36%E) zs8=ySz3I(mieXMsid5VZ3!bOe#%)_MHpdzg#Wun@QoZO7pV@yrOkf$R8^ zy4794>hiSq#B%Xv_%m1<;eOezZbvx(K|i(VzYz1_Km=>`@pp2tPHv#zXc6poMgK{3 zS}|)GWOuNjZd%1G!Lh=s7vjDbW<@LHvgP61vY0)#a{V+Nm*sXo4)(c+K)(lJ=ZpeunqF2&3GAne%akC3IQ1Wj=KGHOGi$;m>%x z3aLqFI~c_K9ccS}a>P&C|Fv4V4*9LB8>5f;@$_dA$Ft``O9r`DVK`=PC|j|h^?vk4 z_+s>Ax)tnH)}XB-b}75k_xBx_baYbI-}hM>mW6-5Pv^N;<#E;Rk}lkmtO5Nmjj&w& z3?t@4Ee+?wmB0t&^Q_249uFChQPyKgCd8vviQGXfssZ?douy@s;wD@rI}-7_JN3A3 zQK_sm%i|YrX4UlL*`GxOwJQ}%h~E;AuD0oHHT(W>JmoggdE8X#Mt!5wja{MAJ^!C7 zo!2mxZp2=#&OKe@(OvVOB#Nu zJaN-H5y8=sYc8;S=(OBn-LX(kQG2xX!_UAE)#B!CmJ8d*0`%PB-c*jxUZxKAq0}ZFim5t`T7!I?t0j$N~rI zM!cr${Nzch1nkF8gjMfESoKbURqqs7^-hIV@6=smVC6doR=ysv^7Vj~?`T;0j+Xvo z*C?x%?_ZL3F0A>Vc^B;eXYYdRCaU4Bi|h3-ICsvE|BX!V@xS*kn3eOpOpo~-wE7pE z+gWe(FZglxzmw@1tX_!zy-d%@js2s4K~*)!WHP<}1*02={7EYFNoMu`2QoeG1C5Rh z(w36*8r@Ufno{nRMklmrm}Tb2rl>(`=l{P;^&nNGy0mU4RivdjzG0L4?O|u7_=i-H z)1|ZfqqtHWhs|XYPF)5&E5-jy3o@D^S>$w??3rk;6vsDg&t$Q)QvCl6fzB;79ZBhujIh74}Qy_1M<{?p^4;~ylA5e-BCJg0*T2L>huFe`R9W_4`! zxM+bF#9oh!uuFz-#kzX>yVu2;SS}2okM`~|nU9@^!bZanC=5dj8Wxg7( zBlUNWi?|0LjNfLlTz;-LkBb+xO0-Qb?@M*?wiskQ(bd_wqu%PDu!H$pboW2(?+3q& z++xep6zaBAYqLYXg7b4)*bBE1%bP~(hS}xzrW!UHM&foEa?Bqg$7D9mp(gm+*Vt^D zzlT)h9L||d^Wxo9*RKU}qaht9f1{Q~cC?gy02}5U*f4(p4~@yRluLvSb2L0OLY@g5 zfl}!Ib?S+*VLnB4nnAE(P9!?*0(fYIJkze5mfE$zhWR{VRG*@y4$(yCIEa=yc9XJ2 zZi{vkU4PWpUqzeqwT;<}-0D*xr`1~9t#dks7}h9b0G=4oO&%%#BK!Q4w@3H^ZQOCn zIzE@V?*Go)quM(E*?2-ra1u{HqrUJz^Y(}t(jW58_lkO$d~;|2RiAuZmbCMuO4sd} z*S@vCxvs@g{OUK`>^uFNZ8qY#9_ijyznO7_f4JXlv$gs++w86WO*dXmY|(BY-+T%3 z&GCZ~5mn7Q-yig7^3|7Gb)H8YnS`^qo)lX>uk7xvXSc1M8L75<0*t-&7;W_gntJOo zMKB5Hw~$v(fGqRHvB_Sk%tN9KA53=rA;BCEN#_JeIA4T>^F>HFUx0-31xPqwK#mu1 ze_W^j=UZvXc6NvYn<+a{`>_(Nq!E1tcHrN^HvIHxcz&Bi{9Kz{nJ2i5SyI(Pcrd<3 zONQ{(&>3HIrQ8siSSc1d0QE- zME-FV5iX{NEAKo32UztYI-Rz*j$sRG0Q&=4M$xn@8V-c!jGGY z9I}}R4Qb)Rk~EaNfy%EBMgC1x8lR~WXT{-q6|AW)68R03=kf&DG7pw2>#6W#0*TBM z&5zMGUm$(#qz{av-g)wmPltyTOpvD*2tRHj@}<>8u1^gY8g?S*dMf|uY^|jxpLrNi zs$!`eDn^_|+l0_4B41oa#HW7^7xQ}1h9y+KG&w>H2%!~uUS@H|OxQnoQ}295U5)rk zkL`6Yb=d31U+JftnBbtBJiMRoC8vJ6@h2SjxkE0`H)pSD=Y4jKk-X_h{^ecI3n9Ss z@aQ2d@`?C7>(eaWhmPjGca4zi))Q$meST`5&|nIe>s`VpWsS=^Y(~Dn@RPA?N~F)> zx#xIa@K?UO#-_O!9VUCahlCgIpxFRpXS{6j|t?bs)?j%v5(S@$`?H%=fTsvhSti=7XgcMP$dgLcZACqynq{? zr6`q3pJ{20EEYUQ$_w;?tSrZF&8L-cVd+=a6lF86?Y9%rtr?gKeEytOz?$`t7%ibL z#Yz$+oEyDpaItdw0;$LrLa&Dli=T@SgB+;n_C>h3teGl9??+T+i$S5-&-5LT|1zp< zZl-6m`4cHr9NLLFV+2}14W0T7NM0AyqI{RUihSiOFM7K``4zPnf!F`)96eefTsR#e zhqQ$=*?H9{c6b z5SM(sY3!0$njk~_T1V#y=X20l7pNMn^iV z<%btZq8fhwF}m05srUfz19-P6TUm?RuFs^0v&AaR`g5KU!A0`Lc3`DVM3H+U4rjA7 zA*41!5l)1Q0qaR^wm1|eU$4a)2%s+c=oxRFPxou7OP(~r$cxLT(4ahJCdN5i4)u&s zqG}<#w5NqA>pB&GJqK&9T2P^Oj8atuTV2Y~qj+QUv6JEz%F6GFP@cwHn>iB*QKU85 zg)(%gs=Pc=S_4m{YE|>Qu4nnFDk@D$ru-D>X~rjW(#tB|aqHypm!gLj9xhl@aQB^x z+@r+-<_BgEqi!<3u`f-ey78m%RT#uMK7@x_esbeWE!ZnHPB-x?em}rh=TqZ!<4?jT zC&llir%U7T7VKt(AI}KS&i7vaFJPMqz%~_kr^j?{)P{QmK68mttDAVASTT(qEN%2wkLgxSW3TST zH2P;V|1GA`Hrp_b_BrM=-R^gv={s_PX?kov)9uy0m`49astwZ=%m2hQ+TVSq_hOm| z=KmF%W$hn*rmNL|!Zdc(D)1EAUQCnF;Er#h_Mbk}-wS>)Bz#zOjZqG1;4`&#Sx#EH zA>}P$QOa9e8#lHvRI+1F)HCYgR$Bn8tpT_qW^1tQeVJJrNO9Al^<~#rC1z_dY}rYP z*&5)!#5|`DTV}H~0A{dS8noCf4OSpGvoMGQzl7tk703+>gJCO>+j>N9SQrdjVY4#; zTCmy~04*>(gVo3lO)=``n8tY6YUBnI8MYd^tw-br5*fDIhDW-4@komek0c^Da0u$= z7#{BX=e{ zT6@p#v)%ak$1$$K#0KRoABiN2;5lm0$VA%D2lNjUw(%HTP@poV_(R^-$`B zG;g4L#>;vbHEo^mWqxfqWljKZdLnaBbJkWIx@c>K*CkskN-x=3;rXMj6`l>Y zR+Kf^S~2E=trZ_$_~;8h@DBedFsl0+>Gf#b08Vza>_DBs#WEfK7{>R87?0W_*&~WQ zxL!|iykich9!>5X(KRM&xY}KCphFPFm~<_{JL5`NL}M~{X<1)HWAaWI>#y~RHxYFl z(dOCtJ7K^Uq$}(LXIZ1-LQEdU8e`{(cNUfzJxS+y{VV({DrolyVeRaKcK=->%@_si zfcKeo04xPQg=T*`nUuu`oFqy$G*|wEDgVj>M981*K?FN(4&bXLO^KN|f?8N@3A#_`qG(5>)t-x}cqd zSPA~`f)~|TEgevT|NG!&t0{WNN(lHAvTo!Ho{kcvfRnKDxAu>f68Hm3Vfmu1rCdcR zRmhjf+TnkTam2`%Y%PUywsFa^mhu$G#sqz|mNJrK){f|-wUie*HYThDL_80M<_crN z+VL8Po+7txR3bR^l)jn42focI)fkx>-MCT$V+sDNU99I?{IkgQ_=bAo8|sN~=ooxM z$KV_4fp4e>zM-Rmte#^rR&OzkRjZr43ux-y@yr9c4PzOJ17_S)1c&Wo*Vb%|-)M>+ zoANutN+Ap_Fyz1xmb`Eec=*W?pJoa`SmO}=4z}}E-qdlGG(}4f=auChEtW$j17B%5 z0jNwqzm5o(23zr!2&6VCQ?54xsRZ=ms}#H$4_F=l6J6!}0bQNSv7xI|i8M5?HZKUJ z+t8JkpHO_%imn8pE4ki=uB`Z{7hS1#h~EJFSh3X^E4C7Wt;B#l%qJVRYRXmeY}m?% zxMJzyTpPCXD)R2dR*6Iawvy|U8IK5THOPvwf@D}jqa22{3pNrb7JN&EX9{Q0ehYXE*u>pH?)Q;)jo8dSt(c;&SJQ+D~6Vi znHiSi%QH!h`Kb9szEV|5m?jgL@}g&ipIJOzg?9k%8fY6=`HOL;q26Zk2G*8cGm$=b z0&ff)BC4^cZZK5}yCdZKQ@|J)MN3PB^57K1CBUjI!W`R)Broxdm>{3X6Jvpv7NKOc z3&@=erDim2 zjoV0Ykuvxiki@+Rd2uyLn@s(RluZwS8$W~2t&PZa9udL?h7La?h53ka){MDV5Q6K; zv@qYVNZegb6?0F7bOaKRbEly-{gn*cq@2OH#ly$i<{F}GcBO~R${l;;YsY<&t^pnn zr8QX9>zGwp*if}VPH@T@4=(H)VVv{Ap7 zt4oaW;EJBz_tY&8DK&6fTTsejpOO=4Sg!1A)?n)s&8ep^bm za9f*J_^m8i83T@~!xXjc;!ruoQGLTKUP~d*?1>tz9V%!C5i#YOUhvsK1X#E**>aS< zc8EIQ-3o7$BcwOpB)vIG?c?ScoqjEf3+(5(Ed3u*PK)dvSEP4FImFpJu1^0oN|R!5 zmssGx`(6|+x3k;0&wuxCQI6#r$F1p4qU^?KJ;V3=A83x^%Qc?SfAv3bEy}4}<+G|Ta+4STTl8QcoId* zIbbX`u@lvQ1E;AbqOqL3uZC=tHk(@?3mYJt*kF2M_)gSz!?sdO6!ktTrrd#?xmqQp zxRR(H4H{9?0&Ye_yv%}W4DNiWib|~4h}u&~)e^5fA;kx&WvZx64XQsr8)$oWdp-BZ zXBXL?UE9D}pABH;7HC<0QTH2&wH<+oO>WlhzNR|5S2uB=+B-+d_(|WKv$y4n_06t& z()^UVg}qzd#Qv&Xv;O&Ji~3)A=E$x>{XHkQTs;isqxi|jM6k;O9;G_FL^v?5me_*RT#kGOoy zNZt6+utYzql7T#cejU{6sK-?tuI!@J^*Fk!3$jKAh}t?5^=q}~y8%(R8$412qV6_~ zih5T&O4bs)ms-vu-cx7Sceg>b?00GfwFYrVMAI3)dv51Dk5kSXbrUD5-HpPM)VB~H zrhjMG3thlg;J$$lnJ&y`fzQyD6&0Vd#uFRH&=o_&3|(1|KvxVEN83fX z87f|3L&d<h!cR-^?153eI z_hWv#nv^}LxqG&w{>G)YD@~D)V;{udO}Jh3@I+(qFWc`QYdL!T@Exx%em1ppF@n2>*&d5Ks(kz>S2TlM6fWBd`mvgk5+mkzQ2;&*jn+8T_olR0@ot z(kvrtK0+(H0j#dp>I3rKR3MX_M?CnDX@-{UTNh@2Dl>*#$r;URqUtoe9(T*y=Q8Z zU{=8&T_+LDMk42?5g~^|OrCyJarl}m*`lg(0a-%1#PC{aV+M&V!XmtL@LeLyVGuuiQ`_}KaPp05%dOh#o-hBrl=!F` zpNN}MVHMg*#eOTff}L%}eh$jR7Fxn7$}2A%S1_reFs882K)!zr*dHy&0NK};X^Bh!U5p%$5M~w zgQbpDY}Af5#^#IKXLxyU1`(b~rJ37;m&<3}5o1W{#O`m{va?u70?Jn&HPMcn()39* zDN??)861g{6v-)fZqP$rWiev>Y4I;|c8$d*#V@$4OD`~fZ|DJ|^Gt|Jm z{IVVKtX+QnuKz}bCCf?1&a!Rdw(GR4+oe15 zt)v(wIiBk%ZrUcMp5c|Gq5SXyMLJKF%wv3QT1B>!`ZRy6NKAQ582t@?o5kH<@Wa{6;O`On=lKRiG$DtI z74-(a*G+|A44&^e-z1AEzY<}wwPdMT?-#c|U$aHjmczZ5l5_{XoM&J@yp_y_dW9Rx z2bn$0iuxWrD;P2J$QEicu^cYXDTt#~M>^#HS?6#HL z3l!CTl()ceHC=Ql&?~A2PO}{PHwme|trOEX>8qmVwMX_S4i~tZ9MS$XS5&A-4iwtXER6a2^HotmTOhl7U@k8tRf2sgAo;AUafY9kRHmk{e#A@XNShOVV;>&spxQ zET*HMp>G((t?5LOB!iNXNR-rzT$=@+*3)ab8`hQxKlRp=x=CLar6gbG!uFQDRg|Of zah#I=BX>#m*rM#I;4bYI_r!2z7DaWMkF<#CKXXMnXLCewK+0N-e{!NeDM|>%?2x|V znhJ%>ID2q|VmC)x0@o(rkQKB>e8gH(YH;5sUw*?7yYEV&vgbQaajfQREKL^i{y%6? zi*WgM1Gh=(H-u+3R`P8YCH*(__zV|fkuSYrfae0D?6DZmS`^1AR7qXMiSW^ax#Zx= zM-~WgEHQ|iplP%Fj1MiGmoI|Ogybs@JwTI~v34^I@+Gt^wU&=%qmcF;j}hFng7YYh zEVRuf%=zTO{BVnPM&u|`1&{Zy!hEv6M|D<+SxFm?`-<@he+h5uS-w6OGbi;9yhMQN z7#AAki%Sej))%~zyp}fQ2$vTalx}=)q;L3&;-+HZ$EAj;7Wwtp4Tmg3!|Mhso!asb zs-z9%F-pJTEb|19Iba;d-z@~?Ai{+(gYp>GyJ?pU^#ux4=8qb$m}c~ZH4T`+aQN} z>%~mGyF71M4w-Kd*Me>j)s%oJp7;rB`;l^7D#rL$7bEMaYNddAU7mb1?{) zT=e@*N=i9jmLFi2u_hTc6NUuh3#A5{4>Smi1NASaJcc}JYP-;|*br*=YV>Pit2Zpa zR8>ckV&$;KhN2?5A26oRo#Jd-5N84YC2&lzmxG!^w-GNGOE zn?B&R-0yhYVxunkEhMta`y}IJ8Lt(5UEzy3+1~VnR|_Aoj(FUZAlsL722l3_CE&Sw zFgC@+58R`Gvn)hJPoh4jx}JnwW&D`zFfm3DG_*vmw8jvT595P=@v(P6yxL7inHTm; z53lqzb+>ihxY>cPuP-0m#m_K;lVtC4G4?+0Cqdmqdwc|rC_NXbV(-O^_D_hw3vVi| zXy?2ohN^67))DYAmEex+h`XI)m%#a0?mP*3h=^w3obM1HP2&@x-!waZb}Q-S0{SPw z7T^r=Q&lxGA1%u6+e94jfL}^15Mqf+^M5V zQnmpjj{g1vdyQbCWZfYyd&TWnsC*V{Yt;_Qcq^sfVXU;D-cbz|X`Cz4W>PJvJ!nZt|e;alYRU<+ravgDXi@f#|f|ApbniP-m28 zji$B5qUr;~?OZ>5O+rVD(hoNJ%TuW|JBISJ)=}Z-rG~I#e#Rtl!T$7iuAHBa7A`e- z6-hIV*sJJCOZv-$9jTc706R`|X@yE@{X52b87W(dvBMbivlmij3$#Vc^4Sj*wg)tP zv!Mj2AQk<>>Ukp!lzD-n!*VO#&%UCt$)dFG$DRsM_~d+jq;_F4_Ge*C9}tfm#uv$T zuN%bl`=lgS={`*cOJd?T7#DE)OF-+A3-v<7LPJnKD0gbAo{d3;xi+716ID4%PcV5Y%#R-d1 zx@t1-zKL1Ni|fzR$VBBK=FpR$t)tG~mh#(=RMGTs%7zA-m&4vHi`ONltcQ))B12i8 zh;(^=ie6r#HRwny#tF1l>&dhAC1-~~;<*GCWZs5SvJQ4z-ujPBpy=Fo^RawJ|JZ2C z#hx~v@O(S^woEVLtN$Qd9I%K8i)l)Afjfx2^jU)t`YiaTW&9d>s9BiL zXe`EQX`o&h9!Lbx*cVf#c7^2+%@-CeHh7tJ9HXLQ$9Gg-xX7@k{X{NX!Hm{c#KsqG zz9}yXGaNSSKv|n&h5AK?SZj}zdkvtZ7zx&2i~{>&l;Rn2eLYQDA1Ny>3yA#85OAk) z(6iVmr#!z$FK17}y9ebgM(dyTAY%02fG0|+{L?}`qn%3A8`%H#5Y*O2H^t6>uyp`> zdBpfpitu`y9(x~pY4&K)>|EHD`h#X0A$!~ZCmQ;+NyiU{r`$SFZ#BH+u7ihMMokxj znjRsg4way$prTf4+6rn~N#x;c$=F2sbperlex$xl?{aVTxLWhDw&!g3rJGmUn_Ai$;~ysU@Ij!K zSfTvPg;-O$SZQido6$siWg(ILazU%=u$Nnn{obR2#{H=j%vPe2^1=Iod+VUFoCM0` z56y23FuV~m!2OURv_f_;3DkWZw5~^}FAykbJy5>8Ly5h+eTiynyZ6lrUB2C7{4wzL zf5qx5pngt%s*(YI11g-9mSiB(rSKl=h5W7f7ET8(dKnQ*i->!C#q!AI-1d^qeBcpC zC)T01JEX~uhkq-vWj86|OE`DF{KB0#{Ol^!R!rCwN&Os}>?7?Xoy4g5&?d^ePx~rS zpkPf4i7+GuXg!w5gN^XpEXF^eOi6`b&-Zko+B=WsbI~e3U(yM=Q*+r<>|0fDd8{z4h}>4OZKQD~=E%Vmsmc}7WxW6DF+{3_Jftd+EX-JvAyq9xS{#|3 z@mhvdxdG`Zz3FD8f7qK|j`Zr@^ai9~>rKChbS<@&16_Xg7-}oW2eFU?d(#&oJ+3!> z1JYA^)6Gc#us6LN>D9gI4M@L+bgB9teJz8P{bt6tNp1^`diCfeM3g2RtebZpI@tg1 zl8)FL@ofoBWjAWtYMbUg4tWrIH|%y~hg7{EUx+favktSU9zLJ%z~)!Y{6ACv8_fS# zJqWxn;|m)CAJLd)GuIQ})}UKc8@*dR+Woq!y7*T#kYI&S#8ZYxY0Ug@bFp$vkTK=K zzxffASV^Q=v1I7Y2RHdwj^f!WQq(Pd)oRs)OEX-ER51y4`eTiGW4#2B8xIq%x?f-a zi|-we`$Hc+xLVZO)+5boL+O>2{|f6ZxQt5Q9>H2YO53hWv#RLO?lnCn{e|KEsUSO2 zcYA(LJz`0N?sa@i#V8#u+2*&2Kh;X5SK1)ex(iR!SS1N3Z1+^c(^;ffKGpJpbqxw( zUPPF$1Lsh|WA8R)d^98^%{oX1Qm9SJqc|q$l@4HlmPE*UwgW{yBtE_lWZzCDxkxXZ zn{SYvd{6rLLKo0qCM6nKmHC4(9N%QsBkUDLeAfX!PT-K{FE9*$h<&nDmis{-BKO;V zRB1}0+;-7%EnfmpMpcLsrL=D(ep;pdG`-P&_ZNl?#Qx`atCXuK4Quos6|Z#w>AD!o ztGb@ZyS-!l?1bTaXwx0GS_9G*jl{FatsdLuXB}u8zC)QLV7t@m_GG6tmozklTRz%6(gwj3`Na8XPoiZ7JLJE>Z)dQlaFv>t> zY(8CisF*J3#Vsn@v`jv`z)+Yi_KP>ft_|2+;s9Q`^8vm@&(frgUK`vU{D1;r74XeE ztfLqzoiSo1nyAvPLhl7Cy}F(%sv^BqnM;Ebj>+=RFJo25q8}n8=y^1uQIuBc<)7X( zn0BNs>yQP=MN6F6_fWnJ1P$(!dE$a?az`*-m|UUu)0Auz_uK~loP;lw2WFe!rr08n z9Hrwic1FvEkXR<-O@dnu$Z^*yyYG_N93?p$5&IU=vI4d?Ku}Wd>gA;vg}W{EL~&-K+_2P8yG6-t;KhyCX~Nc| zmE!te!2dQw!u<=lI#4DiH%FVqR5N$DNT_FWi4Lq3CtI%=f@U&+OPP9Wi<39-0R=&M z@?vnfOnLyBfiRSDxHBPN^wy6viF;D`qMWjP`TSxlVwYvc*=~aDRsONvin!NUF69VU zFp7|%2zw#F`o|vXuvL*ZaAA#HtbBQpKDMQl2agjg{5Z(4KiQ*QNlD>Ldg+C5&UT1n{fg%k!46*?e|0lVdQRxITKq%09Wm*%fP6^zpOV zTPAGLX)3`T-vMV_XO%NpWe`?bLuIe5q4i&64KXBpCuub#^*i2p z_sMbdHN>I1L!!R^nU-eNKhY5lQIBaJR{hARSyz5sdq>~=&4$ONS$7{dtJ|LlYWSkG zQyC7Yz!SgSi|^@nYFW#6_dMh)wPfXe-A*$(Q@-TwvYTfc4Nv*lR%_Ssv4gOPJ@8k;=NsA(&E1f9>F`d?&wmHJQ`>`SKv8)0oqP|Fx*1+N;? z>HKAshtV~FK>Jm73f891jap`z3OiKixrxE3%fl$!?^44m)uYVXmoLLTOtxO>)wR}k zzPHo^ZD%bWp<=yfXFGY@akWQqZ%R1VckfFo+f%y|YiW(I_mvh-Mm^h+*&?y-iF$U^J`M|11dLcm=TrqAt$JlwewIb#%&Ar zKOFwMg>Xkavk|tnta0|$FSafG7Vo&E>ujT!^&P_s$9Oo^9q*XgZ`TJUVZ(HA5d1q$dS_$9UyH=d%&gfk$WW<*Lv%MqV z);w|l&EsCsufd)3{t8rUz|zZ`|(afCcU@!H$kPnC53Zn(Ws;AzA20Cw6u{ z`LxU!3R{*PpUPS~T~;M?zDBk?+iW@95c@tHl+a*-09=*=B87 zs*JgbCtk&Tc}?z5hn5%<(C5Z2Se3)dtaD<-7QvA|TWgF&diZ8_{G&u6S+z#iI=owA z9x_BszDC@Q9axhpb*D4&tihNG{gbtw&iXM;uo#m2C3Bczph97Is*~(|3wvU=3drqY z9yWw&F|JtWVE8C^Jb|@F!k4@EuIIUv!c6YQGR(nGCI*{sF#98C=ZUZ|GqEhhau|I%2I(k40Y8*!AQ;Sq=}QkcyTN=U1*EPmmFP8jR=tSiVT3 z?6}Jn>%+iu$D*81^stu?o@ev`<>@4PP?#Dvj&o7}^G$3F=Xa9#mb-d0Diyn~jon*_ z{Qohpx6Uv2+v-GpFWKrtd#BmzLVKt7)iZu7>RH@Z&zDPj>j}mC8f~rT%}z2R%2rQX zg0-Hd$nV`(&&$Pq^}I8wub!@nef7+p*jLXdBI=3kt4BY-x1MP9=UHn#h(+_>8@>Hm zX02yA`ZK<-o-gwI>T!Odub$Ojef4}luCJc)<516vzIwi#)mu*j`ZL5@&)euvNN;~4 zto5u#f1c~B=jH6adfxHutEX#BUp;fj^wsl;2kKehSC2lRx1NpY&p>NEThO05z5RLJ zTF*B0N7q-+7km2Zadz*kXZ6UwdcGggSI_tnvg;?FqQfoHSJ%#$jd5~HN|J|3RNo?) z9nM`2EJbS+jblg?X3K~f)+bWfwXFpK^V^WV+RfcK&%|cr&r)CQA|s;s`6cj|HX-8O4WY&Dl*U3UqLLVoEb z?@8mr*x7hF#X;?B^lj-RSOCjp?JZH(+Q&h3mg?+PF%eoK?u*S$*HrL)ghJBr}{_L9Kwhi6p z7L^j{R&V1%BCpuKS`j5N!RYyW`Y=T;8z{w?qEz1-A>=J0B^!M6I=cq6u|77>?YjMYNndX1D`~`*zLIus?klNr zQ(s9!ULd4=clEvV)<dUs>Vn`^x%qU0+!v*4fH(tG`G8qsT49 zMPIUEn&wMAc9D#5(XjUYoO$FArHxqASK7|E`$}tE-B+4^b#G}6_vq)vmIhb-aYz^c z+)B+jTPp`VQU6__bjO3$p^3*5hi-bXJT(4T{Lr`VZVB0XbgRp@yDO)F(|P;tpzDm^ zb4P4Hwv!~tC>zyk7UK^TdmXqip4E-_OLi`kjmg{x`2OQfk@w=ttXy1U7`r!O8ncLW z8@!JEHH-ODU=jRoM6(!vOve4YZ&fwC>EP#y-&p*f$IlDD5o&nTQFBQa}Z(NFRWpuDGvE(jhn?~QrKGlSu24W8JYeBt|02Tz2ph`yCi z98b42K@2mI+fv(L}Yf9>*Xx)H5Z=Z^A6p?nXN|1Z90ZCV1R8~9yQ z@J}rP*w?0JFoUq3t4xTqVZl(UKL?nf-+6Bbd)ucaFU4wA!&m>dGHj4~vuUKEyq?aQqD;3Eh4 zY30ks1{MpJ=}N_G&7{aqp1;>H&`!RXV-S|)7+iRte&q3RxvIdgK#2?+F*{5CG25Wj z>>a7p+!(2(6&lLb%g+3W*pb%I!!SA!pt=Gw55L*I?@S1J)2f zHu~S|6qrU~!D~YdJ{owgHG9C*mBl=Ug~j)FJKw2i_bq3W3>NtXNa@y)2PueQ zxZ^v*#!AGKmU&C{$1@ucArR;Df7V+DzDz7XuQISzao1M*;|@HHQHr?(kE*`4?)K}n%N@}^%qjxJB z0`H=d0WvA9r?220jSX=^-qe}0#c(LJr*6a*%*#&qR9itax)O1OPTf>KX{D%Y>kY=!8yRCaP zv`3k*Ww5xcOvlEgJmcQ(xH>FI`Uq51r{Q?dD(EmRdCw5{4v!l_ZNW`vAJyN#bmz*g zreE5w-)IGWC;W3&)~^64zl{RFpaB&Ig&Z3|hXwQh%b)Pi2XLom@*h=F_c-q^>0=|6 z{+B;jk!s1)5x1e9@O=v^?=Y2y-E?z#p49HKwUJn=N@`iQ6DI2%0=F?=619rkv+AIUhO3@R~}m2o`ZC^ z2|nr&ebMq4;+8*#Otb4DZbLlD@L!JeJ^ILH9X+td<73Uo)Bj>{8#07cw7BJa;i(Jo z&Fo3qx{o_vfLwnDKP@f9P!c=uVWgSW124V!KW;p#Kh#wcBc{#cgOSd91n;4^1K(QR zK6ZpFw$Ph#3`D!T;#rrwW4Dc-;r0cP085D!D=vlBbQ)S zGHUpFdxxhrtrZ->ZtLjtkod`c9R8Ptdzqv*K7zaQ#@W z-wT_<|AI!?Y$%DDdcVmMu)%$+lHST`Hk7Rs9anH2d&QJaE`G0)0mmDKV#aRl@>$O% z>{I$L$7tPnt8HJ~R%Oo`UQAAh#iYVj#f0S~tyT7va}{xC_p%n6xv6`D;zWn1+|7}RWig_vn@`wxaHW#*H$E%}mfw4hb8Y(+$Mvwn^xmsz9I>CY3=(>8W=*Zv zJ#VD(TNTr0?(AMMt%nyK|BFLu{Tqm8Lt7N(^iT$Q(SM{*d$CQxPZbiQ^*+6AH`wlgAyB z=y(h^AA4bk`d_pPcA9Ch;$zm^_%8lvF(|)n!PsvwEdDkq&h(b(2)jwXoNwCZjZxpm z!>@}|EO#thF+Jwj?A2@+Q#PXJfaqh;#1|o5)E0A!F@-CMLy8p7m2LLg3k?A05j|W^ zixN}PxI(k2G2zL!Y%zs{UI;XNgAc5&n)E&@@>n-&Avv6PEDrdNJDNLoeL23p zg}I_sN94t_0eg8(cA)a!^nn>WOH&%)A;)Y`s;-R0e&P{|sontpI-4d#Zz7S1te`~2 z1RV+ZgL(POO@kP}Ox^R=L@UM^*ioJ~6lBe+WE|w&GjIHGHhRMH?BxlYYd3ps$=s5# zqIQMX%FLC6!2$f=_|bI3ai{MM)11CEOyk4#$L*XA)9eW7+L!v{u$hjs88j;lZU z-|c;QIMm(y@R=DigKR}1DxwJ$GD2xF6@y2Vt+a_TM6#-QW zKiREQLLJ|>HO8F)84D6SlU3g?w0%!mli|!zRaK;srVdhW6pFV_qpcvn>@M7yNbzoe z*TTzIL%xk*HQ zFq2g&8tEzsK1LpRG7yl&8>Wsr-oOYWIk)gr(#hVu!XyUl>Sf9aDEyQ{98O#VBbO=X zK`|TPjbNbjLW#NifY}@9BbccxArDio26m#YB{StyVPACG(o-->VYk+hWY*$tEXUe- z%;Y;*%E;ZMFjFt!67*(r8xQYFE+i?}!l;&mv7a%m3#<(Rywer#OryZ|15NDWf1JqdH725{KG(wWF5 zd2e8*bWFv>a+M{7vMwN59)9# z-ff)|sACvOVx}}x>i00^`hbSL6vu47OrnrC%PISyW_MXX_mU&|XpZXk5uEv&;E$KkUF_?!1@ZK8i9h-;ROq)b4CasJW-~)0%P(@;{ zWs_PHS`%OHgYOVnb$gk#Di-XXv&!08;p4lWu_19oS)dMM3Q6gDlE3S9_`geXfZ3S1 zS{`QJ@KEP|*(CR#{3Pdu#1A>G$C?b|9luw+g?A`q_)hnM@O@6#!TZ9s?yfQS;Vnoa z{KxLv*@f4y;Y~nSXJ@?h6TDv1HLF7WZlvpM$3tPvnDU`>qfPNUna(cHyU~4kodTCW z+b&MOGj1@wOq7oEo^69w0J?B}x$E_`|y(uMN`w}|R}ePghWF*gR= z2!1DO<8`UBloCaP1yM5Cic_Pgg_2@rDPx+Xjn_qkZ5Y*y+BkUa=Q>IX2U}5mBjU{F ztC&)ezvQpzK4(}rhSjSs#X5YV=b0a8dQJkKPY!j2H(W8WFCst5F@g4dY(JyNxm8Nd zFv>#&h zogL)G&Zod}ICBct!n#UM4|1G1HOLL8$0CeMP7c;Vl_KMo$j=S*hl0?$VRpK+Xv(a z=mW2(gw}Oo`x#4g$G3&CYbYIW;!eN{2xx%iI0a?eFmTrYX-K5Pjxvze*Vl)@xm z%Gmw#@&Y6NU6!`Y=^D}&22Yr$H9DYXb{CBX3vTr(5_o6n|Jbbx}kXj9LlPMQK#u7DPAJz#%sVSGv z^6i@)z?5-)mmi!IdoAE6aun5UH zJZAh;O3*$gp#SwlKGYZb;|L_>oPr_(Ps!Txkm_%d_kU#`+h)dI(d_kpg_&J`S*CGA@hh zx(p|;U^+GmspqG-lE-E-rEXH*W${m((B`LrY!%YEkyO8dzdehkV>pF$W8?Dl`c+It z0{&~Pqwr;xQ;Vwrhh7muUv8iNEayoc@vryFdEi zTOM_mjxN;W&!jIT+ozB9$5MZNMouJu+Y)kEM&bs(LJh?RxPL^OsrU@+tFy4%8U8~{ zHL}oSY|Sw?;JwgaG8n6w(u*j63bla_1x_0opP|K90GVHR0Cuf2DRpo*Kin??-_$6( zyQygwYyWFfwg-ln!D2Ke}x8VeFE?WAy!pFcr$-gX6qz$Hq4z6KR3=$Cq=0_a-W2NZBdZhuZ zU*a{xGq*+mSYgc&&kND2AqD=&rmz@&bM40tAjdeJGE&@FxXs4Mh79YNV680y3vMP@ za5^k(AHg~%SZn|7I;NFy9TV)jg;s^I!}UL{V_K7U30HD>BaC1^yc5rhcdViVu%3x{ zRr^@y4^RMOUez%co;PuTIgZ75uEd?C!r8kx9(F?JSc4zO!_LRt1Yx{-DEwDOIRiMp zbRPcC4JXe-{`dul58$|?@QX`Bs}TC<%9X}bSUsXpNIbBng*Q9E3L5@z%yY{G>U&^S z<0xIZAfEA>*aNhE9mi7UjJFXR06s16VkiQT6K89BZc75+c1b2^s9f0=;!iei} zgPl1Lw+Zg1+0j9&=ziGsq`j(zx#kP(Q-k$JfdKArf|Y_lVFg5e5z@%wBHpvDEDcu< zi}!R9i|1^?;(6|1X@H#a?F;A*>e8sCvv{>ztzf@9oSe+D67Hhz?1U4Od#!k$YvDT} zChQ&@3#TV5urxe&SiNkld*eM|f6K9Keaoi%&JT4^4}5!d3HA{kg?&VOkq&t;+zi)X zl}qNL9JUT60r^JcO81}(Y>iiotawW-taN00&<#J0nsHXVmq)E~Aq4l%jf9&Kvut2z zdk^}=*2oHi^_%CgHgm;_S2+$ac4uk4hP9Z5Ygsx{cz+TV_QPL-J@7q<>Bk06yTfkD zS#T4bjTJN4h{UTjwBl8tvC?p_vf@>hS@G%&S-F%1k`6TzcG`1EI&?QU$r)BFX>PE? z=r(+{)C8wM4~H+8ZsV_$NILRRzkH?W>m-Fsa4Iv!<- zluldIORBZz^jFe2oGNLJt%(Avb)iZVvfM5uZMn5unzM!u`!ZCKCcNQ*_jfFEV`rB_ zYmT3emK3s2#3wUOg!k(3j;`xWvqpjn+?Ya+sP~!eZw1s)lgY&gRuqM<^yNpA-GdAKV9n0cXQ3Va2|+tk1T22u@y9*`iR+4IZ@>G zUVxP-Kc=~)EyoX}wG}?slGD^WqTvtffST=zgU41xo2%U|+?pY+`DU;z$r_hdv$d|- zU*On6zQ31^G34;R(9-B#LpY>fxxF_2HWQ^WE@PU!+4);J4pfEwbR7j2iP6Zhr7LK_ z|G;VYW7wQ;rgU#MkJ?Y*bIbM6@CG$o^_#d&sW%u~lC=b1=I!G^0d5!SY=;`1sIoae z*9)LU8IE+X)b1ZG61DkT8)j1nB<;np{Y!)#q;)AajH+(?P?TUJX+@shko~SNK}zb4 zU5Tb`!!CMzkNfJPRCa=tj>1R8XpCXw_pzKFQ%YQ!6x>81!IP8p4(#@W4SY%Y~Vx)oi@ z@kzBwEiv3D_g1M%N0y2ppO*?3>Wzk7`_|T^R2z7~Zy2%2Gkt1v(O3#2Af8{%LdC|53o5_qHvLx35K)P2nYXaz=U6cM{+S*U%V z3KyUFlGvE@29HYTF86j$;0qM#_VakD55T;SgBIl3_?;mqwV9TXT+~cq%b)~yng`6c zPIEf>Ms#v5n={Z%Yh`oZH_K|9Q|&EB!W~0!+ifA;K!)?JT3S=uJu!`{j+hAxMf2@K zj-PwtM1*IXEy8oHSd$Ad1Fz~-d)!#P8}L}qEUTWX1>-KA<4oWv*tZ@GcTsP_wQ zH-X*IW8NR-w!I{QO&ZVH+E1q1Q>`;g)ol56JCf!b9=(^)ygR%^gE|uOTFO$T8}{@I zaIJ8r9{iR=IS#zj(K#fACKp)Jjahaj-xp?=k_x7Vs(ER0$M!M09ouOG_Fc{`I>t0) z5Pu>5%y!P^URs|qFHPsc$GnmU|e zqqf2%LI047-HWf0?vZX;J2`jhQt2yTWfXB53ZyuWRAi8$bFLL;zS@fO2fmww-jvq7 ztMUeBQON9woCtDMz5nb0ZQPF-S3Dmw%evi4c9l}CsW#Ry9^aNkxktGzD~NWFcC#)} z;c9y6?feUt)>Tm-O+;?nA@0aFmsFo5*U`ODBxsVGTyK?*- z#UnzQDg~?kAb)peuh5{Y!<$#S^wkV1TSvALY5s#UOqYMK=y5TvaHBTRT1dw|UM(f+@j+S*WB--bjxCuhsQQ|= zaKDeVh*CJCnZzlhOYt&P5y@Kf?yuC16GXbju$u~m^zXX^v_2@I(i3cSWS=3mH?0M! zY${zRsRw1k88k6$Omj5Q9>ZP+dmUrg)+JaU+4o6#Q9vJIIpEmM`J_vi%yv$EGYxB^ zWkOAqU?1Pk!*VNzEz%q!&4+8p5|33gqhv4J9&{Eb zMyRc1IMiO*LET~W6(ldUD`!n`ntX0?l^4sKJ%q{e9u~0ySH=ry^YxCDcV;1y~Q+6(F?h z<9A|Pml%HbOX7ZVw}{i%{gf{R{0JWHa4sjrWQy6U$)3JkPycYw_1v&vJ%%55(#&=G zkRBWv5fT{@$PLoco}uOF=HP$}!6S+vj0}nNUd4?X40FT6g2S}DBDBVZPaEemQA-r& z1p9Ec#`!>bj*(Yn#7aXrt5Wn>N37(AaU)l2x%m49aecJral^yC{J7I(nI^&vuFg)5 z|6Q7Y5TG~MEHW5Gc;*Z(Jw4w*FTZdthL(%lw*_52=39HtW4ZjHuQg-Fzf>zs{i$9Q?(g>#b_faa zH8cGYH^_c2XLV#?geNCBD1y5#0(XUXq<>(99)lCHZqiIiYJ{*?5LzAXx6&(oCDY_b zO(H)2OMQOap#Mdk!W@!%0pB36h{!PRzt;PY{te@D{C%{5#l~6;hL$KaeTEiOgoT*S zpRj{^f1+B5|4*q1St$|(hBmP)3D-gR$uDtbzl0@ue|E3_l;;fiIYu0>Fdwg=V2SG) z7|dDa=^w-~gp;lDcntFo2?^xlkgOU(d>TOz^#Iua1l&@93h?Ixv;e$QC5VR*R}6st zXTZ${xCs6c0CnKU_09_pf(a!a&3J4JnTt@0Awjqr6NC!DGb4iV0mufJ20#V~fOHHM z0MjQEgcQJi$eRuz!k4qk+fz7)g!q$irO!e~S_;7tEx}Tqs_kdfy z+BAZGTUIYgK>YK$*;pcbVzX%)>zDkK z&(&D}sg>5UxuPXhB`JT#@b~e18g~V@JXrPYxb~Ao<1N2d_`ex;@Q>hfg8hU3MJ9;r znH%i)AJb?1NBpPa2)H{5*yIIMauS|s67<4~bTl^ZBfnnf8>7`W)^ONjpJCg`nvA1w zjF*-=oaVT&SZ05xEPpbqW6Rj0Kf?{-pK@v37k10WhIqodAuS%;f!D5sv_36lP}`NOz2A&gnbU0 z9;LlWS^dGAB_GX3c~0wbIx3;_SH4{9B8YFqEF__;>IFgc5C_r7R)Xj!Jkh$h1kp=u zMy5{);xkbKccdLz7#_56*(kAlNK0d-jZy3Cwy3%9XrTz%b!E4jn#!RL(%w+LKp8lb zAZwA)0y^5roeBTf+x|WI`Gx0NJiI+`pNlABTS?WRldhfU`q1r3F5~nP5J&>dAu;JWFb(31% zkWE(||2nByv3f+V;`zpJ(=uMqb(lK-l)Y@i0o|cj+p}X__UH`T?Kf0$llSBKk#{q= zccvqsp0Gt#r-4+OXja6TvA+a6n=P;l_Y%2vtYiu&H`&mc;4J8 zLa(#keCv60Cz~0I6I^Urld%_EVGOt?CZ@tLs86BGv=D`-Oa|5LGl?M90mK6A12_bb z0uTgnA7B{BFIh2=!No8V;32>PfIus_1%P1yY5=1FuuPN&um!LJm@Wnj za52mU2mx3LfMqF$6=DbkHyt1o0L$1MfINUCfL#EXP?CRM7nN^;HsN3&vY$YZwl!o;Xig z-onK-T$WoX8?eT({6`tB}`H14mlK{ zG~)UZb?HF!(GF{E$6va3&Xg~wIn_S@xua7>_L9%RhATT_cSZzrj;>Q#cQ;;|d?)2A zQM~JY;QS-Kx&6hT)`gZG-`VeT#OZ7C0Z_^q6k1&6q*nd2LdE7A0 z$pZ8FQe{dq<~29_)LP8%Nb&>^%=5U$V?mhj&%UvWFz+w942{G5Kl$3X1NVWF$;C;y zA7*KP%fx-rsL)xA`{TyWycxJp#vPYU!~GH*OL>F)CL~Bf7WdD9(GwfoM~rgDQru4; z3~nvPef65ZcR%j0I?mZ-+-G?g)=tO$_U_CFHtsviDX}WJ|9mJP9dI9#jAviN{kS`$ z?{D0f#>K8Q+@EUBomIF`l}>QoaK9R8g)YQ>d*yMV3GUy!e?Pf```G0qYXR=(toJ)! zxC? zh4!gFBk_398_m(dW1_VvwhoVr;ZbE5@YtB(64rpnN46KE?cvPv74#dW+IOxbJ|6x4 z&4a4wmu>4F2X$E5y0aI(VWT5hYh@3eFE!b^EtYImjobW1wmSl)FzUwArlXfE0kvGtlZU+>zQ=e4a ztxRmlUr%_)%tcX^X~>;BnmFvoLn$6}h|Z>B(wrfeP+8Y@L5KAkq ze#DsXKoq-0BKO98l>Pi~(#=Olh<&pih_i2%h}8MsB!`+$s4OQ88NP)3=_h;?@D8mc zn1x3XdFWX*+w~BsciL#A!Zdqf6&!5?}S*P-d(FX~n=7l#ww@(3vn3X<1~WNym$b9SM9= zocmK$2-lW;%~v3v@`(b19_uZzKDV5RkZK^k__Uj-I6a2sPa01cp0!4e zM$?GuH`anj7S@E4Aem5o^o*!b*+h6&t{`O2X`{Q;N5n^iEdm|wQlhWv7Aa=vI`qvi zgP@y+5GfY33BCFr;)TsP!Ko@4!gfv~X-{btF_961=3e=Oc)0i*+WS5gUCN(^BH(M@ zrTfXq_p~=^-M)gfdGb9(<)tIlbQ$95dO7%3eJEO zW7M&>hG;%ri5?CU2&AvliA^$eQm}z73N~#QteBOG(n>Z99ypc~3IQKTg*6W7?B_y( zzsG6hrtU$?`J4`COFR~6ACN)GxrW3t8$A?gutN|$u!G>e-i4S=#|gUu9tUp zlF05DNATMo5x3!MhCh#_qPMqJB3T`E!Q*G22(RyTf-Br6R5xrh>BzRN#OOyUf<0cJ zQB3JbLAk3o5iILSq%F8WNJ*)o!yVNGEma#$sa7ELFa1q4C+|o6d$k1dS12l~KShMj zt|BUCP9l7*qzT6}N`i#Qx&QwE=KtK({ax}m2Yz$lHwS)m;5P?;bKo}zeskbA2Yz$l zHwS)m;5P?;bKw6I4oCwYR$B&?RYgrAZQ^BkGQ&xo8h5$+$o4-Ie&r?|%hq=-e>PI)S+EpUn;4nKvk^ zv`peCZrEwj*lBgazcTM$!Jl5QisF-7^cF0Y9e4bqTbdSqk0E{ZQuBA^D}O8J)wqsKT%D?#yTW7KRjZ2k0mb*n56BPoys6T>&>>@A z#+#x)-3}(Ho|bX>a;K|4{@E$>Y^O2<#(k>d4o~wV+n%3p^|D+ODr4dE{l)Qf+S{4; z?@1q8wq3QoaQC|FO+zy6+)pho3t6Sx4R^4dSk%{SzgDBnrMx_1lyP2F|LgkmlgzpN zf@=5LIb`+atKOR&ihq%}*h+op>koAg7Abg@eTzJ|ATi=5^~vKq$$F75ZiH|B6!U>n z<9zRu{+JVEoL|yLPGg^azhs&nKL6wnoA=QNjC+L`ub&XJ7cha4xY=an|O-VM3L z$zaTv`$8J2oT$0jQhE+QZCSc`DX%4=(xZ4>r>tpAs8U}j`PrYNjF}A+nP1bK+=taV z*q2P0l9CnkD0xPaUpD1@sG+5X{g|0A_m5M{t~Bkjb)jDzZ8GbAg=6i@>{t3kPv`3^ z293JAw|Y=X#$=7h7OUnddnQ zZL&JaIDYoC@r}4ysJd@c-l@aSS~3Hp?H}$xR?|E-y5&;tyK^z0zPG+;AG+({`FquR zkCF^ymeKAEn|CYLM?qm0ZR(-hUavGv;Vfe%1l138}N~_U{-R7 zebJ5CkH)@vS$gY$!`uxTQ*vj9t#eOlF&r@_^YrzpdzhYItzZi8f|3%;X2xiqcoiz#?;|Ct<=_)84uuQsE z6BvAb=H8ewPp9SToylP=Vefp;9R51AaKxnHncu$7T4rm+?vmcuQB03nNv}}tt*h(V zQn6;jiE@KbyVJ6Tk^MQ-=kG~tCWiP;{&Ey=xDqvD-(6Q`K#vnvCMtR=){nMvu3sK`F}pRH{!YS z&Lz8cPY65N_V~_<%?|$lIZqa^eb79rI@R~8UDLH1il*C!{?^km8=LKPt7AXK+;)7L zsIS=@qQaRoJa49n<-l!i<-U$}Dvzd}`*Ppy2!G6cmdmr8Gc!0d?r;4tW5?F#pEMUd ztZ7|3q0X14G_>GAtZAVdr)=@9E8Mx}Ba>TIdkf#bx%u?-8tO;)>7~j;Y|b;1xi)o2 zv$^`J!-j1vI(T%nAZPb8qISdI%}cE&$bD&8b-d#ST-yA?t5T}&aJ^NwVr|Cs2h!}* z7Z%J5C}f@dptLadk2y<^q$<8`37qQC&NV+f!g$%nnO3ptmNpws8@3MB*jVOxKk#JM z0;?8&?N#@=egzxrHGE~{A8nyUCSFXLGwTLx?~F9Xv=`kKw-)8kV)QVozD=I|s!ip@ z)`=gTWX#}@yPNyJZObJso0JjA_gR{ecd`8Z+VZC_wnh!e=pS5hYTc%SrBy{{PCFwK z`A5`l>k^CnJUb{;En6n2ovD9x?q%M1J2iu`sdj9gB2$muR#hzL!;b2E6kZ&CwC^A0R_TxJC z-v8jJ!u!t}gO+_{Y4v`2%CEF=)qQzB=O#+vd(pGgRogS4|HBygZvv8CnUbWgnPLM+ z9KoJ5XPTCtzSd+DLlZ+|#Db%sQJ8-?XAL4v2d|V8HEfNUslQ2xvFB?1}vf|yVzX(5vvM5Y^zF&X-Ue7U5Py@v3DhQrzDK+ z*DqjOO3F*@Ux}+LZb_0~QeGU5Mu7*@ASu5>?2)*(;*1g;CFLb}je~S-lS|4=>}`oF zv9HC|2ze#(V|Nk&#!XVbRP2$s68rjB{3Y$1Ls`sIiG8mm&M0wbj*!$0nc(?n2%#Ae z_jCWvA1TTqajjH@#eV!K{qTuA*5KgsmwImtEuB1AN>kIMN zFTsD2xV*&G6XP$bMp8}^UIiYU1!TfM9!Yt{f5eLN;wVYA5#Wcme`PcB6XU`DH{~VK YxE=}ak~r+15tmQ=ITHMmb|umO1vmEO0{{R3 literal 0 HcmV?d00001 diff --git a/ckb/devnet/specs/fiber/commitment_lock b/ckb/devnet/specs/fiber/commitment_lock new file mode 100755 index 0000000000000000000000000000000000000000..d7cffeaaab0b93bbfdde7a5a83392879ec16f62a GIT binary patch literal 111880 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 BFi-#h literal 0 HcmV?d00001 diff --git a/ckb/devnet/specs/fiber/funding_lock b/ckb/devnet/specs/fiber/funding_lock new file mode 100755 index 0000000000000000000000000000000000000000..63d3373d8c892e3058b310594ba4c2dce13c6aeb GIT binary patch literal 74800 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 literal 0 HcmV?d00001 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..914af057 100644 --- a/src/cmd/clean.ts +++ b/src/cmd/clean.ts @@ -1,42 +1,91 @@ import fs from 'fs'; +import path from 'path'; import { isFolderExists } from '../util/fs'; import { readSettings } from '../cfg/setting'; import { logger } from '../util/logger'; +import { isProcessAlive, readPidFile } from '../util/daemon'; +import { acquireEnvLock } from '../fiber/env-lock'; +import { assertFiberFullyStopped } from '../fiber/clean'; +import { fiberNodePaths, fiberRootPath } from '../fiber/paths'; +import { removeRuntimeFileIfStale } from '../fiber/runtime'; export interface CleanOptions { data?: boolean; } +function assertCkbDaemonStopped() { + const pidFile = path.join(readSettings().devnet.dataPath, 'logs', 'daemon.pid'); + 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[] { + const nodesDir = path.join(fiberRootPath(settings), 'nodes'); + if (!isFolderExists(nodesDir)) return []; + return fs + .readdirSync(nodesDir) + .filter((entry) => /^\d+$/.test(entry)) + .map((entry) => fiberNodePaths(Number(entry), 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..6375715c --- /dev/null +++ b/src/cmd/fiber.ts @@ -0,0 +1,199 @@ +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 { 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 #${node.id + 2}, 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..affe8c22 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,27 @@ 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, + 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,13 +45,10 @@ export interface NodeProp { binaryPath?: string; daemon?: boolean; verbose?: boolean; -} - -interface PidMetadata { - pid: number; - scriptPath: string; - startedAt: string; - status?: 'starting' | 'running'; + fiber?: boolean; + fnnVersion?: string; + fiberNodes?: number; + fnnBinaryPath?: string; } const DAEMON_LOG_DIR = 'logs'; @@ -52,7 +70,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 +88,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 +109,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 +221,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 +302,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 @@ -384,220 +536,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 +566,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 +683,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 +709,20 @@ 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) { + const timeoutMs = 5 * 60_000; // the child may still be downloading FNN + 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 +735,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..067f51cd --- /dev/null +++ b/src/fiber/clean.ts @@ -0,0 +1,119 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { confirm } from '@inquirer/prompts'; +import { acquireEnvLock } from './env-lock'; +import { fiberDaemonPaths, 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[] { + const nodesDir = path.join(fiberRootPath(settings), 'nodes'); + if (!isFolderExists(nodesDir)) return []; + return fs + .readdirSync(nodesDir) + .filter((entry) => /^\d+$/.test(entry)) + .map((entry) => fiberNodePaths(Number(entry), 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 nodesDir = path.join(root, 'nodes'); + const stores = isFolderExists(nodesDir) + ? fs + .readdirSync(nodesDir) + .filter((entry) => /^\d+$/.test(entry)) + .map((entry) => fiberNodePaths(Number(entry), 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..ced6c61b --- /dev/null +++ b/src/fiber/daemon.ts @@ -0,0 +1,311 @@ +import { spawn } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + cleanupPidFile, + closeFileDescriptors, + isProcessAlive, + 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; + 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); + + const lockFiles = storeLockFilesForRuntime(settings); + 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 nodeDaemonPaths = resolveNodeDaemonPaths(settings); + const nodeDaemon = readPidFile(nodeDaemonPaths.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.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 }); +} + +function resolveNodeDaemonPaths(settings: Settings) { + const logDir = path.join(settings.devnet.dataPath, 'logs'); + return { logDir, pidFile: path.join(logDir, 'daemon.pid') }; +} 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..a40fc188 --- /dev/null +++ b/src/fiber/install.ts @@ -0,0 +1,197 @@ +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)); + + 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'); + } + 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..ebf498f2 --- /dev/null +++ b/src/fiber/manager.ts @@ -0,0 +1,397 @@ +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 { 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; + const lockMatches = + actualLock && + actualLock.code_hash.toLowerCase() === expectedLock.codeHash.toLowerCase() && + actualLock.hash_type.toLowerCase() === expectedLock.hashType.toLowerCase() && + actualLock.args.toLowerCase() === expectedLock.args.toLowerCase(); + if (!lockMatches) { + 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); + + const handles = nodes.map((node) => spawnFnn(node, options.fnnPath, settings)); + 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.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) { + try { + node.process.kill('SIGKILL'); + } catch { + // already gone + } + } + } + removeRuntimeFileIfManager(settings); +} + +function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise { + if (child.exitCode != 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..535c2469 --- /dev/null +++ b/src/fiber/nodes-yml.ts @@ -0,0 +1,147 @@ +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', + '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 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..3102126f --- /dev/null +++ b/src/fiber/paths.ts @@ -0,0 +1,125 @@ +import * as path from 'path'; +import { packageRootPath, readSettings, Settings } from '../cfg/setting'; + +// 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)); +} + +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..c7a7502a --- /dev/null +++ b/src/fiber/scripts.ts @@ -0,0 +1,149 @@ +import { resolveDevnetSystemScripts } from '../scripts/private'; +import { SystemScript } 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: Record, name: string): SystemScript { + const script = scripts[name]; + if (script == null) { + throw new Error(`missing:${name}`); + } + 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 as unknown as Record; + + const missing = ['auth', 'funding_lock', 'commitment_lock'].filter((name) => scripts[name] == null); + if (missing.length > 0) { + throw new FiberContractsMissingError(missing); + } + + const auth = requireScript(scripts, 'auth'); + const fundingLock = requireScript(scripts, 'funding_lock'); + const commitmentLock = requireScript(scripts, 'commitment_lock'); + const sudt = requireScript(scripts, 'sudt'); + const xudt = requireScript(scripts, '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..20f8c7d7 --- /dev/null +++ b/src/fiber/status.ts @@ -0,0 +1,235 @@ +import { checkNodeReadiness } from '../devnet/readiness'; +import { getProcessCommandLine, isProcessAlive, 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'; +import * as path from 'path'; + +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(path.join(settings.devnet.dataPath, 'logs', 'daemon.pid')); + if (nodePid != null && nodePid.pid === runtime.managerPid) return 'yes'; + } + return 'yes'; +} + +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..95f90451 --- /dev/null +++ b/src/fiber/store-lock.ts @@ -0,0 +1,62 @@ +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 }; + if (err.code === 'ENOENT' || err.code === 'ETIMEDOUT') return null; + // Exit 1: no holder — matches are printed on stdout; stderr may carry + // unrelated warnings (e.g. an un-stat-able fuse mount), so only stdout + // decides. An inspection error leaves stdout empty but non-string. + 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..e37b575e --- /dev/null +++ b/src/util/daemon.ts @@ -0,0 +1,239 @@ +import { execFile, spawn } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { logger } from './logger'; + +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) => { + // 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()); + } + }); + }); +} + +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' }), ); From 94fa28350c47ff10fbd25e3e3b4a2216ea1d5be2 Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Fri, 31 Jul 2026 05:55:05 +0000 Subject: [PATCH 2/2] fix(fiber): address PR #483 review feedback - Sanitize builder home paths (/Users/quake, /home/quake) embedded in the committed funding_lock/commitment_lock binaries with equal-length replacements; make fiber reproduces the sanitized copies and fails with a clear message when the ckb/fiber submodule is missing - Keep already-spawned FNNs from being orphaned when a later spawn fails - Treat signal-terminated children as exited (exitCode is null there) - Capture store lock files before signaling the manager; it removes runtime.json during its own shutdown - isStoreLockHeld: only lsof exit 1 means 'no holder'; any other exit status or a timeout kill now reports 'unknown' instead of 'free' - Align the node --fiber --daemon readiness wait with the fiber daemon's 10-minute budget (first run may download FNN) - Mark ckb.udt_whitelist as a managed nodes.yml field so per-node overrides cannot silently break UDT payments - Replace deprecated wmic with PowerShell Get-CimInstance for Windows process command-line lookup - Give requireScript a full contextual error instead of raw missing: - Warn that shrinking nodes.yml discards the removed nodes' config overrides - Share helpers: fiberAccountIndex in the start summary, lockMatches between status and manager, nodeDaemonPaths across cmd/fiber modules, fiberNodeIds for node enumeration, SystemScriptName for script lookup - Delete the downloaded FNN tarball after install (also on failure) Co-Authored-By: Claude Fable 5 --- Makefile | 10 +++++ ckb/devnet/specs/fiber/commitment_lock | Bin 111880 -> 111880 bytes ckb/devnet/specs/fiber/funding_lock | Bin 74800 -> 74800 bytes src/cmd/clean.ts | 15 +++---- src/cmd/fiber.ts | 6 ++- src/cmd/node.ts | 14 +++---- src/fiber/clean.ts | 22 +++------- src/fiber/daemon.ts | 16 +++---- src/fiber/install.ts | 48 +++++++++++---------- src/fiber/manager.ts | 29 ++++++++----- src/fiber/nodes-yml.ts | 4 +- src/fiber/paths.ts | 13 ++++++ src/fiber/scripts.ts | 23 ++++++----- src/fiber/status.ts | 10 +++-- src/fiber/store-lock.ts | 16 +++++-- src/util/daemon.ts | 55 ++++++++++++++++++------- 16 files changed, 170 insertions(+), 111 deletions(-) diff --git a/Makefile b/Makefile index a7a041cd..8f70436e 100644 --- a/Makefile +++ b/Makefile @@ -53,13 +53,23 @@ 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" diff --git a/ckb/devnet/specs/fiber/commitment_lock b/ckb/devnet/specs/fiber/commitment_lock index d7cffeaaab0b93bbfdde7a5a83392879ec16f62a..698ef3bcff7031b203fea9d3fddf06e78ebbf3a3 100755 GIT binary patch delta 559 zcmeBp#n$nPZ38^+7YUpE+ktnpIL|#ss1VTP$p(Da;XJF&Zv2u=aP>Z$Hwboc!DY=R z7t7QjNjk{ZBGkrBR!~6L1hQKPuF+~Un<6j5POHgo{QR2}mD-SmHmj+aBe-UhZ|R6_ zUZ?tz4Q>WV=qrM^nM-dm!g5fEaZYA8+y-|Y&^bm3=YTyvdAX4S!hp&8ZUUQyjM^+7YUpE+ktnpIL|#ss1VTP$p(Da;XJF&Zv2u=aP>Z$Hwboc!DY=R z7t7QjNjk{ZBGkrBR!~6L1hQKPuF+~Un<6j5POHgo{QR2}mD-SmHmj+aBe-UhZ|R6_ zUZ?tz4Q>WV=qrM^nM-dm!g5fEaZYA8+y-|Y&^bm3=YTyvdAX4S!hp&8ZUUQyjM*Mis8ke6qei|K>nhRhR;x!zZuhke>WO4%rEw@}6)lW}DB-pW=jaC$dIw z{;v4|Y7;O#VkTSY8p72=jNhVb4>Mj-zzijT%VldFu2AiT{VjqN`G05dgw A!~g&Q delta 354 zcmdmRf@K2`^{}!QmL_JWPM*lx3TH)cE@QjF2;~+^M{QoqQ2^sA3Yblf;4(v!e9LtT zCOMHcX0imY8r-1N$t!r}pgfR9M!uy8-sXpV)=-U`6$NbB;W9px*Nfjr@HXF<=w*U% zHy29tBir>*Mis8ke6qei|K>nhRhR;x!zZuhke>WO4%rEw@}6)lW}DB-pW=jaC$dIw z{;v4|Y7;O#VkTSY8p72=jNhVb4>Mj-zzijT%VldFu2AiT{VjqN`G05wN` AEC2ui diff --git a/src/cmd/clean.ts b/src/cmd/clean.ts index 914af057..9a2c8186 100644 --- a/src/cmd/clean.ts +++ b/src/cmd/clean.ts @@ -1,12 +1,11 @@ import fs from 'fs'; -import path from 'path'; import { isFolderExists } from '../util/fs'; import { readSettings } from '../cfg/setting'; import { logger } from '../util/logger'; -import { isProcessAlive, readPidFile } from '../util/daemon'; +import { isProcessAlive, nodeDaemonPaths, readPidFile } from '../util/daemon'; import { acquireEnvLock } from '../fiber/env-lock'; import { assertFiberFullyStopped } from '../fiber/clean'; -import { fiberNodePaths, fiberRootPath } from '../fiber/paths'; +import { fiberNodeIds, fiberNodePaths } from '../fiber/paths'; import { removeRuntimeFileIfStale } from '../fiber/runtime'; export interface CleanOptions { @@ -14,7 +13,7 @@ export interface CleanOptions { } function assertCkbDaemonStopped() { - const pidFile = path.join(readSettings().devnet.dataPath, 'logs', 'daemon.pid'); + const pidFile = nodeDaemonPaths(readSettings()).pidFile; const metadata = readPidFile(pidFile); if (metadata && Number.isInteger(metadata.pid) && metadata.pid > 0 && isProcessAlive(metadata.pid)) { throw new Error( @@ -24,12 +23,8 @@ function assertCkbDaemonStopped() { } function fiberStoreDirs(settings: ReturnType): string[] { - const nodesDir = path.join(fiberRootPath(settings), 'nodes'); - if (!isFolderExists(nodesDir)) return []; - return fs - .readdirSync(nodesDir) - .filter((entry) => /^\d+$/.test(entry)) - .map((entry) => fiberNodePaths(Number(entry), settings).fiberStoreDir) + return fiberNodeIds(settings) + .map((id) => fiberNodePaths(id, settings).fiberStoreDir) .filter((storeDir) => isFolderExists(storeDir)); } diff --git a/src/cmd/fiber.ts b/src/cmd/fiber.ts index 6375715c..4daf445d 100644 --- a/src/cmd/fiber.ts +++ b/src/cmd/fiber.ts @@ -8,7 +8,7 @@ import { startFiberEnvironment, stopFiberNodes, FiberEnvironment } from '../fibe import { startFiberDaemon, stopFiber } from '../fiber/daemon'; import { fiberStatus } from '../fiber/status'; import { fiberClean, FiberCleanOptions } from '../fiber/clean'; -import { fiberNodePaths, FIBER_DAEMON_PID_FILE, fiberDaemonPaths } from '../fiber/paths'; +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'; @@ -38,7 +38,9 @@ export function printFiberSummary(env: FiberEnvironment) { 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 #${node.id + 2}, log: ${node.logFile}`); + logger.info( + ` node ${node.id}: FNN ${version}, RPC ${node.rpcUrl}, account #${fiberAccountIndex(node.id)}, log: ${node.logFile}`, + ); } } diff --git a/src/cmd/node.ts b/src/cmd/node.ts index affe8c22..335756af 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -21,6 +21,7 @@ import { cleanupPidFile, closeFileDescriptors, isProcessAlive, + nodeDaemonPaths, PidMetadata, readPidFile, reservePidFile, @@ -51,9 +52,6 @@ export interface NodeProp { fnnBinaryPath?: string; } -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; @@ -433,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 @@ -710,7 +704,9 @@ async function startDaemon(waitForFiber = false) { } async function waitForFiberRuntimeRunning(managerPid: number, settings: Settings, logFile: string) { - const timeoutMs = 5 * 60_000; // the child may still be downloading FNN + // 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)) { diff --git a/src/fiber/clean.ts b/src/fiber/clean.ts index 067f51cd..069574be 100644 --- a/src/fiber/clean.ts +++ b/src/fiber/clean.ts @@ -1,8 +1,7 @@ import * as fs from 'fs'; -import * as path from 'path'; import { confirm } from '@inquirer/prompts'; import { acquireEnvLock } from './env-lock'; -import { fiberDaemonPaths, fiberNodePaths, fiberRootPath } from './paths'; +import { fiberDaemonPaths, fiberNodeIds, fiberNodePaths, fiberRootPath } from './paths'; import { readLiveRuntime, removeRuntimeFileIfStale } from './runtime'; import { readPidFile, isProcessAlive } from '../util/daemon'; import { isStoreLockHeld } from './store-lock'; @@ -16,12 +15,8 @@ export interface FiberCleanOptions { } function existingStoreLockFiles(settings: Settings): string[] { - const nodesDir = path.join(fiberRootPath(settings), 'nodes'); - if (!isFolderExists(nodesDir)) return []; - return fs - .readdirSync(nodesDir) - .filter((entry) => /^\d+$/.test(entry)) - .map((entry) => fiberNodePaths(Number(entry), settings).storeLockFile) + return fiberNodeIds(settings) + .map((id) => fiberNodePaths(id, settings).storeLockFile) .filter((lockFile) => fs.existsSync(lockFile)); } @@ -75,14 +70,9 @@ export async function fiberClean(options: FiberCleanOptions, settings: Settings assertFiberFullyStopped(settings); if (options.data) { - const nodesDir = path.join(root, 'nodes'); - const stores = isFolderExists(nodesDir) - ? fs - .readdirSync(nodesDir) - .filter((entry) => /^\d+$/.test(entry)) - .map((entry) => fiberNodePaths(Number(entry), settings).fiberStoreDir) - .filter((storeDir) => isFolderExists(storeDir)) - : []; + 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.', diff --git a/src/fiber/daemon.ts b/src/fiber/daemon.ts index ced6c61b..8deccd52 100644 --- a/src/fiber/daemon.ts +++ b/src/fiber/daemon.ts @@ -1,10 +1,10 @@ import { spawn } from 'child_process'; import * as fs from 'fs'; -import * as path from 'path'; import { cleanupPidFile, closeFileDescriptors, isProcessAlive, + nodeDaemonPaths, readPidFile, reservePidFile, resolveCliEntry, @@ -187,11 +187,13 @@ async function stopManagerAndCleanup(options: { 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); - const lockFiles = storeLockFilesForRuntime(settings); 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...`); @@ -271,8 +273,7 @@ export async function stopFiber(settings: Settings = readSettings()) { return; } - const nodeDaemonPaths = resolveNodeDaemonPaths(settings); - const nodeDaemon = readPidFile(nodeDaemonPaths.pidFile); + const nodeDaemon = readPidFile(nodeDaemonPaths(settings).pidFile); if ( nodeDaemon && Number.isInteger(nodeDaemon.pid) && @@ -289,7 +290,7 @@ export async function stopFiber(settings: Settings = readSettings()) { ); await stopManagerAndCleanup({ pid: nodeDaemon.pid, - pidFile: nodeDaemonPaths.pidFile, + pidFile: nodeDaemonPaths(settings).pidFile, label: 'node --fiber daemon', settings, }); @@ -304,8 +305,3 @@ export async function stopFiber(settings: Settings = readSettings()) { ); logger.result({ command: 'fiber.stop', stopped: false, reason: 'foreground-manager', pid: runtime.managerPid }); } - -function resolveNodeDaemonPaths(settings: Settings) { - const logDir = path.join(settings.devnet.dataPath, 'logs'); - return { logDir, pidFile: path.join(logDir, 'daemon.pid') }; -} diff --git a/src/fiber/install.ts b/src/fiber/install.ts index a40fc188..1c9d314f 100644 --- a/src/fiber/install.ts +++ b/src/fiber/install.ts @@ -91,27 +91,33 @@ export async function downloadFnnAndUnzip(version: string, settings: Settings = const arrayBuffer = await response.arrayBuffer(); fs.writeFileSync(tempFilePath, Buffer.from(arrayBuffer)); - 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'); + 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.`); } diff --git a/src/fiber/manager.ts b/src/fiber/manager.ts index ebf498f2..537d1942 100644 --- a/src/fiber/manager.ts +++ b/src/fiber/manager.ts @@ -16,6 +16,7 @@ import { 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'; @@ -210,12 +211,7 @@ async function assertNodeIdentitiesAndFunds( const account = fiberNodeAccount(node.id); const expectedLock = account.lockScript; const actualLock = info.default_funding_lock_script; - const lockMatches = - actualLock && - actualLock.code_hash.toLowerCase() === expectedLock.codeHash.toLowerCase() && - actualLock.hash_type.toLowerCase() === expectedLock.hashType.toLowerCase() && - actualLock.args.toLowerCase() === expectedLock.args.toLowerCase(); - if (!lockMatches) { + 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)}.`, @@ -310,7 +306,18 @@ export async function startFiberEnvironment(options: StartFiberEnvironmentOption } await assertFiberPortsFree(nodes); - const handles = nodes.map((node) => spawnFnn(node, options.fnnPath, settings)); + // 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(), @@ -347,7 +354,7 @@ export async function startFiberEnvironment(options: StartFiberEnvironmentOption */ export async function stopFiberNodes(nodes: FnnProcessHandle[], settings: Settings = readSettings()): Promise { for (const node of nodes) { - if (node.process.exitCode == null && !node.process.killed) { + if (node.process.exitCode == null && node.process.signalCode == null && !node.process.killed) { try { node.process.kill('SIGTERM'); } catch { @@ -362,7 +369,7 @@ export async function stopFiberNodes(nodes: FnnProcessHandle[], settings: Settin await waitForChildExit(node.process, remaining); } for (const node of nodes) { - if (node.process.exitCode == null) { + if (node.process.exitCode == null && node.process.signalCode == null) { try { node.process.kill('SIGKILL'); } catch { @@ -374,7 +381,9 @@ export async function stopFiberNodes(nodes: FnnProcessHandle[], settings: Settin } function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise { - if (child.exitCode != null) return Promise.resolve(); + // 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', () => { diff --git a/src/fiber/nodes-yml.ts b/src/fiber/nodes-yml.ts index 535c2469..9cc7f8ab 100644 --- a/src/fiber/nodes-yml.ts +++ b/src/fiber/nodes-yml.ts @@ -21,6 +21,7 @@ export const MANAGED_CONFIG_PATHS = [ 'fiber.bootnode_addrs', 'rpc.listening_addr', 'ckb.rpc_url', + 'ckb.udt_whitelist', 'services', ]; @@ -138,7 +139,8 @@ export function ensureNodesYml( const removed = existing.filter((entry) => entry.id > count); for (const entry of removed) { logger.warn( - `Node ${entry.id} is removed from nodes.yml. Its directory ${fiberNodeDir(entry.id, settings)} is kept; ` + + `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.', ); } diff --git a/src/fiber/paths.ts b/src/fiber/paths.ts index 3102126f..4a93da5d 100644 --- a/src/fiber/paths.ts +++ b/src/fiber/paths.ts @@ -1,5 +1,7 @@ +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): // @@ -81,6 +83,17 @@ export function fiberNodeDir(id: number, settings: Settings = readSettings()): s 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 { diff --git a/src/fiber/scripts.ts b/src/fiber/scripts.ts index c7a7502a..b09829b8 100644 --- a/src/fiber/scripts.ts +++ b/src/fiber/scripts.ts @@ -1,5 +1,5 @@ import { resolveDevnetSystemScripts } from '../scripts/private'; -import { SystemScript } from '../scripts/type'; +import { SystemScript, SystemScriptName, SystemScriptsRecord } from '../scripts/type'; import { udtIssuerLockHash } from './accounts'; export interface FnnCellDep { @@ -57,10 +57,12 @@ function codeCellDep(script: SystemScript): FnnCellDep { }; } -function requireScript(scripts: Record, name: string): SystemScript { +function requireScript(scripts: SystemScriptsRecord, name: SystemScriptName): SystemScript { const script = scripts[name]; if (script == null) { - throw new Error(`missing:${name}`); + throw new Error( + `The devnet chain spec does not include the system script "${name}". Run \`offckb clean\` to rebuild the devnet.`, + ); } return script; } @@ -88,18 +90,19 @@ export function resolveFiberChainScripts(): FiberChainScripts { 'Failed to read the devnet chain spec hashes (ckb list-hashes). Is the CKB binary installed and the devnet initialized?', ); } - const scripts = resolved.scripts as unknown as Record; + const scripts = resolved.scripts; - const missing = ['auth', 'funding_lock', 'commitment_lock'].filter((name) => scripts[name] == null); + 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, 'auth'); - const fundingLock = requireScript(scripts, 'funding_lock'); - const commitmentLock = requireScript(scripts, 'commitment_lock'); - const sudt = requireScript(scripts, 'sudt'); - const xudt = requireScript(scripts, 'xudt'); + 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[] = [ diff --git a/src/fiber/status.ts b/src/fiber/status.ts index 20f8c7d7..e9eba12d 100644 --- a/src/fiber/status.ts +++ b/src/fiber/status.ts @@ -1,5 +1,5 @@ import { checkNodeReadiness } from '../devnet/readiness'; -import { getProcessCommandLine, isProcessAlive, readPidFile } from '../util/daemon'; +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'; @@ -7,7 +7,6 @@ import { fiberNodeAccount, fiberPublicKeyFromSecret, readFiberNodeSecretKey } fr import { readRuntime, FiberRuntime } from './runtime'; import { fnnNodeInfo, FnnNodeInfo } from './rpc'; import { logger } from '../util/logger'; -import * as path from 'path'; export type FiberNodeStatus = 'starting' | 'running' | 'stopped' | 'unknown' | 'conflict'; export type OffckbManaged = 'yes' | 'no' | 'unknown'; @@ -57,13 +56,16 @@ async function resolveOffckbManaged(runtime: FiberRuntime | null, settings: Sett const fiberPid = readPidFile(fiberDaemonPaths(settings).pidFile); if (fiberPid != null && fiberPid.pid !== runtime.managerPid) return 'no'; if (fiberPid == null) { - const nodePid = readPidFile(path.join(settings.devnet.dataPath, 'logs', 'daemon.pid')); + const nodePid = readPidFile(nodeDaemonPaths(settings).pidFile); if (nodePid != null && nodePid.pid === runtime.managerPid) return 'yes'; } return 'yes'; } -function lockMatches( +// 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 { diff --git a/src/fiber/store-lock.ts b/src/fiber/store-lock.ts index 95f90451..ed89f696 100644 --- a/src/fiber/store-lock.ts +++ b/src/fiber/store-lock.ts @@ -35,11 +35,19 @@ export function isStoreLockHeld(lockFile: string): boolean | null { // 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 }; + 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; - // Exit 1: no holder — matches are printed on stdout; stderr may carry - // unrelated warnings (e.g. an un-stat-able fuse mount), so only stdout - // decides. An inspection error leaves stdout empty but non-string. + // 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; } diff --git a/src/util/daemon.ts b/src/util/daemon.ts index e37b575e..f79d1449 100644 --- a/src/util/daemon.ts +++ b/src/util/daemon.ts @@ -2,6 +2,21 @@ 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; @@ -157,24 +172,36 @@ export function waitForProcessExit(pid: number, timeoutMs: 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 (!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; } - if (process.platform === 'win32') { - const match = stdout.match(/CommandLine=(.+)/); - resolve(match ? match[1].trim() : null); - } else { - resolve(stdout.trim()); - } + resolve(stdout.trim()); }); }); }