diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88f8a2eef..dba47866f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,8 +36,16 @@ jobs: - uses: taiki-e/install-action@v2 with: tool: nextest + - name: Install dependencies + run: | + wget "https://bitcoin.org/bin/bitcoin-core-27.0/bitcoin-27.0-x86_64-linux-gnu.tar.gz" + tar -xvaf "bitcoin-27.0-x86_64-linux-gnu.tar.gz" + echo "$(pwd)/bitcoin-27.0/bin" >> $GITHUB_PATH + wget "https://github.com/lightningnetwork/lnd/releases/download/v0.18.0-beta/lnd-linux-amd64-v0.18.0-beta.tar.gz" + tar -xvaf "lnd-linux-amd64-v0.18.0-beta.tar.gz" + echo "$(pwd)/lnd-linux-amd64-v0.18.0-beta" >> $GITHUB_PATH - run: | - RUST_BACKTRACE=full RUST_LOG=trace,fnn=trace,fnn::cch::actor::tracker=off,fnn::fiber::gossip=off,tentacle=off,tokio_yamux=off,tentacle_secio=off cargo nextest run --no-fail-fast + RUST_BACKTRACE=full RUST_LOG=trace,fnn=trace,fnn::cch::actor::tracker=off,fnn::fiber::gossip=off,tentacle=off,tokio_yamux=off,tentacle_secio=off cargo nextest run --no-fail-fast --features lnd-tests fmt: name: Rustfmt diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 421540d1b..b539a4990 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -14,6 +14,7 @@ jobs: - udt - reestablish - cross-chain-hub + - cross-chain-hub-separate - router-pay - udt-router-pay - watchtower @@ -60,7 +61,7 @@ jobs: wget "https://github.com/nervosnetwork/ckb/releases/download/v${version}/ckb_v${version}_x86_64-unknown-linux-gnu-portable.tar.gz" tar -xvaf "ckb_v${version}_x86_64-unknown-linux-gnu-portable.tar.gz" sudo mv "ckb_v${version}_x86_64-unknown-linux-gnu-portable"/* /usr/local/bin/ - if [ ${{ matrix.workflow }} = "cross-chain-hub" ]; then + if [[ "${{ matrix.workflow }}" = cross-chain-hub* ]]; then wget "https://bitcoin.org/bin/bitcoin-core-27.0/bitcoin-27.0-x86_64-linux-gnu.tar.gz" tar -xvaf "bitcoin-27.0-x86_64-linux-gnu.tar.gz" echo "$(pwd)/bitcoin-27.0/bin" >> $GITHUB_PATH @@ -82,13 +83,15 @@ jobs: export ON_GITHUB_ACTION=y # Pass it to tests/funding-tx-builder as args export EXTRA_BRU_ARGS="${{ matrix.extra_bru_args }}" + + port_file=./tests/nodes/.ports + rm -f "$port_file" ./tests/nodes/start.sh "e2e/${{ matrix.workflow }}" & # Wait for the nodes to start, the initialization takes some time # check all the ports are open # when .ports file is not generated, we will retry 20 times to check if all ports are open - port_file=./tests/nodes/.ports retry_count=0 while [ $retry_count -lt 100 ]; do if [ -f $port_file ]; then @@ -105,6 +108,7 @@ jobs: ports+=("$line") done < ./tests/nodes/.ports + cat ./tests/nodes/.ports_map echo "Checking if all ports are open ... ${ports[@]}" try_number=120 diff --git a/.gitignore b/.gitignore index 0852d20c6..6c18d57f5 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,9 @@ /tests/nodes/*/dev.toml /tests/nodes/*.log /tests/deploy/udt-init/target -tests/nodes/.ports +/tests/nodes/.ports +/tests/nodes/.ports_map +/tests/nodes/.ports_tmp /coverage-report /*.info .vscode diff --git a/Cargo.lock b/Cargo.lock index 50ca3528e..062feb8b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -183,6 +183,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "742b2f12ff517f144b6181d24f3f2481b503e05650ee79feec1f090048089f88" +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -386,6 +397,12 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bech32" +version = "0.10.0-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98f7eed2b2781a6f0b5c903471d48e15f56fb4e1165df8a9a2337fd1a59d45ea" + [[package]] name = "bincode" version = "1.3.3" @@ -435,7 +452,7 @@ dependencies = [ "rustc-hash 1.1.0", "shlex", "syn 2.0.104", - "which", + "which 4.4.2", ] [[package]] @@ -521,6 +538,30 @@ dependencies = [ "serde", ] +[[package]] +name = "bitcoin" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c85783c2fe40083ea54a33aa2f0ba58831d90fcd190f5bdc47e74e84d2a96ae" +dependencies = [ + "bech32 0.10.0-beta", + "bitcoin-internals", + "bitcoin_hashes 0.13.0", + "hex-conservative 0.1.2", + "hex_lit", + "secp256k1 0.28.2", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" +dependencies = [ + "serde", +] + [[package]] name = "bitcoin-io" version = "0.1.3" @@ -543,6 +584,17 @@ dependencies = [ "serde", ] +[[package]] +name = "bitcoin_hashes" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" +dependencies = [ + "bitcoin-internals", + "hex-conservative 0.1.2", + "serde", +] + [[package]] name = "bitcoin_hashes" version = "0.14.0" @@ -553,6 +605,43 @@ dependencies = [ "hex-conservative 0.2.1", ] +[[package]] +name = "bitcoincore-rpc" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eb70725a621848c83b3809913d5314c0d20ca84877d99dd909504b564edab00" +dependencies = [ + "bitcoincore-rpc-json", + "jsonrpc", + "log", + "serde", + "serde_json", +] + +[[package]] +name = "bitcoincore-rpc-json" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "856ffbee2e492c23bca715d72ea34aae80d58400f2bda26a82015d6bc2ec3662" +dependencies = [ + "bitcoin 0.31.2", + "serde", + "serde_json", +] + +[[package]] +name = "bitcoind" +version = "0.34.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2542fac51d8cd8fce6109f4a3ffd1acfdaa3394c36d4a8207af15b8b0540e2fc" +dependencies = [ + "anyhow", + "bitcoincore-rpc", + "log", + "tempfile", + "which 4.4.2", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1155,7 +1244,7 @@ dependencies = [ "sha3", "thiserror 1.0.69", "tokio", - "tokio-util", + "tokio-util 0.7.16", "tokio_with_wasm", "web-time", ] @@ -1982,6 +2071,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -2120,6 +2222,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "fixedbitset" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37ab347416e802de484e4d03c7316c48f1ecb56574dfd4a46a80f173ce1de04d" + [[package]] name = "fixedbitset" version = "0.4.2" @@ -2147,7 +2255,7 @@ dependencies = [ "bech32 0.9.1", "bincode 1.3.3", "biscuit-auth", - "bitcoin", + "bitcoin 0.30.2", "bitflags 2.9.2", "bitmask-enum", "bs58", @@ -2177,6 +2285,7 @@ dependencies = [ "indicatif", "jsonrpsee", "lightning-invoice", + "lnd", "lnd-grpc-tonic-client", "molecule", "musig2", @@ -2200,7 +2309,7 @@ dependencies = [ "tentacle", "thiserror 1.0.69", "tokio", - "tokio-util", + "tokio-util 0.7.16", "tower 0.5.2", "tracing", "tracing-subscriber", @@ -2533,7 +2642,7 @@ dependencies = [ "indexmap 2.10.0", "slab", "tokio", - "tokio-util", + "tokio-util 0.7.16", "tracing", ] @@ -2552,7 +2661,7 @@ dependencies = [ "indexmap 2.10.0", "slab", "tokio", - "tokio-util", + "tokio-util 0.7.16", "tracing", ] @@ -2602,6 +2711,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "heck" version = "0.4.1" @@ -2739,6 +2857,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "hyper" version = "0.14.32" @@ -2812,10 +2936,10 @@ dependencies = [ "hyper 1.6.0", "hyper-util", "log", - "rustls", + "rustls 0.23.28", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.2", "tower-service", ] @@ -3210,6 +3334,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonrpc" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8128f36b47411cd3f044be8c1f5cc0c9e24d1d1bfdc45f0a57897b32513053f2" +dependencies = [ + "base64 0.13.1", + "serde", + "serde_json", +] + [[package]] name = "jsonrpc-core" version = "18.0.0" @@ -3256,14 +3391,14 @@ dependencies = [ "http 1.3.1", "jsonrpsee-core", "pin-project", - "rustls", + "rustls 0.23.28", "rustls-pki-types", "rustls-platform-verifier", "soketto", "thiserror 2.0.12", "tokio", - "tokio-rustls", - "tokio-util", + "tokio-rustls 0.26.2", + "tokio-util 0.7.16", "tracing", "url", ] @@ -3309,7 +3444,7 @@ dependencies = [ "hyper-util", "jsonrpsee-core", "jsonrpsee-types", - "rustls", + "rustls 0.23.28", "rustls-platform-verifier", "serde", "serde_json", @@ -3354,7 +3489,7 @@ dependencies = [ "thiserror 2.0.12", "tokio", "tokio-stream", - "tokio-util", + "tokio-util 0.7.16", "tower 0.5.2", "tracing", ] @@ -3440,7 +3575,7 @@ version = "0.0.121" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b0c1f811ae288f86c6767055c55b5f7a721ca1e61bf1897a9ae2ec663e8aba1" dependencies = [ - "bitcoin", + "bitcoin 0.30.2", "hex-conservative 0.1.2", ] @@ -3451,7 +3586,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b186aca4a605d4db3b85979922be287b9ebd5dedd8132963bb9dbeb8f7d2a04" dependencies = [ "bech32 0.9.1", - "bitcoin", + "bitcoin 0.30.2", "lightning", "num-traits", "secp256k1 0.27.0", @@ -3496,6 +3631,24 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" +[[package]] +name = "lnd" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5739cdddbd3123c79b5edcaf9cbe2be84b3be3c011f94c0a0a9f3ad382399f51" +dependencies = [ + "anyhow", + "async-recursion", + "bitcoind", + "env_logger", + "hex", + "log", + "nix", + "tokio", + "tonic_lnd", + "which 5.0.0", +] + [[package]] name = "lnd-grpc-tonic-client" version = "0.3.0" @@ -3509,7 +3662,7 @@ dependencies = [ "prost 0.12.6", "thiserror 1.0.69", "tonic 0.11.0", - "tonic-build", + "tonic-build 0.11.0", "tonic-openssl", "tower-service", ] @@ -3660,6 +3813,12 @@ dependencies = [ "faster-hex", ] +[[package]] +name = "multimap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" + [[package]] name = "multimap" version = "0.10.1" @@ -3700,6 +3859,17 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.9.2", + "cfg-if", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -4019,13 +4189,23 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "petgraph" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "467d164a6de56270bd7c4d070df81d07beace25012d5103ced4e9ff08d6afdb7" +dependencies = [ + "fixedbitset 0.2.0", + "indexmap 1.9.3", +] + [[package]] name = "petgraph" version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ - "fixedbitset", + "fixedbitset 0.4.2", "indexmap 2.10.0", ] @@ -4308,6 +4488,26 @@ dependencies = [ "yansi", ] +[[package]] +name = "prost" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de5e2533f59d08fcf364fd374ebda0692a70bd6d7e66ef97f306f45c6c5d8020" +dependencies = [ + "bytes", + "prost-derive 0.8.0", +] + +[[package]] +name = "prost" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "444879275cb4fd84958b1a1d5420d15e6fcf7c235fe47f053c9c2a80aceb6001" +dependencies = [ + "bytes", + "prost-derive 0.9.0", +] + [[package]] name = "prost" version = "0.10.4" @@ -4328,6 +4528,24 @@ dependencies = [ "prost-derive 0.12.6", ] +[[package]] +name = "prost-build" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "355f634b43cdd80724ee7848f95770e7e70eefa6dcf14fea676216573b8fd603" +dependencies = [ + "bytes", + "heck 0.3.3", + "itertools 0.10.5", + "log", + "multimap 0.8.3", + "petgraph 0.5.1", + "prost 0.8.0", + "prost-types 0.8.0", + "tempfile", + "which 4.4.2", +] + [[package]] name = "prost-build" version = "0.12.6" @@ -4338,9 +4556,9 @@ dependencies = [ "heck 0.5.0", "itertools 0.12.1", "log", - "multimap", + "multimap 0.10.1", "once_cell", - "petgraph", + "petgraph 0.6.5", "prettyplease", "prost 0.12.6", "prost-types 0.12.6", @@ -4349,6 +4567,32 @@ dependencies = [ "tempfile", ] +[[package]] +name = "prost-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "600d2f334aa05acb02a755e217ef1ab6dea4d51b58b7846588b747edec04efba" +dependencies = [ + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "prost-derive" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9cc1a3263e07e0bf68e96268f37665207b49560d98739662cdfaae215c720fe" +dependencies = [ + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "prost-derive" version = "0.10.1" @@ -4375,6 +4619,16 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "prost-types" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "603bbd6394701d13f3f25aada59c7de9d35a6a5887cfc156181234a44002771b" +dependencies = [ + "bytes", + "prost 0.8.0", +] + [[package]] name = "prost-types" version = "0.10.1" @@ -4716,6 +4970,21 @@ dependencies = [ "subtle", ] +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted 0.7.1", + "web-sys", + "winapi", +] + [[package]] name = "ring" version = "0.17.14" @@ -4726,7 +4995,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.16", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -4799,6 +5068,19 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rustls" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35edb675feee39aec9c99fa5ff985081995a06d594114ae14cbe797ad7b7a6d7" +dependencies = [ + "base64 0.13.1", + "log", + "ring 0.16.20", + "sct", + "webpki", +] + [[package]] name = "rustls" version = "0.23.28" @@ -4807,7 +5089,7 @@ checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" dependencies = [ "log", "once_cell", - "ring", + "ring 0.17.14", "rustls-pki-types", "rustls-webpki", "subtle", @@ -4826,6 +5108,15 @@ dependencies = [ "security-framework 3.2.0", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pki-types" version = "1.12.0" @@ -4846,7 +5137,7 @@ dependencies = [ "jni", "log", "once_cell", - "rustls", + "rustls 0.23.28", "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", @@ -4868,9 +5159,9 @@ version = "0.103.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" dependencies = [ - "ring", + "ring 0.17.14", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -4974,6 +5265,16 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "sct" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b362b83898e0e69f38515b82ee15aa80636befe47c3b6d3d89a911e78fc228ce" +dependencies = [ + "ring 0.16.20", + "untrusted 0.7.1", +] + [[package]] name = "sec1" version = "0.7.3" @@ -5021,6 +5322,7 @@ version = "0.28.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d24b59d129cdadea20aea4fb2352fa053712e5d713eee47d700cd4b2bc002f10" dependencies = [ + "bitcoin_hashes 0.12.0", "rand 0.8.5", "secp256k1-sys 0.9.2", "serde", @@ -5377,6 +5679,12 @@ dependencies = [ "sha1", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "spki" version = "0.6.0" @@ -5546,7 +5854,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tokio-tungstenite", - "tokio-util", + "tokio-util 0.7.16", "tokio-yamux", "url", "wasm-bindgen", @@ -5588,15 +5896,24 @@ dependencies = [ "openssl-sys", "rand 0.8.5", "rand_core 0.6.4", - "ring", + "ring 0.17.14", "secp256k1 0.30.0", "sha2 0.10.9", "tokio", - "tokio-util", + "tokio-util 0.7.16", "unsigned-varint", "x25519-dalek", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -5732,6 +6049,7 @@ dependencies = [ "io-uring", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "slab", @@ -5783,13 +6101,24 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6844de72e57df1980054b38be3a9f4702aba4858be64dd700181a8a6d0e1b6" +dependencies = [ + "rustls 0.19.1", + "tokio", + "webpki", +] + [[package]] name = "tokio-rustls" version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" dependencies = [ - "rustls", + "rustls 0.23.28", "tokio", ] @@ -5802,7 +6131,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", - "tokio-util", + "tokio-util 0.7.16", ] [[package]] @@ -5817,6 +6146,20 @@ dependencies = [ "tungstenite", ] +[[package]] +name = "tokio-util" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36943ee01a6d67977dd3f84a5a1d2efeb4ada3a1ae771cadfaa535d9d9fc6507" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "log", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.16" @@ -5844,7 +6187,7 @@ dependencies = [ "log", "nohash-hasher", "tokio", - "tokio-util", + "tokio-util 0.7.16", "web-time", ] @@ -5898,6 +6241,38 @@ dependencies = [ "winnow", ] +[[package]] +name = "tonic" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff08f4649d10a70ffa3522ca559031285d8e421d727ac85c60825761818f5d0a" +dependencies = [ + "async-stream", + "async-trait", + "base64 0.13.1", + "bytes", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost 0.9.0", + "prost-derive 0.9.0", + "tokio", + "tokio-rustls 0.22.0", + "tokio-stream", + "tokio-util 0.6.10", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", + "tracing-futures", +] + [[package]] name = "tonic" version = "0.7.2" @@ -5922,7 +6297,7 @@ dependencies = [ "prost-derive 0.10.1", "tokio", "tokio-stream", - "tokio-util", + "tokio-util 0.7.16", "tower 0.4.13", "tower-layer", "tower-service", @@ -5957,6 +6332,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "tonic-build" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12b52d07035516c2b74337d2ac7746075e7dcae7643816c1b12c5ff8a7484c08" +dependencies = [ + "proc-macro2", + "prost-build 0.8.0", + "quote", + "syn 1.0.109", +] + [[package]] name = "tonic-build" version = "0.11.0" @@ -5965,7 +6352,7 @@ checksum = "be4ef6dd70a610078cb4e338a0f79d06bc759ff1b22d2120c2ff02ae264ba9c2" dependencies = [ "prettyplease", "proc-macro2", - "prost-build", + "prost-build 0.12.6", "quote", "syn 2.0.104", ] @@ -5984,6 +6371,22 @@ dependencies = [ "tonic 0.7.2", ] +[[package]] +name = "tonic_lnd" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a207832efa21cc12bd0d520ce36554af91f5bbcc8873273bc1bab238b3365dbb" +dependencies = [ + "hex", + "prost 0.9.0", + "rustls 0.19.1", + "rustls-pemfile", + "tokio", + "tonic 0.6.2", + "tonic-build 0.5.2", + "webpki", +] + [[package]] name = "tower" version = "0.4.13" @@ -5998,7 +6401,7 @@ dependencies = [ "rand 0.8.5", "slab", "tokio", - "tokio-util", + "tokio-util 0.7.16", "tower-layer", "tower-service", "tracing", @@ -6175,6 +6578,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-width" version = "0.1.14" @@ -6221,6 +6630,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -6453,6 +6868,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e38c0608262c46d4a56202ebabdeb094cef7e560ca7a226c6bf055188aa4ea" +dependencies = [ + "ring 0.16.20", + "untrusted 0.7.1", +] + [[package]] name = "webpki-root-certs" version = "0.26.11" @@ -6483,6 +6908,19 @@ dependencies = [ "rustix 0.38.44", ] +[[package]] +name = "which" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bf3ea8596f3a0dd5980b46430f2058dfe2c36a27ccfbb1845d6fbfcd9ba6e14" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", + "windows-sys 0.48.0", +] + [[package]] name = "winapi" version = "0.3.9" @@ -6625,6 +7063,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -6667,6 +7114,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -6714,6 +7176,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -6732,6 +7200,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -6750,6 +7224,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -6780,6 +7260,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -6798,6 +7284,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -6816,6 +7308,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -6834,6 +7332,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/crates/fiber-bin/src/main.rs b/crates/fiber-bin/src/main.rs index 383172bcc..1f54f5c1d 100644 --- a/crates/fiber-bin/src/main.rs +++ b/crates/fiber-bin/src/main.rs @@ -2,17 +2,18 @@ use ckb_chain_spec::ChainSpec; use ckb_resource::Resource; use core::default::Default; use fnn::actors::RootActor; -use fnn::cch::CchMessage; use fnn::ckb::contracts::TypeIDResolver; #[cfg(debug_assertions)] use fnn::ckb::contracts::{get_cell_deps, Contract}; use fnn::ckb::{contracts::try_init_contracts_context, CkbChainActor}; -use fnn::fiber::{channel::ChannelSubscribers, graph::NetworkGraph, network::init_chain_hash}; +use fnn::fiber::types::Pubkey; +use fnn::fiber::{graph::NetworkGraph, network::init_chain_hash}; use fnn::rpc::server::start_rpc; use fnn::rpc::watchtower::{ CreatePreimageParams, CreateWatchChannelParams, RemovePreimageParams, RemoveWatchChannelParams, UpdateLocalSettlementParams, UpdateRevocationParams, WatchtowerRpcClient, }; +use fnn::store::pub_sub::StoreWithPubSub; use fnn::store::Store; use fnn::tasks::{ cancel_tasks_and_wait_for_completion, new_tokio_cancellation_token, new_tokio_task_tracker, @@ -20,7 +21,7 @@ use fnn::tasks::{ use fnn::watchtower::{ WatchtowerActor, WatchtowerMessage, DEFAULT_WATCHTOWER_CHECK_INTERVAL_SECONDS, }; -use fnn::{start_cch, start_network, Config, NetworkServiceEvent}; +use fnn::{start_cch, start_network, CchArgs, Config, NetworkServiceEvent}; use jsonrpsee::http_client::HttpClientBuilder; use jsonrpsee::ws_client::{HeaderMap, HeaderValue}; use ractor::{Actor, ActorRef}; @@ -77,19 +78,16 @@ pub async fn main() -> Result<(), ExitMessage> { let _span = info_span!("node", node = fnn::get_node_prefix()).entered(); let config = Config::parse(); + let fiber_fallback_config = config.fiber_fallback_config.clone(); - let store_path = config - .fiber - .as_ref() - .ok_or_else(|| ExitMessage("fiber config is required but absent".to_string()))? - .store_path(); + let store_path = fiber_fallback_config.store_path(); let store = Store::new(store_path).map_err(|err| ExitMessage(err.to_string()))?; + let store = StoreWithPubSub::new(store); let tracker = new_tokio_task_tracker(); let token = new_tokio_cancellation_token(); let root_actor = RootActor::start(tracker, token).await; - let subscribers = ChannelSubscribers::default(); #[cfg(debug_assertions)] let rpc_dev_module_commitment_txs = config.rpc.as_ref().and_then(|rpc_config| { @@ -111,7 +109,7 @@ pub async fn main() -> Result<(), ExitMessage> { .to_string(), ) })?; - let node_public_key = fiber_config.public_key(); + let node_public_key: Pubkey = fiber_config.public_key().into(); let chain = fiber_config.chain.as_str(); let chain_spec = ChainSpec::load_from(&match chain { @@ -150,7 +148,7 @@ pub async fn main() -> Result<(), ExitMessage> { let network_graph = Arc::new(RwLock::new(NetworkGraph::new( store.clone(), - node_public_key.clone().into(), + node_public_key, fiber_config.announce_private_addr(), ))); @@ -168,7 +166,6 @@ pub async fn main() -> Result<(), ExitMessage> { new_tokio_task_tracker(), root_actor.get_cell(), store.clone(), - subscribers.clone(), network_graph.clone(), default_shutdown_script, ) @@ -302,12 +299,24 @@ pub async fn main() -> Result<(), ExitMessage> { Some(cch_config) => { info!("Starting cch"); let ignore_startup_failure = cch_config.ignore_startup_failure; + let node_keypair = + if let Some(fiber) = config.fiber.as_ref() { + Some(fiber.read_or_generate_secret_key().map_err(|err| { + ExitMessage(format!("failed to read secret key: {}", err)) + })?) + } else { + None + }; match start_cch( - cch_config, - new_tokio_task_tracker(), - new_tokio_cancellation_token(), + CchArgs { + config: cch_config, + tracker: new_tokio_task_tracker(), + token: new_tokio_cancellation_token(), + network_actor: network_actor.clone(), + store: store.clone(), + node_keypair, + }, root_actor.get_cell(), - network_actor.clone(), ) .await { @@ -323,18 +332,6 @@ pub async fn main() -> Result<(), ExitMessage> { } } Ok(actor) => { - subscribers.pending_received_tlcs_subscribers.subscribe( - actor.clone(), - |tlc_notification| { - Some(CchMessage::PendingReceivedTlcNotification(tlc_notification)) - }, - ); - subscribers.settled_tlcs_subscribers.subscribe( - actor.clone(), - |tlc_notification| { - Some(CchMessage::SettledTlcNotification(tlc_notification)) - }, - ); info!("cch started successfully ..."); Some(actor) } @@ -344,8 +341,8 @@ pub async fn main() -> Result<(), ExitMessage> { }; // Start rpc service - let rpc_server_handle = match (config.rpc, network_graph) { - (Some(rpc_config), Some(network_graph)) => { + let rpc_server_handle = match config.rpc { + Some(rpc_config) => { match start_rpc( rpc_config, config.ckb, @@ -354,20 +351,20 @@ pub async fn main() -> Result<(), ExitMessage> { cch_actor, store, network_graph, - #[cfg(debug_assertions)] ckb_chain_actor, - #[cfg(debug_assertions)] rpc_dev_module_commitment_txs, + root_actor.get_cell(), + #[cfg(debug_assertions)] + ckb_chain_actor, + #[cfg(debug_assertions)] + rpc_dev_module_commitment_txs, ) - .await { + .await + { Ok(handle) => Some(handle), Err(err) => { return ExitMessage::err(format!("rpc server failed to start: {}", err)); } } - }, - (Some(_), None) => return ExitMessage::err( - "RPC requires network graph in the fiber service which is not enabled in the config file" - .to_string() - ), + } _ => None, }; diff --git a/crates/fiber-lib/Cargo.toml b/crates/fiber-lib/Cargo.toml index 9d512040d..c1d1838c7 100644 --- a/crates/fiber-lib/Cargo.toml +++ b/crates/fiber-lib/Cargo.toml @@ -14,8 +14,8 @@ arcode = "0.2.4" async-trait = "0.1" bech32 = "0.9" bincode = "1.3.3" -bitcoin = {version = "0.30.2", features = ["serde", "rand"]} -bitflags = {version = "2.5.0", features = ["serde"]} +bitcoin = { version = "0.30.2", features = ["serde", "rand"] } +bitflags = { version = "2.5.0", features = ["serde"] } bitmask-enum = "2.2.5" bs58 = "0.5.1" ckb-gen-types = "0.202.0" @@ -23,63 +23,64 @@ ckb-jsonrpc-types = "0.202.0" ckb-sdk = "4.4" ckb-testtool = {version = "0.16.0", optional = true} ckb-types = "0.202.0" -clap = {version = "4.5.2", features = ["derive", "env", "string"]} +clap = { version = "4.5.2", features = ["derive", "env", "string"] } clap-serde-derive = "0.2.1" either = "1.15.0" fiber-sphinx = "2.1.0" futures = "0.3.30" -getrandom = {version = "0.2", features = ["js"]} +getrandom = { version = "0.2", features = ["js"] } git-version = "0.3.9" hex = "0.4.3" indicatif = "0.18" -lightning-invoice = {version = "0.29.0"} -molecule = {version = "0.8.0", default-features = false} -musig2 = {version = "0.0.11", features = ["secp256k1", "serde"]} +lightning-invoice = { version = "0.29.0" } +molecule = { version = "0.8.0", default-features = false } +musig2 = { version = "0.0.11", features = ["secp256k1", "serde"] } nom = "7.1.3" num_enum = "0.7.3" once_cell = "1.19.0" parking_lot = "0.12" paste = "1.0" -ractor = {"git" = "https://github.com/officeyutong/ractor", branch = "use-non-send-future-in-wasm", features = [ +ractor = { "git" = "https://github.com/officeyutong/ractor", branch = "use-non-send-future-in-wasm", features = [ "async-trait", -]} +] } rand = "0.8.5" regex = "1.10.5" scrypt = "0.11" -secp256k1 = {version = "0.28.0", features = ["serde", "recovery", "rand-std"]} -serde = {version = "1.0.197", features = ["derive"]} -serde_json = {version = "1.0"} -serde_with = {version = "3.7.0", features = ["macros", "base64"]} +secp256k1 = { version = "0.28.0", features = ["serde", "recovery", "rand-std"] } +serde = { version = "1.0.197", features = ["derive"] } +serde_json = { version = "1.0" } +serde_with = { version = "3.7.0", features = ["macros", "base64"] } serde_yaml = "0.9.32" -strum = {version = "0.26", features = ["derive"]} -tempfile = {version = "3.10.1", optional = true} +strum = { version = "0.26", features = ["derive"] } +tempfile = { version = "3.10.1", optional = true } thiserror = "1.0.58" -tokio-util = {version = "0.7.10", features = ["rt"]} +tokio-util = { version = "0.7.10", features = ["rt"] } tracing = "0.1" -tracing-subscriber = {version = "0.3", features = ["env-filter"]} +tracing-subscriber = { version = "0.3", features = ["env-filter"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] biscuit-auth = "6.0.0-beta.3" ckb-hash = "0.202.0" -clap = {version = "4.5.2", features = ["derive", "env", "string"]} +clap = { version = "4.5.2", features = ["derive", "env", "string"] } clap-serde-derive = "0.2.1" console = "0.16.0" home = "0.5.9" -hyper = {version = "1.5"} -jsonrpsee = {version = "0.25.1", features = ["client", "server", "macros"]} +hyper = { version = "1.5" } +jsonrpsee = { version = "0.25.1", features = ["client", "server", "macros"] } +lnd = { version = "0.1.6", optional = true } lnd-grpc-tonic-client = "0.3.0" -rocksdb = {package = "ckb-rocksdb", version = "=0.21.1", features = [ +rocksdb = { package = "ckb-rocksdb", version = "=0.21.1", features = [ "lz4", -], default-features = false} -tentacle = {version = "0.7", default-features = false, features = [ +], default-features = false } +tentacle = { version = "0.7", default-features = false, features = [ "upnp", "parking_lot", "openssl-vendored", "tokio-runtime", "tokio-timer", "ws", -]} -tokio = {version = "1", features = [ +] } +tokio = { version = "1", features = [ "io-util", "macros", "rt", @@ -89,36 +90,38 @@ tokio = {version = "1", features = [ "time", "signal", "process", -]} -tower = {version = "0.5"} +] } +tower = { version = "0.5" } [target.'cfg(target_arch = "wasm32")'.dependencies] -biscuit-auth = {version = "6.0.0-beta.3", features = ["wasm"]} -ckb-hash = {version = "0.202.0", features = [ +biscuit-auth = { version = "6.0.0-beta.3", features = ["wasm"] } +ckb-hash = { version = "0.202.0", features = [ "blake2b-ref", -], default-features = false} -console_error_panic_hook = {version = "0.1.7"} -fiber-wasm-db-common = {path = "../fiber-wasm-db-common"} -getrandom = {version = "0.3", features = ["wasm_js"]} -jsonrpsee = {version = "0.25.1", features = [ +], default-features = false } +console_error_panic_hook = { version = "0.1.7" } +fiber-wasm-db-common = { path = "../fiber-wasm-db-common" } +getrandom = { version = "0.3", features = ["wasm_js"] } +jsonrpsee = { version = "0.25.1", features = [ "macros", "server-core", "wasm-client", -]} -tentacle = {version = "0.7", default-features = false, features = [ +] } +tentacle = { version = "0.7", default-features = false, features = [ "wasm-timer", -]} -tokio = {version = "1", features = ["io-util", "macros", "rt", "sync"]} -tracing = {version = "0.1", features = ["log"]} +] } +tokio = { version = "1", features = ["io-util", "macros", "rt", "sync"] } +tracing = { version = "0.1", features = ["log"] } wasm-bindgen = "0.2.84" web-sys = "0.3.72" -web-time = {version = "1.1.0", features = ["serde"]} +web-time = { version = "1.1.0", features = ["serde"] } [features] -bench = ["tempfile", "ckb-testtool"] +bench = ["tempfile", "ckb-testtool", "lnd"] default = ["watchtower"] portable = ["rocksdb/portable"] watchtower = [] +# Some lnd related unit tests will only run if this feature is enabled. +lnd-tests = [] [dev-dependencies] ciborium = "0.2.2" @@ -128,8 +131,13 @@ ouroboros = "0.18.5" tempfile = "3.10.1" [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] -criterion = {version = "0.5", features = ["html_reports"]} -jsonrpsee = {version = "0.25.1", features = ["server", "macros", "http-client"]} +criterion = { version = "0.5", features = ["html_reports"] } +jsonrpsee = { version = "0.25.1", features = [ + "server", + "macros", + "http-client", +] } +lnd = "0.1.6" [target.'cfg(target_arch = "wasm32")'.dev-dependencies] wasm-bindgen-test = "0.3.50" diff --git a/crates/fiber-lib/src/cch/actor.rs b/crates/fiber-lib/src/cch/actor.rs index 9632f6eca..cd6bdfed1 100644 --- a/crates/fiber-lib/src/cch/actor.rs +++ b/crates/fiber-lib/src/cch/actor.rs @@ -1,4 +1,4 @@ -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, Result}; use futures::StreamExt as _; use hex::ToHex; use lightning_invoice::Bolt11Invoice; @@ -7,59 +7,75 @@ use lnd_grpc_tonic_client::{ RouterClient, Uri, }; -use ractor::{call, RpcReplyPort}; +use ractor::RpcReplyPort; use ractor::{Actor, ActorCell, ActorProcessingErr, ActorRef}; +use secp256k1::{PublicKey, Secp256k1, SecretKey}; use serde::Deserialize; +use tentacle::secio::SecioKeyPair; +use crate::cch::cch_fiber_agent::CchFiberAgent; +use crate::cch::order_guard::{CchOrderGuardActor, CchOrderGuardArgs}; +use crate::fiber::hash_algorithm::HashAlgorithm; +use crate::fiber::payment::PaymentStatus; use crate::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::marker::PhantomData; use std::str::FromStr; use tokio::{select, time::sleep}; use tokio_util::{sync::CancellationToken, task::TaskTracker}; -use crate::ckb::contracts::{get_script_by_contract, Contract}; -use crate::fiber::channel::{ - AddTlcCommand, ChannelCommand, ChannelCommandWithId, RemoveTlcCommand, TlcNotification, +use crate::fiber::types::{Hash256, Privkey}; +use crate::fiber::NetworkActorMessage; +use crate::invoice::{CkbInvoice, Currency, InvoiceBuilder}; +use crate::store::pub_sub::{ + InvoiceUpdatedEvent, InvoiceUpdatedPayload, PaymentUpdatedEvent, PaymentUpdatedPayload, + PubSubClient, StoreUpdatedEvent, Subscribe, }; -use crate::fiber::hash_algorithm::HashAlgorithm; -use crate::fiber::types::{Hash256, RemoveTlcFulfill, RemoveTlcReason, NO_SHARED_SECRET}; -use crate::fiber::{NetworkActorCommand, NetworkActorMessage}; -use crate::invoice::Currency; -use crate::now_timestamp_as_millis_u64; -use super::error::CchDbError; -use super::{CchConfig, CchError, CchOrderStatus, CchOrdersDb, ReceiveBTCOrder, SendBTCOrder}; +use super::{ + order_guard::{CchOrderGuardEvent, CchOrderGuardMessage}, + CchConfig, CchError, CchInvoice, CchOrder, CchOrderStatus, CchOrderStore, CchStoreError, +}; pub const BTC_PAYMENT_TIMEOUT_SECONDS: i32 = 60; pub const DEFAULT_ORDER_EXPIRY_SECONDS: u64 = 86400; // 24 hours +pub const ORDER_PURGE_TTL: u64 = 86400 * 14; // 14 days -pub async fn start_cch( - config: CchConfig, - tracker: TaskTracker, - token: CancellationToken, +pub async fn start_cch( + args: CchArgs, root_actor: ActorCell, - network_actor: Option>, ) -> Result> { + if args.network_actor.is_none() { + if args.config.fiber_rpc_url.is_none() { + return Err(anyhow!( + "Cch requires either in process network actor or configured fiber RPC URL" + )); + } else { + ensure_fiber_http_url(args.config.fiber_rpc_url.clone())?; + } + } + let (actor, _handle) = Actor::spawn_linked( Some("cch actor".to_string()), - CchActor::new(config, tracker, token, network_actor), - (), + CchActor::default(), + args, root_actor, ) .await?; + Ok(actor) } #[derive(Debug)] pub struct SettleSendBTCOrderEvent { - payment_hash: String, - preimage: Option, + payment_hash: Hash256, + preimage: Option, status: CchOrderStatus, } #[derive(Debug)] pub struct SettleReceiveBTCOrderEvent { - payment_hash: String, - preimage: Option, + payment_hash: Hash256, + preimage: Option, status: CchOrderStatus, } @@ -71,41 +87,56 @@ pub struct SendBTC { #[derive(Clone, Debug, Deserialize)] pub struct ReceiveBTC { - /// Payment hash for the HTLC for both CKB and BTC. - pub payment_hash: String, - - /// Assume that the cross-chain hub already has a channel to the payee and the channel has - /// enough balance to pay the order. - /// TODO: Let the cross-chain hub create a channel to the payee on demand. - pub channel_id: Hash256, - /// Amount required to pay in Satoshis via BTC, including the fee for the cross-chain hub - pub amount_sats: u128, - /// Expiry set for the HTLC for the CKB payment to the payee. - pub final_tlc_expiry: u64, + pub fiber_pay_req: String, } pub enum CchMessage { - SendBTC(SendBTC, RpcReplyPort>), - ReceiveBTC(ReceiveBTC, RpcReplyPort>), + SendBTC(SendBTC, RpcReplyPort>), + ReceiveBTC(ReceiveBTC, RpcReplyPort>), - GetReceiveBTCOrder(String, RpcReplyPort>), + GetCchOrder(Hash256, RpcReplyPort>), SettleSendBTCOrder(SettleSendBTCOrderEvent), SettleReceiveBTCOrder(SettleReceiveBTCOrderEvent), - PendingReceivedTlcNotification(TlcNotification), - SettledTlcNotification(TlcNotification), + StoreUpdatedEvent(StoreUpdatedEvent), + + /// Try the next action to move forward the order + StepOrder(CchOrder), +} + +impl From for CchMessage { + fn from(event: StoreUpdatedEvent) -> Self { + CchMessage::StoreUpdatedEvent(event) + } +} + +impl From for CchMessage { + fn from(event: CchOrderGuardEvent) -> Self { + match event { + CchOrderGuardEvent::OrderLoaded(order) => CchMessage::StepOrder(order), + } + } +} +impl TryFrom for CchOrderGuardEvent { + type Error = String; + fn try_from(message: CchMessage) -> Result { + match message { + CchMessage::StepOrder(order) => Ok(CchOrderGuardEvent::OrderLoaded(order)), + _ => Err("Message has invalid type".to_string()), + } + } } #[derive(Clone)] -struct LndConnectionInfo { - uri: Uri, - cert: Option>, - macaroon: Option>, +pub struct LndConnectionInfo { + pub uri: Uri, + pub cert: Option>, + pub macaroon: Option>, } impl LndConnectionInfo { - async fn create_router_client( + pub(crate) async fn create_router_client( &self, ) -> Result { create_router_client( @@ -116,7 +147,7 @@ impl LndConnectionInfo { .await } - async fn create_invoices_client( + pub(crate) async fn create_invoices_client( &self, ) -> Result { create_invoices_client( @@ -128,61 +159,105 @@ impl LndConnectionInfo { } } -pub struct CchActor { +pub struct CchActor(PhantomData); + +impl Default for CchActor { + fn default() -> Self { + Self(PhantomData) + } +} + +pub struct CchArgs { + pub config: CchConfig, + pub tracker: TaskTracker, + pub token: CancellationToken, + pub network_actor: Option>, + pub node_keypair: Option, + pub store: S, +} + +pub struct CchState { config: CchConfig, tracker: TaskTracker, token: CancellationToken, - network_actor: Option>, -} - -pub struct CchState { + fiber_agent: CchFiberAgent, + node_keypair: Option<(PublicKey, SecretKey)>, + store: S, lnd_connection: LndConnectionInfo, - orders_db: CchOrdersDb, + order_guard: ActorRef, } -#[cfg_attr(target_arch="wasm32",async_trait::async_trait(?Send))] -#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] -impl Actor for CchActor { + +#[async_trait::async_trait] +impl Actor for CchActor { type Msg = CchMessage; - type State = CchState; - type Arguments = (); + type State = CchState; + type Arguments = CchArgs; async fn pre_start( &self, myself: ActorRef, - _config: Self::Arguments, + args: Self::Arguments, ) -> Result { - let lnd_rpc_url: Uri = self.config.lnd_rpc_url.clone().try_into()?; - let cert = match self.config.resolve_lnd_cert_path() { - Some(path) => Some( - tokio::fs::read(&path) - .await - .with_context(|| format!("read cert file {}", path.display()))?, - ), - None => None, - }; - let macaroon = match self.config.resolve_lnd_macaroon_path() { - Some(path) => Some( - tokio::fs::read(&path) - .await - .with_context(|| format!("read macaroon file {}", path.display()))?, - ), - None => None, + let lnd_connection = args.config.get_lnd_connection_info().await?; + let (order_guard, _) = Actor::spawn_linked( + None, + CchOrderGuardActor::default(), + CchOrderGuardArgs { + watcher: myself.get_derived(), + purge_ttl: ORDER_PURGE_TTL, + store: args.store.clone(), + }, + myself.get_cell(), + ) + .await?; + + if args.network_actor.is_some() { + // in process + args.store.subscribe(Box::new(myself.clone())); + } else { + let pub_sub_client = + PubSubClient::new(ensure_fiber_ws_url(args.config.fiber_rpc_url.clone())?); + pub_sub_client.subscribe(Box::new(myself.clone())); + let token = args.token.clone(); + args.tracker + .spawn(async move { pub_sub_client.run(token).await }); }; - let lnd_connection = LndConnectionInfo { - uri: lnd_rpc_url, - cert, - macaroon, + let fiber_agent = + CchFiberAgent::try_new(args.network_actor, args.config.fiber_rpc_url.as_deref())?; + + let node_keypair = args.node_keypair.map(|kp| { + let private_key: Privkey = <[u8; 32]>::try_from(kp.as_ref()) + .expect("valid length for key") + .into(); + let secio_kp = SecioKeyPair::from(kp); + + ( + PublicKey::from_slice(secio_kp.public_key().inner_ref()).expect("valid public key"), + private_key.into(), + ) + }); + + let state = CchState { + config: args.config, + tracker: args.tracker, + token: args.token, + store: args.store, + node_keypair, + fiber_agent, + lnd_connection, + order_guard, }; - let payments_tracker = - LndPaymentsTracker::new(myself.clone(), lnd_connection.clone(), self.token.clone()); - self.tracker + let payments_tracker = LndPaymentsTracker::new( + myself.clone(), + state.lnd_connection.clone(), + state.token.clone(), + ); + state + .tracker .spawn(async move { payments_tracker.run().await }); - Ok(CchState { - lnd_connection, - orders_db: Default::default(), - }) + Ok(state) } async fn handle( @@ -193,7 +268,7 @@ impl Actor for CchActor { ) -> Result<(), ActorProcessingErr> { match message { CchMessage::SendBTC(send_btc, port) => { - let result = self.send_btc(state, send_btc).await; + let result = state.send_btc(myself, send_btc).await; if !port.is_closed() { // ignore error let _ = port.send(result); @@ -201,19 +276,15 @@ impl Actor for CchActor { Ok(()) } CchMessage::ReceiveBTC(receive_btc, port) => { - let result = self.receive_btc(myself, state, receive_btc).await; + let result = state.receive_btc(myself, receive_btc).await; if !port.is_closed() { // ignore error let _ = port.send(result); } Ok(()) } - CchMessage::GetReceiveBTCOrder(payment_hash, port) => { - let result = state - .orders_db - .get_receive_btc_order(&payment_hash) - .await - .map_err(Into::into); + CchMessage::GetCchOrder(payment_hash, port) => { + let result = state.store.get_cch_order(&payment_hash).map_err(Into::into); if !port.is_closed() { // ignore error let _ = port.send(result); @@ -222,33 +293,33 @@ impl Actor for CchActor { } CchMessage::SettleSendBTCOrder(event) => { tracing::debug!("settle_send_btc_order {:?}", event); - if let Err(err) = self.settle_send_btc_order(state, event).await { + if let Err(err) = state.settle_send_btc_order(myself, event).await { tracing::error!("settle_send_btc_order failed: {}", err); } Ok(()) } CchMessage::SettleReceiveBTCOrder(event) => { tracing::debug!("settle_receive_btc_order {:?}", event); - if let Err(err) = self.settle_receive_btc_order(state, event).await { + if let Err(err) = state.settle_receive_btc_order(myself, event).await { tracing::error!("settle_receive_btc_order failed: {}", err); } Ok(()) } - CchMessage::PendingReceivedTlcNotification(tlc_notification) => { - if let Err(err) = self - .handle_pending_received_tlc_notification(state, tlc_notification) - .await - { - tracing::error!("handle_pending_received_tlc_notification failed: {}", err); + CchMessage::StoreUpdatedEvent(event) => { + tracing::debug!( + store_updated_event = ?event, + "Cch actor received store updated event" + ); + if let Err(err) = state.handle_store_updated_event(myself, event).await { + tracing::error!("handle_store_updated_event failed: {}", err); } Ok(()) } - CchMessage::SettledTlcNotification(tlc_notification) => { - if let Err(err) = self - .handle_settled_tlc_notification(state, tlc_notification) - .await - { - tracing::error!("handle_settled_tlc_notification failed: {}", err); + CchMessage::StepOrder(order) => { + if let Err(err) = state.step_order(myself.clone(), &order).await { + tracing::error!("failed to step order: {}, order={:?}", err, order); + // Retry later + myself.send_after(Duration::from_secs(10), || CchMessage::StepOrder(order)); } Ok(()) } @@ -256,30 +327,18 @@ impl Actor for CchActor { } } -impl CchActor { - pub fn new( - config: CchConfig, - tracker: TaskTracker, - token: CancellationToken, - network_actor: Option>, - ) -> Self { - Self { - config, - tracker, - token, - network_actor, - } - } - +impl CchState { async fn send_btc( &self, - state: &mut CchState, + myself: ActorRef, send_btc: SendBTC, - ) -> Result { + ) -> Result { let duration_since_epoch = SystemTime::now().duration_since(UNIX_EPOCH)?; let invoice = Bolt11Invoice::from_str(&send_btc.btc_pay_req)?; tracing::debug!("BTC invoice: {:?}", invoice); + let payment_hash = Hash256::from_str(&invoice.payment_hash().encode_hex::()) + .map_err(|_| CchError::HexDecodingError(invoice.payment_hash().to_string()))?; let expiry = invoice .expires_at() @@ -295,191 +354,160 @@ impl CchActor { / 1_000_000_000u128 + (self.config.base_fee_sats as u128); - let wrapped_btc_type_script: ckb_jsonrpc_types::Script = get_script_by_contract( - Contract::SimpleUDT, - hex::decode( - self.config - .wrapped_btc_type_script_args - .trim_start_matches("0x"), - ) - .map_err(|_| { - CchError::HexDecodingError(self.config.wrapped_btc_type_script_args.clone()) - })? - .as_ref(), - ) - .into(); - let mut order = SendBTCOrder { - expires_after: expiry, + let wrapped_btc_type_script: ckb_jsonrpc_types::Script = + self.config.get_wrapped_btc_script().into(); + let invoice_amount_sats = amount_msat.div_ceil(1_000u128) + fee_sats; + + let invoice_builder = InvoiceBuilder::new(send_btc.currency) + .amount(Some(invoice_amount_sats)) + .payment_hash(payment_hash) + .hash_algorithm(HashAlgorithm::Sha256) + .expiry_time(Duration::from_secs(expiry)) + .final_expiry_delta(self.config.ckb_final_tlc_expiry_delta) + .udt_type_script(wrapped_btc_type_script.clone().into()); + let invoice = if let Some((public_key, secret_key)) = &self.node_keypair { + invoice_builder + .payee_pub_key(*public_key) + .build_with_sign(|hash| Secp256k1::new().sign_ecdsa_recoverable(hash, secret_key)) + } else { + invoice_builder.build() + }?; + let invoice = self + .fiber_agent + .add_invoice(invoice) + .await + .map_err(CchError::FiberNodeError)?; + let order = CchOrder { wrapped_btc_type_script, fee_sats, - currency: send_btc.currency, + payment_hash, + expires_after: expiry, created_at: duration_since_epoch.as_secs(), ckb_final_tlc_expiry_delta: self.config.ckb_final_tlc_expiry_delta, - btc_pay_req: send_btc.btc_pay_req, - ckb_pay_req: Default::default(), - payment_hash: format!("0x{}", invoice.payment_hash().encode_hex::()), + outgoing_pay_req: send_btc.btc_pay_req, + incoming_invoice: CchInvoice::Fiber(invoice), payment_preimage: None, - channel_id: None, - tlc_id: None, - amount_sats: amount_msat.div_ceil(1_000u128) + fee_sats, + amount_sats: invoice_amount_sats, status: CchOrderStatus::Pending, }; - order.generate_ckb_invoice()?; - - state.orders_db.insert_send_btc_order(order.clone()).await?; - // TODO(now): save order and invoice into db: store.insert_invoice(invoice.clone()) + self.store.insert_cch_order(order.clone())?; + self.step_order(myself, &order).await?; Ok(order) } + async fn handle_store_updated_event( + &self, + myself: ActorRef, + event: StoreUpdatedEvent, + ) -> Result<()> { + match event { + StoreUpdatedEvent::InvoiceUpdated(invoice_updated_event) => { + self.handle_invoice_updated_event(myself, invoice_updated_event) + .await?; + } + StoreUpdatedEvent::PaymentUpdated(payment_updated_event) => { + self.handle_payment_updated_event(myself, payment_updated_event) + .await?; + } + } + Ok(()) + } + // On receiving new TLC, check whether it matches the SendBTC order - async fn handle_pending_received_tlc_notification( + async fn handle_invoice_updated_event( &self, - state: &mut CchState, - tlc_notification: TlcNotification, + myself: ActorRef, + event: InvoiceUpdatedEvent, ) -> Result<()> { - let payment_hash = format!("{:#x}", tlc_notification.tlc.payment_hash); + if !matches!( + event.payload, + InvoiceUpdatedPayload::Received { + is_finished: true, + .. + } + ) { + // TODO: handle other states + return Ok(()); + } + let payment_hash = event.invoice_hash; tracing::debug!("[inbounding tlc] payment hash: {}", payment_hash); - let mut order = match state.orders_db.get_send_btc_order(&payment_hash).await { - Err(CchDbError::NotFound(_)) => return Ok(()), + let mut order = match self.store.get_cch_order(&payment_hash) { + Err(CchStoreError::NotFound(_)) => return Ok(()), Err(err) => return Err(err.into()), - Ok(order) => order, + Ok(order) if order.is_from_fiber_to_lightning() => order, + // ignore if the order is not from fiber to lightning + Ok(_) => return Ok(()), }; if order.status != CchOrderStatus::Pending { return Err(CchError::SendBTCOrderAlreadyPaid.into()); } - if tlc_notification.tlc.amount < order.amount_sats { - // TODO: split the payment into multiple parts - return Err(CchError::SendBTCReceivedAmountTooSmall.into()); - } - - order.channel_id = Some(tlc_notification.channel_id); - order.tlc_id = Some(tlc_notification.tlc.tlc_id.into()); - state.orders_db.update_send_btc_order(order.clone()).await?; - - let req = routerrpc::SendPaymentRequest { - payment_request: order.btc_pay_req.clone(), - timeout_seconds: BTC_PAYMENT_TIMEOUT_SECONDS, - ..Default::default() - }; - tracing::debug!("[inbounding tlc] SendPaymentRequest: {:?}", req); - - let mut client = state.lnd_connection.create_router_client().await?; - // TODO: set a fee - let mut stream = client.send_payment_v2(req).await?.into_inner(); - // Wait for the first message then quit - select! { - payment_result_opt = stream.next() => { - tracing::debug!("[inbounding tlc] payment result: {:?}", payment_result_opt); - if let Some(Ok(payment)) = payment_result_opt { - order.status = lnrpc::payment::PaymentStatus::try_from(payment.status)?.into(); - state.orders_db - .update_send_btc_order(order) - .await?; - } - } - _ = self.token.cancelled() => { - tracing::debug!("Cancellation received, shutting down cch service"); - return Ok(()); - } - } - + order.status = CchOrderStatus::IncomingAccepted; + self.update_cch_order(order.clone()); + self.step_order(myself, &order).await?; Ok(()) } - async fn handle_settled_tlc_notification( + async fn handle_payment_updated_event( &self, - state: &mut CchState, - tlc_notification: TlcNotification, + myself: ActorRef, + event: PaymentUpdatedEvent, ) -> Result<()> { - let payment_hash = format!("{:#x}", tlc_notification.tlc.payment_hash); - tracing::debug!("[settled tlc] payment hash: {}", payment_hash); + let payment_hash = event.payment_hash; + tracing::debug!("[settled tlc] payment hash: {:#x}", payment_hash); - match state.orders_db.get_receive_btc_order(&payment_hash).await { - Err(CchDbError::NotFound(_)) => return Ok(()), + let mut order = match self.store.get_cch_order(&payment_hash) { + Err(CchStoreError::NotFound(_)) => return Ok(()), Err(err) => return Err(err.into()), + // ignore if the order is from fiber to lightning + Ok(order) if order.is_from_fiber_to_lightning() => return Ok(()), + Ok(order) => order, + }; + + let preimage = match event.payload { + PaymentUpdatedPayload::Success { preimage } => preimage, + PaymentUpdatedPayload::Failed => { + // TODO: handle failed payment + return Ok(()); + } _ => { - // ignore + tracing::debug!("Ignore payment update"); + return Ok(()); } }; - let preimage = tlc_notification - .tlc - .payment_preimage - .ok_or(CchError::ReceiveBTCMissingPreimage)?; - tracing::debug!("[settled tlc] preimage: {:#x}", preimage); - - // settle the lnd invoice - let req = invoicesrpc::SettleInvoiceMsg { - preimage: preimage.as_ref().to_vec(), - }; - tracing::debug!("[settled tlc] SettleInvoiceMsg: {:?}", req); - - let mut client = state.lnd_connection.create_invoices_client().await?; - // TODO: set a fee - let resp = client.settle_invoice(req).await?.into_inner(); - tracing::debug!("[settled tlc] SettleInvoiceResp: {:?}", resp); + order.status = CchOrderStatus::OutgoingSettled; + order.payment_preimage = Some(preimage); + self.update_cch_order(order.clone()); + self.step_order(myself, &order).await?; Ok(()) } async fn settle_send_btc_order( &self, - state: &mut CchState, + myself: ActorRef, event: SettleSendBTCOrderEvent, ) -> Result<()> { - let mut order = match state - .orders_db - .get_send_btc_order(&event.payment_hash) - .await - { - Err(CchDbError::NotFound(_)) => return Ok(()), + let payment_hash = event.payment_hash; + + let mut order = match self.store.get_cch_order(&payment_hash) { + Err(CchStoreError::NotFound(_)) => return Ok(()), Err(err) => return Err(err.into()), + Ok(order) if !order.is_from_fiber_to_lightning() => return Ok(()), Ok(order) => order, }; order.status = event.status; - if let (Some(preimage), Some(network_actor), Some(channel_id), Some(tlc_id)) = ( - event.preimage, - &self.network_actor, - order.channel_id, - order.tlc_id, - ) { - tracing::info!( - "SettleSendBTCOrder: payment_hash={}, status={:?}", - event.payment_hash, - event.status - ); - order.payment_preimage = Some(preimage.clone()); - - let message = move |rpc_reply| -> NetworkActorMessage { - NetworkActorMessage::Command(NetworkActorCommand::ControlFiberChannel( - ChannelCommandWithId { - channel_id, - command: ChannelCommand::RemoveTlc( - RemoveTlcCommand { - id: tlc_id, - reason: RemoveTlcReason::RemoveTlcFulfill(RemoveTlcFulfill { - payment_preimage: Hash256::from_str(&preimage) - .expect("decode preimage"), - }), - }, - rpc_reply, - ), - }, - )) - }; - - call!(network_actor, message) - .expect("call actor") - .map_err(|msg| anyhow!(msg))?; + if let Some(preimage) = event.preimage { + order.payment_preimage = Some(preimage); } - - state.orders_db.update_send_btc_order(order).await?; + self.update_cch_order(order.clone()); + self.step_order(myself, &order).await?; Ok(()) } @@ -487,14 +515,14 @@ impl CchActor { async fn receive_btc( &self, myself: ActorRef, - state: &mut CchState, receive_btc: ReceiveBTC, - ) -> Result { + ) -> Result { + let invoice = CkbInvoice::from_str(&receive_btc.fiber_pay_req)?; + let payment_hash = *invoice.payment_hash(); + let amount_sats = invoice.amount().ok_or(CchError::CKBInvoiceMissingAmount)?; + let final_tlc_minimum_expiry_delta = + *invoice.final_tlc_minimum_expiry_delta().unwrap_or(&0); let duration_since_epoch = SystemTime::now().duration_since(UNIX_EPOCH)?; - let hash_bin = hex::decode(receive_btc.payment_hash.trim_start_matches("0x")) - .map_err(|_| CchError::HexDecodingError(receive_btc.payment_hash.clone()))?; - - let amount_sats = receive_btc.amount_sats; let fee_sats = amount_sats * (self.config.fee_rate_per_million_sats as u128) / 1_000_000u128 + (self.config.base_fee_sats as u128); @@ -505,126 +533,210 @@ impl CchActor { return Err(CchError::ReceiveBTCOrderAmountTooLarge); } - let mut client = state.lnd_connection.create_invoices_client().await?; + let mut client = self.lnd_connection.create_invoices_client().await?; let req = invoicesrpc::AddHoldInvoiceRequest { - hash: hash_bin, + hash: payment_hash.into(), value_msat: (amount_sats * 1_000u128) as i64, expiry: DEFAULT_ORDER_EXPIRY_SECONDS as i64, - cltv_expiry: self.config.btc_final_tlc_expiry + receive_btc.final_tlc_expiry, + cltv_expiry: self.config.btc_final_tlc_expiry + final_tlc_minimum_expiry_delta, ..Default::default() }; - let invoice = client + let add_invoice_resp = client .add_hold_invoice(req) .await .map_err(|err| CchError::LndRpcError(err.to_string()))? .into_inner(); - let btc_pay_req = invoice.payment_request; - - let wrapped_btc_type_script: ckb_jsonrpc_types::Script = get_script_by_contract( - Contract::SimpleUDT, - hex::decode( - self.config - .wrapped_btc_type_script_args - .trim_start_matches("0x"), - ) - .map_err(|_| { - CchError::HexDecodingError(self.config.wrapped_btc_type_script_args.clone()) - })? - .as_ref(), - ) - .into(); - let order = ReceiveBTCOrder { + let incoming_invoice = Bolt11Invoice::from_str(&add_invoice_resp.payment_request)?; + + let wrapped_btc_type_script: ckb_jsonrpc_types::Script = + self.config.get_wrapped_btc_script().into(); + let order = CchOrder { created_at: duration_since_epoch.as_secs(), expires_after: DEFAULT_ORDER_EXPIRY_SECONDS, - ckb_final_tlc_expiry_delta: receive_btc.final_tlc_expiry, - btc_pay_req, - payment_hash: receive_btc.payment_hash.clone(), + ckb_final_tlc_expiry_delta: final_tlc_minimum_expiry_delta, + outgoing_pay_req: receive_btc.fiber_pay_req, + incoming_invoice: CchInvoice::Lightning(incoming_invoice), + payment_hash, payment_preimage: None, amount_sats, fee_sats, status: CchOrderStatus::Pending, wrapped_btc_type_script, - // TODO: check the channel exists and has enough local balance. - channel_id: receive_btc.channel_id, - tlc_id: None, }; - state - .orders_db - .insert_receive_btc_order(order.clone()) - .await?; - - let invoice_tracker = LndInvoiceTracker::new( - myself, - receive_btc.payment_hash, - state.lnd_connection.clone(), - self.token.clone(), - ); - self.tracker - .spawn(async move { invoice_tracker.run().await }); + self.store.insert_cch_order(order.clone())?; + self.step_order(myself, &order).await?; Ok(order) } async fn settle_receive_btc_order( &self, - state: &mut CchState, + myself: ActorRef, event: SettleReceiveBTCOrderEvent, ) -> Result<()> { - let mut order = match state - .orders_db - .get_receive_btc_order(&event.payment_hash) - .await - { - Err(CchDbError::NotFound(_)) => return Ok(()), + let mut order = match self.store.get_cch_order(&event.payment_hash) { + Err(CchStoreError::NotFound(_)) => return Ok(()), Err(err) => return Err(err.into()), + Ok(order) if order.is_from_fiber_to_lightning() => return Ok(()), Ok(order) => order, }; - - if event.status == CchOrderStatus::Accepted && self.network_actor.is_some() { - // AddTlc to initiate the CKB payment - let message = |rpc_reply| -> NetworkActorMessage { - NetworkActorMessage::Command(NetworkActorCommand::ControlFiberChannel( - ChannelCommandWithId { - channel_id: order.channel_id, - command: ChannelCommand::AddTlc( - AddTlcCommand { - amount: order.amount_sats - order.fee_sats, - payment_hash: Hash256::from_str(&order.payment_hash) - .expect("parse Hash256"), - attempt_id: None, - expiry: now_timestamp_as_millis_u64() - + self.config.ckb_final_tlc_expiry_delta, - hash_algorithm: HashAlgorithm::Sha256, - onion_packet: None, - shared_secret: NO_SHARED_SECRET, - previous_tlc: None, - }, - rpc_reply, - ), - }, - )) - }; - let tlc_response = call!( - self.network_actor - .as_ref() - .expect("CCH requires network actor"), - message - ) - .expect("call actor") - .map_err(|msg| anyhow!(msg))?; - order.tlc_id = Some(tlc_response.tlc_id); - } - order.status = event.status; - order.payment_preimage = event.preimage.clone(); + order.payment_preimage = event.preimage; + self.update_cch_order(order.clone()); + self.step_order(myself, &order).await?; + Ok(()) + } - state - .orders_db - .update_receive_btc_order(order.clone()) - .await?; + async fn step_order( + &self, + myself: ActorRef, + order: &CchOrder, + ) -> Result<(), CchError> { + if order.is_from_fiber_to_lightning() { + // send btc + match order.status { + CchOrderStatus::Pending => {} + CchOrderStatus::IncomingAccepted => { + // Need to send the outgoing payment + let req = routerrpc::SendPaymentRequest { + payment_request: order.outgoing_pay_req.clone(), + timeout_seconds: BTC_PAYMENT_TIMEOUT_SECONDS, + ..Default::default() + }; + tracing::debug!("[inbounding tlc] SendPaymentRequest: {:?}", req); + + let mut order = order.clone(); + let mut client = self.lnd_connection.create_router_client().await?; + // TODO: set a fee + let mut stream = client + .send_payment_v2(req) + .await + .map_err(|err| CchError::LndRpcError(err.to_string()))? + .into_inner(); + // Wait for the first message then quit + select! { + payment_result_opt = stream.next() => { + tracing::debug!("[inbounding tlc] payment result: {:?}", payment_result_opt); + if let Some(Ok(payment)) = payment_result_opt { + // TODO: the payment result here may indicate a failure, we need to handle it + order.status = lnrpc::payment::PaymentStatus::try_from(payment.status).map_err(|err| { + CchError::LndRpcError(format!("expect a valid payment status: {}", err)) + })?.into(); + if order.status == CchOrderStatus::OutgoingSettled { + order.payment_preimage = Some(Hash256::from_str(&payment.payment_preimage).expect("lnd payment preimage is valid Hash256")); + } + self.update_cch_order(order.clone()); + } + } + _ = self.token.cancelled() => { + tracing::debug!("Cancellation received, shutting down cch service"); + return Ok(()); + } + } + // Repeat step until moving to the next step + let _ = myself.send_message(CchMessage::StepOrder(order)); + } + CchOrderStatus::OutgoingInFlight => {} + CchOrderStatus::OutgoingSettled => { + if let Some(preimage) = order.payment_preimage { + tracing::info!( + "SettleSendBTCOrder: payment_hash={:#x}, status={:?}", + order.payment_hash, + order.status + ); + + self.fiber_agent + .settle_invoice(order.payment_hash, preimage) + .await + .map_err(CchError::FiberNodeError)?; + let mut order = order.clone(); + order.status = CchOrderStatus::Succeeded; + self.update_cch_order(order); + } + } + CchOrderStatus::Succeeded => {} + CchOrderStatus::Failed => {} + } + } else { + // receive btc + match order.status { + CchOrderStatus::Pending => { + let invoice_tracker = LndInvoiceTracker::new( + myself, + order.payment_hash, + self.lnd_connection.clone(), + self.token.clone(), + ); + self.tracker + .spawn(async move { invoice_tracker.run().await }); + } + CchOrderStatus::IncomingAccepted => { + tracing::debug!( + payment_hash = ?order.payment_hash, + "Sending payment to fiber node because we received payment from LND", + ); + + let payment_status = self + .fiber_agent + .send_payment(order.outgoing_pay_req.clone()) + .await + .map_err(CchError::FiberNodeError)?; + + let mut order = order.clone(); + if payment_status == PaymentStatus::Failed { + order.status = CchOrderStatus::Failed; + } else { + order.status = CchOrderStatus::OutgoingInFlight; + } + self.update_cch_order(order.clone()); + let _ = myself.send_message(CchMessage::StepOrder(order.clone())); + } + CchOrderStatus::OutgoingInFlight => {} + CchOrderStatus::OutgoingSettled => { + if let Some(preimage) = order.payment_preimage { + // settle the lnd invoice + let req = invoicesrpc::SettleInvoiceMsg { + preimage: preimage.as_ref().to_vec(), + }; + tracing::debug!("[settled tlc] SettleInvoiceMsg: {:?}", req); + + let mut client = self.lnd_connection.create_invoices_client().await?; + // TODO: set a fee + let resp = client + .settle_invoice(req) + .await + .map_err(|err| CchError::LndRpcError(err.to_string()))? + .into_inner(); + tracing::debug!("[settled tlc] SettleInvoiceResp: {:?}", resp); + + let mut order = order.clone(); + order.status = CchOrderStatus::Succeeded; + self.update_cch_order(order); + } + } + CchOrderStatus::Succeeded => {} + CchOrderStatus::Failed => {} + } + } Ok(()) } + + /// Update the order in store. If the order is inactive, register it in CchOrderGuardActor. + fn update_cch_order(&self, order: CchOrder) { + if order.status.is_inactive() { + if let Err(err) = self + .order_guard + .send_message(CchOrderGuardMessage::DeactivateOrder { + payment_hash: order.payment_hash, + expires_at: order.created_at + order.expires_after, + }) + { + tracing::error!("failed to send message to CchOrderGuardActor: {}", err); + } + } + self.store.update_cch_order(order); + } } struct LndPaymentsTracker { @@ -696,6 +808,7 @@ impl LndPaymentsTracker { .await? .into_inner(); + tracing::debug!("Subscribed to lnd payments"); loop { select! { payment_opt = stream.next() => { @@ -715,13 +828,17 @@ impl LndPaymentsTracker { async fn on_payment(&self, payment: lnrpc::Payment) -> Result<()> { tracing::debug!(target: "fnn::cch::actor::tracker::lnd_payments", "payment: {:?}", payment); + let preimage = if !payment.payment_preimage.is_empty() { + Some(Hash256::from_str(&payment.payment_preimage)?) + } else { + None + }; let event = CchMessage::SettleSendBTCOrder(SettleSendBTCOrderEvent { - payment_hash: format!("0x{}", payment.payment_hash), - preimage: (!payment.payment_preimage.is_empty()) - .then(|| format!("0x{}", payment.payment_preimage)), + payment_hash: Hash256::from_str(&payment.payment_hash)?, + preimage, status: lnrpc::payment::PaymentStatus::try_from(payment.status) .map(Into::into) - .unwrap_or(CchOrderStatus::InFlight), + .unwrap_or(CchOrderStatus::OutgoingInFlight), }); self.cch_actor.cast(event).map_err(Into::into) } @@ -734,7 +851,7 @@ impl LndPaymentsTracker { /// struct LndInvoiceTracker { cch_actor: ActorRef, - payment_hash: String, + payment_hash: Hash256, lnd_connection: LndConnectionInfo, token: CancellationToken, } @@ -742,7 +859,7 @@ struct LndInvoiceTracker { impl LndInvoiceTracker { fn new( cch_actor: ActorRef, - payment_hash: String, + payment_hash: Hash256, lnd_connection: LndConnectionInfo, token: CancellationToken, ) -> Self { @@ -798,11 +915,11 @@ impl LndInvoiceTracker { // TODO: clean up expired orders let mut stream = client .subscribe_single_invoice(invoicesrpc::SubscribeSingleInvoiceRequest { - r_hash: hex::decode(self.payment_hash.trim_start_matches("0x"))?, + r_hash: self.payment_hash.into(), }) .await? .into_inner(); - + tracing::debug!("Subscribed to lnd invoice: {}", self.payment_hash); loop { select! { invoice_opt = stream.next() => { @@ -828,10 +945,14 @@ impl LndInvoiceTracker { let status = lnrpc::invoice::InvoiceState::try_from(invoice.state) .map(Into::into) .unwrap_or(CchOrderStatus::Pending); + let preimage = if !invoice.r_preimage.is_empty() { + Some(Hash256::try_from(invoice.r_preimage.as_slice())?) + } else { + None + }; let event = CchMessage::SettleReceiveBTCOrder(SettleReceiveBTCOrderEvent { - payment_hash: format!("0x{}", hex::encode(invoice.r_hash)), - preimage: (!invoice.r_preimage.is_empty()) - .then(|| format!("0x{}", hex::encode(invoice.r_preimage))), + payment_hash: Hash256::try_from(invoice.r_hash.as_slice())?, + preimage, status, }); self.cch_actor.cast(event)?; @@ -839,3 +960,19 @@ impl LndInvoiceTracker { Ok(status == CchOrderStatus::Succeeded || status == CchOrderStatus::Failed) } } + +fn ensure_fiber_http_url(url_opt: Option) -> Result { + if let Some(url) = url_opt { + if url.starts_with("http://") || url.starts_with("https://") { + return Ok(url); + } + } + Err(anyhow!("fiber_rpc_url must start with http:// or https://")) +} + +fn ensure_fiber_ws_url(url_opt: Option) -> Result { + let mut url = ensure_fiber_http_url(url_opt)?; + // replace http with ws + url.replace_range(..4, "ws"); + Ok(url) +} diff --git a/crates/fiber-lib/src/cch/cch_fiber_agent.rs b/crates/fiber-lib/src/cch/cch_fiber_agent.rs new file mode 100644 index 000000000..0c7295b73 --- /dev/null +++ b/crates/fiber-lib/src/cch/cch_fiber_agent.rs @@ -0,0 +1,159 @@ +use anyhow::{anyhow, Result}; +use jsonrpsee::{ + core::client::ClientT as _, + http_client::{HttpClient, HttpClientBuilder}, + rpc_params, +}; +use ractor::{call, ActorRef}; +use std::str::FromStr as _; + +use crate::{ + fiber::{ + network::SendPaymentCommand, payment::PaymentStatus, types::Hash256, NetworkActorCommand, + NetworkActorMessage, + }, + invoice::CkbInvoice, + rpc::{ + invoice::{InvoiceResult, NewInvoiceParams, SettleInvoiceParams, SettleInvoiceResult}, + payment::{GetPaymentCommandResult, SendPaymentCommandParams}, + }, +}; + +pub enum CchFiberAgent { + InProcess { + network_actor: ActorRef, + }, + Http { + client: HttpClient, + }, +} + +impl CchFiberAgent { + pub fn try_new( + network_actor: Option>, + fiber_rpc_url: Option<&str>, + ) -> Result { + match (network_actor, fiber_rpc_url) { + (Some(network_actor), _) => Ok(CchFiberAgent::InProcess { network_actor }), + (None, Some(url)) => { + let client = HttpClientBuilder::default().build(url)?; + Ok(CchFiberAgent::Http { client }) + } + (None, None) => Err(anyhow!("require either network actor or Fiber RPC URL")), + } + } + + /// Add an invoice. + /// + /// Returns the final invoice. The returned invoice may include extra attributes such as payee + /// pubkey and signature. + pub async fn add_invoice(&self, invoice: CkbInvoice) -> Result { + match self { + Self::InProcess { network_actor } => { + // For in process agent, the invoice should have already been signed + let invoice_cloned = invoice.clone(); + let message = move |rpc_reply| -> NetworkActorMessage { + NetworkActorMessage::Command(NetworkActorCommand::AddInvoice( + invoice.clone(), + None, + rpc_reply, + )) + }; + + call!(network_actor, message).expect("call actor")?; + Ok(invoice_cloned) + } + Self::Http { client } => { + let invoice_params = NewInvoiceParams { + amount: invoice + .amount + .ok_or_else(|| anyhow!("cch invoice requires amount"))?, + payment_hash: Some(*invoice.payment_hash()), + hash_algorithm: invoice.hash_algorithm().copied(), + expiry: invoice.expiry_time().map(|duration| duration.as_secs()), + final_expiry_delta: invoice.final_tlc_minimum_expiry_delta().copied(), + udt_type_script: invoice + .udt_type_script() + .map(|script| script.clone().into()), + description: invoice.description().cloned(), + currency: invoice.currency, + payment_preimage: None, + fallback_address: invoice.fallback_address().cloned(), + allow_mpp: Some(invoice.allow_mpp()), + }; + let response = client + .request::("new_invoice", rpc_params![invoice_params]) + .await?; + // Return the signed invoice from the response + CkbInvoice::from_str(&response.invoice_address).map_err(Into::into) + } + } + } + + pub async fn send_payment(&self, pay_req: String) -> Result { + match self { + Self::InProcess { network_actor } => { + let message = |rpc_reply| -> NetworkActorMessage { + NetworkActorMessage::Command(NetworkActorCommand::SendPayment( + SendPaymentCommand { + invoice: Some(pay_req), + ..Default::default() + }, + rpc_reply, + )) + }; + + Ok(call!(network_actor, message) + .expect("call actor") + .map_err(|err| anyhow!("{}", err))? + .status) + } + Self::Http { client } => { + let payment_params = SendPaymentCommandParams { + invoice: Some(pay_req), + ..Default::default() + }; + let response = client + .request::( + "send_payment", + rpc_params![payment_params], + ) + .await?; + Ok(response.status) + } + } + } + + pub async fn settle_invoice( + &self, + payment_hash: Hash256, + payment_preimage: Hash256, + ) -> Result<()> { + match self { + Self::InProcess { network_actor } => { + let command = move |rpc_reply| -> NetworkActorMessage { + NetworkActorMessage::Command(NetworkActorCommand::SettleInvoice( + payment_hash, + payment_preimage, + rpc_reply, + )) + }; + call!(network_actor, command).expect("call actor")?; + Ok(()) + } + Self::Http { client } => { + let settle_invoice_params = SettleInvoiceParams { + payment_hash, + payment_preimage, + }; + client + .request::( + "settle_invoice", + rpc_params![settle_invoice_params], + ) + .await?; + Ok(()) + } + } + } +} diff --git a/crates/fiber-lib/src/cch/config.rs b/crates/fiber-lib/src/cch/config.rs index 2a2da731a..a3e11a275 100644 --- a/crates/fiber-lib/src/cch/config.rs +++ b/crates/fiber-lib/src/cch/config.rs @@ -1,6 +1,10 @@ use std::path::PathBuf; +use anyhow::Context; use clap_serde_derive::ClapSerde; +use lnd_grpc_tonic_client::Uri; + +use super::actor::LndConnectionInfo; /// Default cross-chain order expiry time in seconds. pub const DEFAULT_ORDER_EXPIRY_TIME: u64 = 3600; @@ -30,30 +34,46 @@ pub struct CchConfig { )] pub lnd_rpc_url: String, + #[arg( + name = "CCH_LND_CERT_HEX", + long = "cch-lnd-cert-hex", + env, + help = "Hex encoded TLS cert for the grpc connection (will be preferred over cch-lnd-cert-path). Leave it empty to use wellknown CA certificates like Let's Encrypt." + )] + pub lnd_cert_hex: Option, + #[arg( name = "CCH_LND_CERT_PATH", long = "cch-lnd-cert-path", env, - help = "Path to the TLS cert file for the grpc connection. Leave it empty to use wellknown CA certificates like Let's Encrypt." + help = "Path to the TLS cert file for the grpc connection (will be ignored if cch-lnd-cert-hex is also set). Leave it empty to use wellknown CA certificates like Let's Encrypt." )] pub lnd_cert_path: Option, + #[arg( + name = "CCH_LND_MACAROON_HEX", + long = "cch-lnd-macaroon-hex", + env, + help = "Hex encoded Macaroon for the grpc connection (will be preferred over cch-lnd-macaroon-path)" + )] + pub lnd_macaroon_hex: Option, + #[arg( name = "CCH_LND_MACAROON_PATH", long = "cch-lnd-macaroon-path", env, - help = "Path to the Macaroon file for the grpc connection" + help = "Path to the Macaroon file for the grpc connection (will be ignored if cch-lnd-macaroon-hex is also set)" )] pub lnd_macaroon_path: Option, - // TODO: use hex type #[arg( - name = "CCH_WRAPPED_BTC_TYPE_SCRIPT_ARGS", - long = "cch-wrapped-btc-type-script-args", + name = "CCH_WRAPPED_BTC_TYPE_SCRIPT", + long = "cch-wrapped-btc-type-script", env, - help = "Wrapped BTC type script args. It must be a UDT with 8 decimal places." + value_parser = parse_script_from_str, + help = "Wrapped BTC type script." )] - pub wrapped_btc_type_script_args: String, + pub wrapped_btc_type_script: ckb_jsonrpc_types::Script, /// Cross-chain order expiry time in seconds. #[default(DEFAULT_ORDER_EXPIRY_TIME)] @@ -107,11 +127,34 @@ pub struct CchConfig { #[default(false)] #[arg(skip)] pub ignore_startup_failure: bool, + + #[default(None)] + #[arg( + name = "CCH_FIBER_RPC_URL", + long = "cch-fiber-rpc-url", + env, + help = "fiber endpoint, default is None. May be used to connect to an external fiber node with websocket and normal http jsonrpc support. The address format should be in the format http[s]://:, if http is specified, the websocket connection will be ws://:, if https is specified, the websocket connection will be wss://:" + )] + pub fiber_rpc_url: Option, } impl CchConfig { - pub fn resolve_lnd_cert_path(&self) -> Option { - self.lnd_cert_path.as_ref().map(|lnd_cert_path| { + pub async fn get_lnd_tlc_cert(&self) -> Result>, anyhow::Error> { + if let Some(cert_hex) = self.lnd_cert_hex.as_deref() { + return Ok(Some(hex::decode(cert_hex).with_context(|| { + format!("decode hex encoded cert {}", cert_hex) + })?)); + } + if let Some(cert_path) = self.resolve_lnd_cert_path() { + return Ok(Some(tokio::fs::read(&cert_path).await.with_context( + || format!("read cert file {}", cert_path.display()), + )?)); + } + Ok(None) + } + + fn resolve_lnd_cert_path(&self) -> Option { + self.lnd_cert_path.as_deref().map(|lnd_cert_path| { let path = PathBuf::from(lnd_cert_path); match (self.base_dir.clone(), path.is_relative()) { (Some(base_dir), true) => base_dir.join(path), @@ -120,8 +163,22 @@ impl CchConfig { }) } - pub fn resolve_lnd_macaroon_path(&self) -> Option { - self.lnd_macaroon_path.as_ref().map(|lnd_macaroon_path| { + pub async fn get_lnd_macaroon(&self) -> Result>, anyhow::Error> { + if let Some(macaroon_hex) = self.lnd_macaroon_hex.as_deref() { + return Ok(Some(hex::decode(macaroon_hex).with_context(|| { + format!("decode hex encoded macaroon {}", macaroon_hex) + })?)); + } + if let Some(macaroon_path) = self.resolve_lnd_macaroon_path() { + return Ok(Some(tokio::fs::read(&macaroon_path).await.with_context( + || format!("read macaroon file {}", macaroon_path.display()), + )?)); + } + Ok(None) + } + + fn resolve_lnd_macaroon_path(&self) -> Option { + self.lnd_macaroon_path.as_deref().map(|lnd_macaroon_path| { let path = PathBuf::from(lnd_macaroon_path); match (self.base_dir.clone(), path.is_relative()) { (Some(base_dir), true) => base_dir.join(path), @@ -129,4 +186,23 @@ impl CchConfig { } }) } + + pub fn get_wrapped_btc_script(&self) -> ckb_types::packed::Script { + ckb_types::packed::Script::from(self.wrapped_btc_type_script.clone()) + } + + pub(crate) async fn get_lnd_connection_info(&self) -> Result { + let lnd_rpc_url: Uri = self.lnd_rpc_url.clone().try_into()?; + let cert = self.get_lnd_tlc_cert().await?; + let macaroon = self.get_lnd_macaroon().await?; + Ok(LndConnectionInfo { + uri: lnd_rpc_url, + cert, + macaroon, + }) + } +} + +fn parse_script_from_str(s: &str) -> Result { + serde_json::from_str(s).map_err(Into::into) } diff --git a/crates/fiber-lib/src/cch/error.rs b/crates/fiber-lib/src/cch/error.rs index c86d8c808..5de63fcb7 100644 --- a/crates/fiber-lib/src/cch/error.rs +++ b/crates/fiber-lib/src/cch/error.rs @@ -1,21 +1,21 @@ -use crate::time::SystemTimeError; +use crate::{fiber::types::Hash256, invoice::SettleInvoiceError, time::SystemTimeError}; use jsonrpsee::types::{error::CALL_EXECUTION_FAILED_CODE, ErrorObjectOwned}; use thiserror::Error; #[derive(Error, Debug)] -pub enum CchDbError { +pub enum CchStoreError { #[error("Inserting duplicated key: {0}")] - Duplicated(String), + Duplicated(Hash256), #[error("Key not found: {0}")] - NotFound(String), + NotFound(Hash256), } #[derive(Error, Debug)] pub enum CchError { - #[error("Database error: {0}")] - DbError(#[from] CchDbError), + #[error("Store error: {0}")] + StoreError(#[from] CchStoreError), #[error("BTC invoice parse error: {0}")] BTCInvoiceParseError(#[from] lightning_invoice::ParseOrSemanticError), #[error("BTC invoice expired")] @@ -24,6 +24,12 @@ pub enum CchError { BTCInvoiceMissingAmount, #[error("CKB invoice error: {0}")] CKBInvoiceError(#[from] crate::invoice::InvoiceError), + #[error("CKB invoice missing amount")] + CKBInvoiceMissingAmount, + #[error("Fail to settle CKB invoice: {0}")] + CKBSettleInvoiceError(#[from] SettleInvoiceError), + #[error("Fiber node error: {0}")] + FiberNodeError(anyhow::Error), #[error("SendBTC order already paid")] SendBTCOrderAlreadyPaid, #[error("SendBTC received payment amount is too small")] diff --git a/crates/fiber-lib/src/cch/mod.rs b/crates/fiber-lib/src/cch/mod.rs index 4f0733dd9..4587fad26 100644 --- a/crates/fiber-lib/src/cch/mod.rs +++ b/crates/fiber-lib/src/cch/mod.rs @@ -1,8 +1,8 @@ mod actor; -pub use actor::{start_cch, CchActor, CchMessage, ReceiveBTC, SendBTC}; +pub use actor::{start_cch, CchActor, CchArgs, CchMessage, ReceiveBTC, SendBTC}; mod error; -pub use error::{CchError, CchResult}; +pub use error::{CchError, CchResult, CchStoreError}; mod config; pub use config::{ @@ -11,7 +11,14 @@ pub use config::{ }; mod order; -pub use order::{CchOrderStatus, ReceiveBTCOrder, SendBTCOrder}; +pub use order::{CchInvoice, CchOrder, CchOrderStatus}; -mod orders_db; -pub use orders_db::CchOrdersDb; +mod order_store; +pub use order_store::{CchOrderStore, CchOrderStoreDeref}; + +mod order_guard; + +mod cch_fiber_agent; + +#[cfg(any(test, feature = "bench"))] +pub mod tests; diff --git a/crates/fiber-lib/src/cch/order.rs b/crates/fiber-lib/src/cch/order.rs index 17c57c877..53a53616a 100644 --- a/crates/fiber-lib/src/cch/order.rs +++ b/crates/fiber-lib/src/cch/order.rs @@ -1,15 +1,14 @@ -use super::CchError; +use lightning_invoice::Bolt11Invoice; use lnd_grpc_tonic_client::lnrpc; use serde::{Deserialize, Serialize}; -use serde_with::serde_as; -use std::{str::FromStr as _, time::Duration}; +use serde_with::{serde_as, DisplayFromStr}; use crate::{ fiber::{ serde_utils::{U128Hex, U64Hex}, types::Hash256, }, - invoice::{Currency, InvoiceBuilder}, + invoice::CkbInvoice, }; /// The status of a cross-chain hub order, will update as the order progresses. @@ -18,35 +17,53 @@ use crate::{ pub enum CchOrderStatus { /// Order is created and has not send out payments yet. Pending = 0, - /// HTLC in the first half is accepted. - Accepted = 1, - /// There's an outgoing payment in flight for the second half. - InFlight = 2, - /// Order is settled. - Succeeded = 3, + /// HTLC in the incoming payment is accepted. + IncomingAccepted = 1, + /// There's an outgoing payment in flight. + OutgoingInFlight = 2, + /// The outgoing payment is settled. + OutgoingSettled = 3, + /// Both payments are settled and the order succeeds. + Succeeded = 4, /// Order is failed. - Failed = 4, + Failed = 5, } -/// lnd payment is the second half of SendBTCOrder +impl CchOrderStatus { + /// An active order is neither succeeded nor failed. + /// + /// Active orders require further actions. + pub fn is_active(&self) -> bool { + !self.is_inactive() + } + + /// An inactive order is either succeeded or failed. + /// + /// Inactive orders do not require further actions. + pub fn is_inactive(&self) -> bool { + matches!(self, CchOrderStatus::Succeeded | CchOrderStatus::Failed) + } +} + +/// lnd payment is the outgoing part of SendBTCOrder impl From for CchOrderStatus { fn from(status: lnrpc::payment::PaymentStatus) -> Self { use lnrpc::payment::PaymentStatus; match status { - PaymentStatus::Succeeded => CchOrderStatus::Succeeded, + PaymentStatus::Succeeded => CchOrderStatus::OutgoingSettled, PaymentStatus::Failed => CchOrderStatus::Failed, - _ => CchOrderStatus::InFlight, + _ => CchOrderStatus::OutgoingInFlight, } } } -/// lnd invoice is the first half of ReceiveBTCOrder +/// lnd invoice is the incoming part of ReceiveBTCOrder impl From for CchOrderStatus { fn from(state: lnrpc::invoice::InvoiceState) -> Self { use lnrpc::invoice::InvoiceState; // Set to InFlight only when a CKB HTLC is created match state { - InvoiceState::Accepted => CchOrderStatus::Accepted, + InvoiceState::Accepted => CchOrderStatus::IncomingAccepted, InvoiceState::Canceled => CchOrderStatus::Failed, InvoiceState::Settled => CchOrderStatus::Succeeded, _ => CchOrderStatus::Pending, @@ -54,61 +71,25 @@ impl From for CchOrderStatus { } } +/// The generated proxy invoice for the incoming payment. +/// +/// The JSON representation: +/// +/// ```text +/// { "Fiber": String } | { "Lightning": String } +/// ``` #[serde_as] #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SendBTCOrder { - // Seconds since epoch when the order is created - #[serde_as(as = "U64Hex")] - pub created_at: u64, - // Seconds after timestamp that the order expires - #[serde_as(as = "U64Hex")] - pub expires_after: u64, - // The minimal expiry delta in milliseconds of the final TLC hop in the CKB network - #[serde_as(as = "U64Hex")] - pub ckb_final_tlc_expiry_delta: u64, - - pub currency: Currency, - pub wrapped_btc_type_script: ckb_jsonrpc_types::Script, - - pub btc_pay_req: String, - pub ckb_pay_req: String, - pub payment_hash: String, - pub payment_preimage: Option, - pub channel_id: Option, - #[serde_as(as = "Option")] - pub tlc_id: Option, - - #[serde_as(as = "U128Hex")] - /// Amount required to pay in Satoshis via wrapped BTC, including the fee for the cross-chain hub - pub amount_sats: u128, - #[serde_as(as = "U128Hex")] - pub fee_sats: u128, - - pub status: CchOrderStatus, -} - -impl SendBTCOrder { - pub fn generate_ckb_invoice(&mut self) -> Result<(), CchError> { - let invoice_builder = InvoiceBuilder::new(self.currency) - .amount(Some(self.amount_sats)) - .payment_hash( - Hash256::from_str(&self.payment_hash) - .map_err(|_| CchError::HexDecodingError(self.payment_hash.clone()))?, - ) - .expiry_time(Duration::from_secs(self.expires_after)) - .final_expiry_delta(self.ckb_final_tlc_expiry_delta) - .udt_type_script(self.wrapped_btc_type_script.clone().into()); - - let invoice = invoice_builder.build()?; - self.ckb_pay_req = invoice.to_string(); - - Ok(()) - } +pub enum CchInvoice { + /// Fiber invoice that once paid, the hub will send the outgoing payment to Lightning + Fiber(#[serde_as(as = "DisplayFromStr")] CkbInvoice), + /// Lightning invoice that once paid, the hub will send the outgoing payment to Fiber + Lightning(#[serde_as(as = "DisplayFromStr")] Bolt11Invoice), } #[serde_as] #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReceiveBTCOrder { +pub struct CchOrder { // Seconds since epoch when the order is created #[serde_as(as = "U64Hex")] pub created_at: u64, @@ -121,14 +102,12 @@ pub struct ReceiveBTCOrder { pub wrapped_btc_type_script: ckb_jsonrpc_types::Script, - pub btc_pay_req: String, - pub payment_hash: String, - pub payment_preimage: Option, - pub channel_id: Hash256, - #[serde_as(as = "Option")] - pub tlc_id: Option, + pub outgoing_pay_req: String, + pub incoming_invoice: CchInvoice, + pub payment_hash: Hash256, + pub payment_preimage: Option, - /// Amount required to pay in Satoshis via BTC, including the fee for the cross-chain hub + /// Amount required to pay in Satoshis via BTC or wrapped BTC, including the fee for the cross-chain hub #[serde_as(as = "U128Hex")] pub amount_sats: u128, #[serde_as(as = "U128Hex")] @@ -136,3 +115,9 @@ pub struct ReceiveBTCOrder { pub status: CchOrderStatus, } + +impl CchOrder { + pub fn is_from_fiber_to_lightning(&self) -> bool { + matches!(self.incoming_invoice, CchInvoice::Fiber(_)) + } +} diff --git a/crates/fiber-lib/src/cch/order_guard.rs b/crates/fiber-lib/src/cch/order_guard.rs new file mode 100644 index 000000000..f6d673845 --- /dev/null +++ b/crates/fiber-lib/src/cch/order_guard.rs @@ -0,0 +1,187 @@ +//! Order guard takes care of the order lifecycle. + +use std::{ + collections::BinaryHeap, + marker::PhantomData, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use ractor::{Actor, ActorProcessingErr, ActorRef, DerivedActorRef}; + +use crate::{ + cch::{CchOrder, CchOrderStore}, + fiber::types::Hash256, +}; + +pub struct CchOrderGuardActor(PhantomData); + +impl Default for CchOrderGuardActor { + fn default() -> Self { + Self(PhantomData) + } +} + +/// The events that CchOrderGuardActor can trigger +pub enum CchOrderGuardEvent { + OrderLoaded(CchOrder), +} + +pub struct CchOrderGuardArgs { + pub watcher: DerivedActorRef, + /// Number of seconds that an inactive order that should be deleted from the database. The time + /// counts from the expire time, a.k.a, order created time plus expires ttl. + pub purge_ttl: u64, + pub store: S, +} + +pub struct CchOrderPurgeItem { + payment_hash: Hash256, + purge_at: u64, +} + +impl Ord for CchOrderPurgeItem { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + // Reverse the order to make the BinaryHeap a min-heap based on purge_at + other.purge_at.cmp(&self.purge_at) + } +} +impl PartialOrd for CchOrderPurgeItem { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Eq for CchOrderPurgeItem {} +impl PartialEq for CchOrderPurgeItem { + fn eq(&self, other: &Self) -> bool { + self.purge_at == other.purge_at + } +} + +pub struct CchOrderGuardState { + watcher: DerivedActorRef, + /// Number of seconds that an inactive order that should be deleted from the database. The time + /// counts from the expire time, a.k.a, order created time plus expires ttl. + purge_ttl: u64, + store: S, + purge_heap: BinaryHeap, + interval_purging_started: bool, +} + +pub enum CchOrderGuardMessage { + /// The order is Succeeded or Failed, and should be purged later. + DeactivateOrder { + payment_hash: Hash256, + // order.created_at + order.expires_after + expires_at: u64, + }, + /// Load orders from store + LoadOrders, + /// Tick to check whether need to purge orders + PurgeOrders, +} + +#[async_trait::async_trait] +impl Actor for CchOrderGuardActor { + type Msg = CchOrderGuardMessage; + type State = CchOrderGuardState; + type Arguments = CchOrderGuardArgs; + + async fn pre_start( + &self, + myself: ActorRef, + args: Self::Arguments, + ) -> Result { + myself.send_message(CchOrderGuardMessage::LoadOrders)?; + Ok(Self::State { + watcher: args.watcher, + purge_ttl: args.purge_ttl, + store: args.store, + purge_heap: Default::default(), + interval_purging_started: false, + }) + } + + async fn handle( + &self, + myself: ActorRef, + message: Self::Msg, + state: &mut Self::State, + ) -> Result<(), ActorProcessingErr> { + state.handle(myself, message).await + } +} + +impl CchOrderGuardState { + async fn handle( + &mut self, + myself: ActorRef, + message: CchOrderGuardMessage, + ) -> Result<(), ActorProcessingErr> { + match message { + CchOrderGuardMessage::LoadOrders => { + self.load_orders().await?; + // Purge orders every hour + if !self.interval_purging_started { + self.interval_purging_started = true; + myself.send_interval(Duration::from_secs(60 * 60), || { + CchOrderGuardMessage::PurgeOrders + }); + } + Ok(()) + } + CchOrderGuardMessage::DeactivateOrder { + payment_hash, + expires_at, + } => { + self.deactivate_order(payment_hash, expires_at).await; + Ok(()) + } + CchOrderGuardMessage::PurgeOrders => self.purge_orders().await, + } + } + + async fn load_orders(&mut self) -> Result<(), ActorProcessingErr> { + // Assume that all order keys can be loaded into memory. + let keys: Vec<_> = self.store.get_cch_order_keys_iter().into_iter().collect(); + for key in keys.into_iter() { + // Ignore not found orders + if let Ok(order) = self.store.get_cch_order(&key) { + if order.status.is_active() { + self.watcher + .send_message(CchOrderGuardEvent::OrderLoaded(order))?; + } else { + self.deactivate_order( + order.payment_hash, + order.created_at + order.expires_after, + ) + .await; + } + } + } + Ok(()) + } + + async fn deactivate_order(&mut self, payment_hash: Hash256, expires_at: u64) { + self.purge_heap.push(CchOrderPurgeItem { + payment_hash, + purge_at: expires_at + self.purge_ttl, + }); + } + + async fn purge_orders(&mut self) -> Result<(), ActorProcessingErr> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + // get current time + while let Some(payment_hash) = self + .purge_heap + .peek() + .and_then(|item| (item.purge_at <= now).then_some(item.payment_hash)) + { + self.store.delete_cch_order(&payment_hash); + self.purge_heap.pop(); + } + Ok(()) + } +} diff --git a/crates/fiber-lib/src/cch/order_store.rs b/crates/fiber-lib/src/cch/order_store.rs new file mode 100644 index 000000000..afb9f4faf --- /dev/null +++ b/crates/fiber-lib/src/cch/order_store.rs @@ -0,0 +1,53 @@ +use crate::{cch::error::CchStoreError, fiber::types::Hash256}; + +use super::CchOrder; + +pub trait CchOrderStore { + /// Gets an order from the store. + /// + /// Returns the order if found, a NotFound error otherwise. + fn get_cch_order(&self, payment_hash: &Hash256) -> Result; + + /// Inserts a new order. + /// + /// Returns an error if an order with the same payment hash already exist in the database. + fn insert_cch_order(&self, order: CchOrder) -> Result<(), CchStoreError>; + + /// Inserts or updates an order. + fn update_cch_order(&self, order: CchOrder); + + /// Deletes an order by payment hash. + fn delete_cch_order(&self, payment_hash: &Hash256); + + /// Get an iterator to list keys of all orders. + fn get_cch_order_keys_iter(&self) -> impl IntoIterator; +} + +/// Used for delegating the store trait +pub trait CchOrderStoreDeref { + type Target: CchOrderStore; + fn cch_order_store_deref(&self) -> &Self::Target; +} + +impl CchOrderStore for T { + fn get_cch_order(&self, payment_hash: &Hash256) -> Result { + self.cch_order_store_deref().get_cch_order(payment_hash) + } + + fn insert_cch_order(&self, order: CchOrder) -> Result<(), CchStoreError> { + self.cch_order_store_deref().insert_cch_order(order) + } + + fn update_cch_order(&self, order: CchOrder) { + self.cch_order_store_deref().update_cch_order(order); + } + + /// Get an iterator to list keys of all orders. + fn get_cch_order_keys_iter(&self) -> impl IntoIterator { + self.cch_order_store_deref().get_cch_order_keys_iter() + } + + fn delete_cch_order(&self, payment_hash: &Hash256) { + self.cch_order_store_deref().delete_cch_order(payment_hash); + } +} diff --git a/crates/fiber-lib/src/cch/orders_db.rs b/crates/fiber-lib/src/cch/orders_db.rs deleted file mode 100644 index c6573f04f..000000000 --- a/crates/fiber-lib/src/cch/orders_db.rs +++ /dev/null @@ -1,71 +0,0 @@ -use std::collections::HashMap; - -use super::{error::CchDbError, ReceiveBTCOrder, SendBTCOrder}; - -// TODO: persist orders -#[derive(Default)] -pub struct CchOrdersDb { - /// SendBTCOrder map by payment hash - send_btc_orders: HashMap, - receive_btc_orders: HashMap, -} - -impl CchOrdersDb { - pub async fn insert_send_btc_order(&mut self, order: SendBTCOrder) -> Result<(), CchDbError> { - let key = order.payment_hash.clone(); - match self.send_btc_orders.insert(key.clone(), order) { - Some(_) => Err(CchDbError::Duplicated(key)), - None => Ok(()), - } - } - - pub async fn get_send_btc_order( - &mut self, - payment_hash: &str, - ) -> Result { - self.send_btc_orders - .get(payment_hash) - .ok_or_else(|| CchDbError::NotFound(payment_hash.to_string())) - .cloned() - } - - pub async fn update_send_btc_order(&mut self, order: SendBTCOrder) -> Result<(), CchDbError> { - let key = order.payment_hash.clone(); - match self.send_btc_orders.insert(key.clone(), order) { - Some(_) => Ok(()), - None => Err(CchDbError::NotFound(key)), - } - } - - pub async fn insert_receive_btc_order( - &mut self, - order: ReceiveBTCOrder, - ) -> Result<(), CchDbError> { - let key = order.payment_hash.clone(); - match self.receive_btc_orders.insert(key.clone(), order) { - Some(_) => Err(CchDbError::Duplicated(key)), - None => Ok(()), - } - } - - pub async fn get_receive_btc_order( - &mut self, - payment_hash: &str, - ) -> Result { - self.receive_btc_orders - .get(payment_hash) - .ok_or_else(|| CchDbError::NotFound(payment_hash.to_string())) - .cloned() - } - - pub async fn update_receive_btc_order( - &mut self, - order: ReceiveBTCOrder, - ) -> Result<(), CchDbError> { - let key = order.payment_hash.clone(); - match self.receive_btc_orders.insert(key.clone(), order) { - Some(_) => Ok(()), - None => Err(CchDbError::NotFound(key)), - } - } -} diff --git a/crates/fiber-lib/src/cch/tests/lnd_test_utils.rs b/crates/fiber-lib/src/cch/tests/lnd_test_utils.rs new file mode 100644 index 000000000..21505a9da --- /dev/null +++ b/crates/fiber-lib/src/cch/tests/lnd_test_utils.rs @@ -0,0 +1,364 @@ +use std::time::Duration; +use std::{str::FromStr as _, sync::Arc}; + +use futures::StreamExt; +use lightning_invoice::Bolt11Invoice; +use lnd::{ + self, + bitcoind::{ + self, + bitcoincore_rpc::{ + bitcoin::{address::NetworkChecked, Address}, + RpcApi, + }, + BitcoinD, + }, + tonic_lnd::lnrpc::{GetInfoRequest, GetInfoResponse}, + Lnd, LndConf, +}; +use tokio::select; + +use crate::cch::actor::LndConnectionInfo; + +fn get_bitcoind_exe_path() -> String { + bitcoind::exe_path().expect("bitcoind executable does not exist. See https://docs.rs/bitcoind/0.34.3/bitcoind/fn.exe_path.html for how the bitcoind executable is searched") +} + +fn get_lnd_exe_path() -> String { + lnd::exe_path().expect("lnd executable does not exist. See https://docs.rs/lnd/0.1.6/lnd/fn.exe_path.html for how the lnd executable is searched") +} + +pub enum LndBitcoinDConf<'a> { + New(Option>), + Existing(Arc), +} + +impl Default for LndBitcoinDConf<'_> { + fn default() -> Self { + Self::New(None) + } +} + +pub struct LndNode { + pub lnd: Lnd, + pub bitcoind: Arc, + pub address: Address, +} + +impl LndNode { + pub async fn new(lnd_conf: Option>, bitcoind_conf: LndBitcoinDConf<'_>) -> Self { + let bitcoind = match bitcoind_conf { + LndBitcoinDConf::New(conf) => { + let conf = conf.unwrap_or_default(); + let bitcoind = + BitcoinD::with_conf(get_bitcoind_exe_path(), &conf).expect("run bitcoind"); + Arc::new(bitcoind) + } + LndBitcoinDConf::Existing(bitcoind) => bitcoind, + }; + let cookie = bitcoind + .params + .cookie_file + .to_str() + .expect("get bitcoind cookie"); + let rpc_socket = bitcoind.params.rpc_socket.to_string(); + + let lnd_exe = get_lnd_exe_path(); + let lnd_conf = lnd_conf.unwrap_or_default(); + let mut lnd = Lnd::with_conf( + &lnd_exe, + &lnd_conf, + cookie.to_string(), + rpc_socket, + bitcoind.as_ref(), + ) + .await + .unwrap(); + + use lnd::tonic_lnd::lnrpc::{AddressType, NewAddressRequest}; + let mut request = NewAddressRequest::default(); + request.set_type(AddressType::TaprootPubkey); + let client = lnd.client.lightning(); + for _ in 1..=10 { + match client.new_address(request.clone()).await { + Ok(response) => { + return Self { + lnd, + bitcoind, + address: Address::from_str(&response.into_inner().address) + .expect("valid address") + .assume_checked(), + }; + } + Err(e) + if e.message() + .contains("the RPC server is in the process of starting up, but not yet ready to accept calls") => + { + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + } + Err(e) => panic!("new address failed: {}", e), + } + } + panic!("Creating new address failed, server is still starting"); + } + + pub async fn new_lnd_with_the_same_bitcoind(&self, lnd_conf: Option>) -> Self { + Self::new(lnd_conf, LndBitcoinDConf::Existing(self.bitcoind.clone())).await + } + + pub async fn new_two_nodes_with_established_channel( + bitcoind_conf: Option>, + lnd_conf1: Option>, + lnd_conf2: Option>, + ) -> (Self, Self, lnd::tonic_lnd::lnrpc::ChannelPoint) { + let mut lnd1 = Self::new(lnd_conf1, LndBitcoinDConf::New(bitcoind_conf)).await; + lnd1.make_some_money(); + let mut lnd2 = lnd1.new_lnd_with_the_same_bitcoind(lnd_conf2).await; + lnd2.make_some_money(); + + let channel = lnd1.open_channel_with(&mut lnd2).await; + + (lnd1, lnd2, channel) + } + + pub fn get_lnd_connection_info(&self) -> LndConnectionInfo { + LndConnectionInfo { + uri: self.lnd.grpc_url.clone().try_into().expect("valid uri"), + cert: hex::decode(&self.lnd.tls_cert).ok(), + macaroon: hex::decode(&self.lnd.admin_macaroon).ok(), + } + } + + pub async fn get_info(&mut self) -> GetInfoResponse { + self.lnd + .client + .lightning() + .get_info(GetInfoRequest::default()) + .await + .expect("get node info") + .into_inner() + } + + pub fn make_some_money(&self) { + self.bitcoind + .client + .generate_to_address(100, &self.address) + .expect("Blocks generated to address."); + } + + pub async fn connect(&mut self, other: &mut Self) { + let other_info = other.get_info().await; + let address = lnd::tonic_lnd::lnrpc::LightningAddress { + pubkey: other_info.identity_pubkey, + host: other + .lnd + .listen_url + .clone() + .expect("have listening address"), + }; + let request = lnd::tonic_lnd::lnrpc::ConnectPeerRequest { + addr: Some(address), + perm: false, + ..Default::default() + }; + for _ in 1..=10 { + let response = self + .lnd + .client + .lightning() + .connect_peer(request.clone()) + .await; + match response { + Ok(_) => return, + Err(e) if e.message().contains("already connected to peer") => return, + Err(e) + if e.message() + .contains("server is still in the process of starting") => + { + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + } + Err(e) => panic!("connect peer failed: {}", e), + } + } + panic!("Connect peer failed after 5 retries, server is still starting"); + } + + pub async fn wait_synced_to_chain(&mut self) { + for _ in 0..100 { + let info = self.get_info().await; + if info.synced_to_chain { + return; + } + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + } + panic!("not synced to chain"); + } + + pub async fn get_active_channels(&mut self) -> Vec { + self.lnd + .client + .lightning() + .list_channels(lnd::tonic_lnd::lnrpc::ListChannelsRequest { + active_only: true, + ..Default::default() + }) + .await + .expect("get channel info") + .into_inner() + .channels + } + + pub async fn wait_for_channel_to_be_active( + &mut self, + channel_outpoint: &lnd::tonic_lnd::lnrpc::ChannelPoint, + ) { + let channel_outpoint_str = channel_outpoint_to_string(channel_outpoint); + for _ in 1..=10 { + let channels = self.get_active_channels().await; + for channel in channels { + if channel.channel_point == channel_outpoint_str { + return; + } + } + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + } + + panic!( + "Opened channel but failed to wait for confirmation: {:?}", + channel_outpoint + ); + } + + /// Open channel with another node. Returns the channel point. + /// self will have some money in the channel, while other will have none. + pub async fn open_channel_with( + &mut self, + other: &mut Self, + ) -> lnd::tonic_lnd::lnrpc::ChannelPoint { + self.connect(other).await; + + // We need to wait for the nodes to be synced to the chain before opening a channel. + // This is a requirement of LND. + self.wait_synced_to_chain().await; + other.wait_synced_to_chain().await; + + let other_info = other.get_info().await; + let request = lnd::tonic_lnd::lnrpc::OpenChannelRequest { + node_pubkey: hex::decode(other_info.identity_pubkey).expect("valid pubkey hex"), + local_funding_amount: 1_000_000, + sat_per_vbyte: 1, + min_confs: 0, + ..Default::default() + }; + + let channel = self + .lnd + .client + .lightning() + .open_channel_sync(request.clone()) + .await + .expect("open channel") + .into_inner(); + + // Confirm that the channel is now active. + self.make_some_money(); + self.wait_synced_to_chain().await; + other.wait_synced_to_chain().await; + self.wait_for_channel_to_be_active(&channel).await; + other.wait_for_channel_to_be_active(&channel).await; + return channel; + } + + pub async fn add_invoice( + &mut self, + value_msat: u64, + ) -> lnd::tonic_lnd::lnrpc::AddInvoiceResponse { + let request = lnd::tonic_lnd::lnrpc::Invoice { + value_msat: value_msat as i64, + ..Default::default() + }; + self.lnd + .client + .lightning() + .add_invoice(request) + .await + .expect("add hold invoice") + .into_inner() + } + + pub async fn send_payment(&self, invoice: &Bolt11Invoice) { + let lnd_connection = self.get_lnd_connection_info(); + let mut lnd_client = lnd_connection + .create_router_client() + .await + .expect("create lnd client"); + let timeout_seconds = 10; + let request = lnd_grpc_tonic_client::routerrpc::SendPaymentRequest { + payment_request: invoice.to_string(), + timeout_seconds, + ..Default::default() + }; + let mut stream = lnd_client + .send_payment_v2(request) + .await + .expect("call send_payment_v2") + .into_inner(); + let mut ticker = tokio::time::interval(Duration::from_secs(timeout_seconds as u64)); + loop { + select! { + Some(payment) = stream.next() => { + let payment = payment.expect("payment"); + use lnd_grpc_tonic_client::lnrpc::payment::PaymentStatus; + if PaymentStatus::InFlight == payment.status.try_into().expect("payment status") { + return; + } + } + _ = ticker.tick() => { + panic!("payment failed"); + } + } + } + } + + pub async fn get_balance_sats(&mut self) -> u64 { + let response = self + .lnd + .client + .lightning() + .channel_balance(lnd::tonic_lnd::lnrpc::ChannelBalanceRequest::default()) + .await + .expect("get wallet balance") + .into_inner(); + response.local_balance.expect("local balance exists").sat + } + + pub async fn get_balance_msats(&mut self) -> u64 { + let response = self + .lnd + .client + .lightning() + .channel_balance(lnd::tonic_lnd::lnrpc::ChannelBalanceRequest::default()) + .await + .expect("get wallet balance") + .into_inner(); + response.local_balance.expect("local balance exists").msat + } +} + +fn channel_outpoint_to_string(channel_outpoint: &lnd::tonic_lnd::lnrpc::ChannelPoint) -> String { + let output_index = channel_outpoint.output_index; + let funding_txid = match channel_outpoint + .funding_txid + .clone() + .expect("funding_txid exists") + { + lnd::tonic_lnd::lnrpc::channel_point::FundingTxid::FundingTxidBytes(mut bytes) => { + // Don't know why the bytes returned from open_channel_sync are reversed with + // the order of the channel_point string returned from list_channels. + bytes.reverse(); + hex::encode(bytes) + } + lnd::tonic_lnd::lnrpc::channel_point::FundingTxid::FundingTxidStr(str) => str, + }; + format!("{}:{}", funding_txid, output_index) +} diff --git a/crates/fiber-lib/src/cch/tests/lnd_tests.rs b/crates/fiber-lib/src/cch/tests/lnd_tests.rs new file mode 100644 index 000000000..ffd163bf8 --- /dev/null +++ b/crates/fiber-lib/src/cch/tests/lnd_tests.rs @@ -0,0 +1,33 @@ +use crate::cch::tests::lnd_test_utils::LndNode; + +#[cfg_attr(not(feature = "lnd-tests"), ignore)] +#[tokio::test] +async fn test_run_lnd_one_node() { + let mut lnd = LndNode::new(Default::default(), Default::default()).await; + println!("node_info: {:?}", lnd.get_info().await); +} + +#[cfg_attr(not(feature = "lnd-tests"), ignore)] +#[tokio::test] +async fn test_run_lnd_two_nodes_with_the_same_bitcoind() { + let mut lnd = LndNode::new(Default::default(), Default::default()).await; + println!("lnd 1 node_info: {:?}", lnd.get_info().await); + + let mut lnd2 = lnd.new_lnd_with_the_same_bitcoind(Default::default()).await; + // The second node should be able to run independently of the first node. + drop(lnd); + + println!("lnd 2 node_info: {:?}", lnd2.get_info().await); +} + +#[cfg_attr(not(feature = "lnd-tests"), ignore)] +#[tokio::test] +async fn test_run_lnd_two_nodes_with_established_channel() { + let (_lnd1, _lnd2, channel) = LndNode::new_two_nodes_with_established_channel( + Default::default(), + Default::default(), + Default::default(), + ) + .await; + println!("channel: {:?}", channel); +} diff --git a/crates/fiber-lib/src/cch/tests/mod.rs b/crates/fiber-lib/src/cch/tests/mod.rs new file mode 100644 index 000000000..d233273fb --- /dev/null +++ b/crates/fiber-lib/src/cch/tests/mod.rs @@ -0,0 +1,7 @@ +#[cfg(not(target_arch = "wasm32"))] +pub mod lnd_test_utils; + +#[cfg(all(not(target_arch = "wasm32"), test))] +mod lnd_tests; +#[cfg(test)] +mod payment; diff --git a/crates/fiber-lib/src/cch/tests/payment.rs b/crates/fiber-lib/src/cch/tests/payment.rs new file mode 100644 index 000000000..a3e5d6f70 --- /dev/null +++ b/crates/fiber-lib/src/cch/tests/payment.rs @@ -0,0 +1,382 @@ +use std::time::Duration; + +use bitcoin::hashes::Hash; +use ckb_types::packed::Script; +use ractor::call_t; + +use crate::{ + cch::{ + tests::lnd_test_utils::{LndBitcoinDConf, LndNode}, + CchInvoice, CchMessage, CchOrder, ReceiveBTC, SendBTC, + }, + ckb::tests::test_utils::{get_always_success_script, get_simple_udt_script}, + fiber::{ + hash_algorithm::HashAlgorithm, network::SendPaymentCommand, payment::PaymentStatus, + types::Hash256, + }, + gen_rand_sha256_hash, + invoice::{CkbInvoiceStatus, Currency, InvoiceBuilder}, + test_utils::{ + establish_channel_between_nodes, init_tracing, NetworkNode, NetworkNodeConfigBuilder, + HUGE_CKB_AMOUNT, + }, + CchConfig, ChannelParameters, +}; + +pub const CALL_ACTOR_TIMEOUT_MS: u64 = 3 * 1000; + +async fn do_test_cross_chain_payment_hub_send_btc(udt_script: Script, multiple_hops: bool) { + init_tracing(); + let _span = tracing::info_span!("node", node = "test").entered(); + + let num_nodes = if multiple_hops { 3 } else { 2 }; + + let nodes = NetworkNode::new_n_interconnected_nodes_with_config(num_nodes, |n| { + let mut builder = NetworkNodeConfigBuilder::new(); + if n == num_nodes - 1 { + let cch_config = CchConfig { + wrapped_btc_type_script: udt_script.clone().into(), + ..Default::default() + }; + builder = builder.should_start_lnd(true).cch_config(cch_config); + } + builder.build() + }) + .await; + + let (hub_channel, fiber_node, mut hub) = if multiple_hops { + let [mut fiber_node, mut middle_hop, mut hub] = nodes.try_into().expect("3 nodes"); + let (_channel, funding_tx_1_hash) = establish_channel_between_nodes( + &mut fiber_node, + &mut middle_hop, + ChannelParameters { + public: true, + node_a_funding_amount: HUGE_CKB_AMOUNT, + node_b_funding_amount: 0, + funding_udt_type_script: Some(udt_script.clone()), + ..Default::default() + }, + ) + .await; + let funding_tx_1 = fiber_node + .get_transaction_view_from_hash(funding_tx_1_hash) + .await + .expect("get funding tx 1"); + hub.submit_tx(funding_tx_1).await; + let (hub_channel, funding_tx_2_hash) = establish_channel_between_nodes( + &mut middle_hop, + &mut hub, + ChannelParameters { + public: true, + node_a_funding_amount: HUGE_CKB_AMOUNT, + node_b_funding_amount: 0, + funding_udt_type_script: Some(udt_script.clone()), + ..Default::default() + }, + ) + .await; + let funding_tx_2 = middle_hop + .get_transaction_view_from_hash(funding_tx_2_hash) + .await + .expect("get funding tx 2"); + fiber_node.submit_tx(funding_tx_2).await; + (hub_channel, fiber_node, hub) + } else { + let [mut fiber_node, mut hub] = nodes.try_into().expect("2 nodes"); + let (fiber_channel, _funding_tx) = establish_channel_between_nodes( + &mut fiber_node, + &mut hub, + ChannelParameters { + public: true, + node_a_funding_amount: HUGE_CKB_AMOUNT, + node_b_funding_amount: 0, + funding_udt_type_script: Some(udt_script.clone()), + ..Default::default() + }, + ) + .await; + + (fiber_channel, fiber_node, hub) + }; + + let mut lnd_node = LndNode::new( + Default::default(), + LndBitcoinDConf::Existing(hub.get_bitcoind()), + ) + .await; + + let hub_old_amount = hub.get_local_balance_from_channel(hub_channel); + + hub.get_lnd_node_mut().make_some_money(); + hub.get_lnd_node_mut() + .open_channel_with(&mut lnd_node) + .await; + + // TODO: without the sleep below, we may fail to send the payment below. The root cause is unknown to me. + // We will see two payments in the logs, which tells us the payment is failed because of FailureReasonInsufficientBalance. + // Payment { payment_hash: "650feb233a22fb60a7e2458d03c0a5afa7043207a39c8c1c8a05d183bb5b7455", value: 100, creation_date: 1739422958, fee: 0, payment_preimage: "0000000000000000000000000000000000000000000000000000000000000000", value_sat: 100, value_msat: 100000, payment_request: "lnbcrt1u1pn66l8wpp5v587kge6ytakpflzgkxs8s9947nsgvs85wwgc8y2qhgc8w6mw32sdqqcqzzsxqyz5vqsp53k09akasd35ldkhl4twt9mmxd63cgu2l9j7jept03g6djv5nkazq9qxpqysgqq2dpmpqrsglycahtz4vsuy29a5kjhjt3w4ea664h0tfs0g5cwyn9dm54c2qe4tzxzatcw7dnfhuht5kewdqmn0zrg4cj7h74xejre2sqnhmf42", status: InFlight, fee_sat: 0, fee_msat: 0, creation_time_ns: 1739422958687770515, htlcs: [], payment_index: 1, failure_reason: FailureReasonNone }) + // Payment { payment_hash: "650feb233a22fb60a7e2458d03c0a5afa7043207a39c8c1c8a05d183bb5b7455", value: 100, creation_date: 1739422958, fee: 0, payment_preimage: "0000000000000000000000000000000000000000000000000000000000000000", value_sat: 100, value_msat: 100000, payment_request: "lnbcrt1u1pn66l8wpp5v587kge6ytakpflzgkxs8s9947nsgvs85wwgc8y2qhgc8w6mw32sdqqcqzzsxqyz5vqsp53k09akasd35ldkhl4twt9mmxd63cgu2l9j7jept03g6djv5nkazq9qxpqysgqq2dpmpqrsglycahtz4vsuy29a5kjhjt3w4ea664h0tfs0g5cwyn9dm54c2qe4tzxzatcw7dnfhuht5kewdqmn0zrg4cj7h74xejre2sqnhmf42", status: Failed, fee_sat: 0, fee_msat: 0, creation_time_ns: 1739422958687770515, htlcs: [], payment_index: 1, failure_reason: FailureReasonInsufficientBalance } + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + let lnd_amount_sats = 100; + let lnd_amount_msats = lnd_amount_sats * 1000; + let add_invoice_result = lnd_node.add_invoice(lnd_amount_msats).await; + let lnd_old_amount = lnd_node.get_balance_sats().await; + + let hash = Hash256::try_from(add_invoice_result.r_hash.as_slice()).expect("valid hash"); + + let send_btc_result: CchOrder = call_t!( + hub.get_cch_actor(), + CchMessage::SendBTC, + CALL_ACTOR_TIMEOUT_MS, + SendBTC { + btc_pay_req: add_invoice_result.payment_request, + currency: Currency::Fibd, + } + ) + .expect("send btc actor call") + .expect("send btc result"); + + let fiber_invoice = match send_btc_result.incoming_invoice { + CchInvoice::Fiber(fiber_invoice) => fiber_invoice, + _ => panic!("expect a fiber invoice"), + }; + assert_eq!(fiber_invoice.payment_hash(), &hash); + assert_eq!(fiber_invoice.hash_algorithm(), Some(&HashAlgorithm::Sha256)); + + let hub_amount = fiber_invoice.amount.expect("has amount"); + assert!( + hub_amount >= lnd_amount_sats.into(), + "hub should receive more money than lnd, but we have hub_amount: {}, lnd_amount: {}", + hub_amount, + lnd_amount_sats + ); + + let res = fiber_node + .send_payment(SendPaymentCommand { + invoice: Some(fiber_invoice.to_string()), + ..Default::default() + }) + .await; + + assert!(res.is_ok()); + + let payment_hash = res.unwrap().payment_hash; + assert_eq!(hash, payment_hash); + + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + + fiber_node + .assert_payment_status(payment_hash, PaymentStatus::Success, Some(1)) + .await; + + assert_eq!(hub.get_invoice_status(&hash), Some(CkbInvoiceStatus::Paid)); + let hub_new_amount = hub.get_local_balance_from_channel(hub_channel); + assert_eq!(hub_new_amount, hub_old_amount + hub_amount); + + let lnd_new_amount = lnd_node.get_balance_sats().await; + assert_eq!(lnd_new_amount, lnd_old_amount + lnd_amount_sats); +} + +#[cfg_attr(not(feature = "lnd-tests"), ignore)] +#[tokio::test] +async fn test_cross_chain_payment_hub_send_btc_always_success_single_hop() { + do_test_cross_chain_payment_hub_send_btc(get_always_success_script(), false).await; +} + +#[cfg_attr(not(feature = "lnd-tests"), ignore)] +#[tokio::test] +async fn test_cross_chain_payment_hub_send_btc_simple_udt_single_hop() { + do_test_cross_chain_payment_hub_send_btc(get_simple_udt_script(), false).await; +} + +#[cfg_attr(not(feature = "lnd-tests"), ignore)] +#[tokio::test] +async fn test_cross_chain_payment_hub_send_btc_always_success_multiple_hops() { + do_test_cross_chain_payment_hub_send_btc(get_always_success_script(), true).await; +} + +#[cfg_attr(not(feature = "lnd-tests"), ignore)] +#[tokio::test] +async fn test_cross_chain_payment_hub_send_btc_simple_udt_multiple_hops() { + do_test_cross_chain_payment_hub_send_btc(get_simple_udt_script(), true).await; +} + +async fn do_test_cross_chain_payment_hub_receive_btc(udt_script: Script, multiple_hops: bool) { + init_tracing(); + let _span = tracing::info_span!("node", node = "test").entered(); + + let num_nodes = if multiple_hops { 3 } else { 2 }; + + let nodes = NetworkNode::new_n_interconnected_nodes_with_config(num_nodes, |n| { + let mut builder = NetworkNodeConfigBuilder::new(); + if n == num_nodes - 1 { + let cch_config = CchConfig { + wrapped_btc_type_script: udt_script.clone().into(), + ..Default::default() + }; + builder = builder.should_start_lnd(true).cch_config(cch_config); + } + builder.build() + }) + .await; + + let (fiber_node_channel, fiber_node, mut hub) = if multiple_hops { + let [mut fiber_node, mut middle_hop, mut hub] = nodes.try_into().expect("3 nodes"); + let (fiber_node_channel, funding_tx_1_hash) = establish_channel_between_nodes( + &mut middle_hop, + &mut fiber_node, + ChannelParameters { + public: true, + node_a_funding_amount: HUGE_CKB_AMOUNT, + node_b_funding_amount: 0, + funding_udt_type_script: Some(udt_script.clone()), + ..Default::default() + }, + ) + .await; + let funding_tx_1 = middle_hop + .get_transaction_view_from_hash(funding_tx_1_hash) + .await + .expect("get funding tx 1"); + hub.submit_tx(funding_tx_1).await; + let (_, funding_tx_2_hash) = establish_channel_between_nodes( + &mut hub, + &mut middle_hop, + ChannelParameters { + public: true, + node_a_funding_amount: HUGE_CKB_AMOUNT, + node_b_funding_amount: 0, + funding_udt_type_script: Some(udt_script.clone()), + ..Default::default() + }, + ) + .await; + let funding_tx_2 = hub + .get_transaction_view_from_hash(funding_tx_2_hash) + .await + .expect("get funding tx 2"); + fiber_node.submit_tx(funding_tx_2).await; + (fiber_node_channel, fiber_node, hub) + } else { + let [mut fiber_node, mut hub] = nodes.try_into().expect("2 nodes"); + let (fiber_channel, _funding_tx) = establish_channel_between_nodes( + &mut hub, + &mut fiber_node, + ChannelParameters { + public: true, + node_a_funding_amount: HUGE_CKB_AMOUNT, + node_b_funding_amount: 0, + funding_udt_type_script: Some(udt_script.clone()), + ..Default::default() + }, + ) + .await; + + (fiber_channel, fiber_node, hub) + }; + + let mut lnd_node = LndNode::new( + Default::default(), + LndBitcoinDConf::Existing(hub.get_bitcoind()), + ) + .await; + + lnd_node.make_some_money(); + lnd_node.open_channel_with(hub.get_lnd_node_mut()).await; + + // TODO: without the sleep below, we may fail to send the payment below. The root cause is unknown to me. + // We will see two payments in the logs, which tells us the payment is failed because of FailureReasonInsufficientBalance. + // Payment { payment_hash: "650feb233a22fb60a7e2458d03c0a5afa7043207a39c8c1c8a05d183bb5b7455", value: 100, creation_date: 1739422958, fee: 0, payment_preimage: "0000000000000000000000000000000000000000000000000000000000000000", value_sat: 100, value_msat: 100000, payment_request: "lnbcrt1u1pn66l8wpp5v587kge6ytakpflzgkxs8s9947nsgvs85wwgc8y2qhgc8w6mw32sdqqcqzzsxqyz5vqsp53k09akasd35ldkhl4twt9mmxd63cgu2l9j7jept03g6djv5nkazq9qxpqysgqq2dpmpqrsglycahtz4vsuy29a5kjhjt3w4ea664h0tfs0g5cwyn9dm54c2qe4tzxzatcw7dnfhuht5kewdqmn0zrg4cj7h74xejre2sqnhmf42", status: InFlight, fee_sat: 0, fee_msat: 0, creation_time_ns: 1739422958687770515, htlcs: [], payment_index: 1, failure_reason: FailureReasonNone }) + // Payment { payment_hash: "650feb233a22fb60a7e2458d03c0a5afa7043207a39c8c1c8a05d183bb5b7455", value: 100, creation_date: 1739422958, fee: 0, payment_preimage: "0000000000000000000000000000000000000000000000000000000000000000", value_sat: 100, value_msat: 100000, payment_request: "lnbcrt1u1pn66l8wpp5v587kge6ytakpflzgkxs8s9947nsgvs85wwgc8y2qhgc8w6mw32sdqqcqzzsxqyz5vqsp53k09akasd35ldkhl4twt9mmxd63cgu2l9j7jept03g6djv5nkazq9qxpqysgqq2dpmpqrsglycahtz4vsuy29a5kjhjt3w4ea664h0tfs0g5cwyn9dm54c2qe4tzxzatcw7dnfhuht5kewdqmn0zrg4cj7h74xejre2sqnhmf42", status: Failed, fee_sat: 0, fee_msat: 0, creation_time_ns: 1739422958687770515, htlcs: [], payment_index: 1, failure_reason: FailureReasonInsufficientBalance } + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + let fiber_amount_sats: u128 = 100; + let fiber_amount_msats = fiber_amount_sats * 1000; + let preimage = gen_rand_sha256_hash(); + let fiber_invoice = InvoiceBuilder::new(Currency::Fibd) + .amount(Some(fiber_amount_msats)) + .payment_preimage(preimage) + .hash_algorithm(HashAlgorithm::Sha256) + .payee_pub_key(fiber_node.pubkey.into()) + .expiry_time(Duration::from_secs(100)) + .udt_type_script(udt_script.clone()) + .build() + .expect("build invoice success"); + let payment_hash = *fiber_invoice.payment_hash(); + fiber_node.insert_invoice(fiber_invoice.clone(), Some(preimage)); + + let receive_btc_result: CchOrder = call_t!( + hub.get_cch_actor(), + CchMessage::ReceiveBTC, + CALL_ACTOR_TIMEOUT_MS, + ReceiveBTC { + fiber_pay_req: fiber_invoice.to_string(), + } + ) + .expect("receive btc actor call") + .expect("receive btc result"); + + let lightning_invoice = match receive_btc_result.incoming_invoice { + CchInvoice::Lightning(bolt11) => bolt11, + _ => panic!("expected lightning invoice"), + }; + assert_eq!( + payment_hash, + Hash256::from(lightning_invoice.payment_hash().to_byte_array()) + ); + + let hub_amount = lightning_invoice + .amount_milli_satoshis() + .expect("has amount"); + assert!( + hub_amount >= fiber_amount_sats.try_into().expect("valid amount"), + "hub should receive more money than lnd, but we have hub_amount: {}, lnd_amount: {}", + hub_amount, + fiber_amount_sats + ); + + let fiber_old_amount = fiber_node.get_local_balance_from_channel(fiber_node_channel); + let hub_old_amount = hub.get_lnd_node_mut().get_balance_msats().await; + + lnd_node.send_payment(&lightning_invoice).await; + + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + + hub.assert_payment_status(payment_hash, PaymentStatus::Success, Some(1)) + .await; + + assert_eq!( + fiber_node.get_invoice_status(&payment_hash), + Some(CkbInvoiceStatus::Paid) + ); + let hub_new_amount = hub.get_lnd_node_mut().get_balance_msats().await; + assert_eq!(hub_new_amount, hub_old_amount + hub_amount); + + let fiber_new_amount = fiber_node.get_local_balance_from_channel(fiber_node_channel); + assert_eq!(fiber_new_amount, fiber_old_amount + fiber_amount_msats); +} + +#[cfg_attr(not(feature = "lnd-tests"), ignore)] +#[tokio::test] +async fn test_cross_chain_payment_hub_receive_btc_always_success_single_hop() { + do_test_cross_chain_payment_hub_receive_btc(get_always_success_script(), false).await; +} + +#[cfg_attr(not(feature = "lnd-tests"), ignore)] +#[tokio::test] +async fn test_cross_chain_payment_hub_receive_btc_simple_udt_single_hop() { + do_test_cross_chain_payment_hub_receive_btc(get_simple_udt_script(), false).await; +} + +#[cfg_attr(not(feature = "lnd-tests"), ignore)] +#[tokio::test] +async fn test_cross_chain_payment_hub_receive_btc_always_success_multiple_hops() { + do_test_cross_chain_payment_hub_receive_btc(get_always_success_script(), true).await; +} + +#[cfg_attr(not(feature = "lnd-tests"), ignore)] +#[tokio::test] +async fn test_cross_chain_payment_hub_receive_btc_simple_udt_multiple_hops() { + do_test_cross_chain_payment_hub_receive_btc(get_simple_udt_script(), true).await; +} diff --git a/crates/fiber-lib/src/ckb/config.rs b/crates/fiber-lib/src/ckb/config.rs index 017241c98..1d334e7b8 100644 --- a/crates/fiber-lib/src/ckb/config.rs +++ b/crates/fiber-lib/src/ckb/config.rs @@ -1,16 +1,17 @@ use super::contracts::{get_script_by_contract, Contract}; +use crate::ckb::contracts::ScriptCellDep; #[cfg(not(target_arch = "wasm32"))] use crate::utils::encrypt_decrypt_file::{decrypt_from_file, encrypt_to_file}; use crate::Result; use ckb_jsonrpc_types::{OutPoint as OutPointWrapper, Script as ScriptWrapper}; use ckb_sdk::{traits::DefaultCellCollector, CkbRpcAsyncClient}; -use ckb_types::core::ScriptHashType; use ckb_types::prelude::Builder; use ckb_types::H256; use ckb_types::{ core::DepType, packed::{CellDep, Script}, }; +use ckb_types::{core::ScriptHashType, prelude::Unpack}; use clap_serde_derive::clap::{self}; use clap_serde_derive::ClapSerde; use molecule::prelude::Entity; @@ -250,6 +251,25 @@ impl UdtDep { } } +impl From<&ScriptCellDep> for UdtDep { + fn from(value: &ScriptCellDep) -> Self { + match value { + ScriptCellDep::CellDep(cell_dep) => UdtDep::with_cell_dep(UdtCellDep::from(cell_dep)), + ScriptCellDep::TypeID(type_id) => UdtDep::with_type_id(type_id.clone().into()), + } + } +} + +impl UdtScript { + pub fn allow_all_for_script(script: &Script) -> Self { + Self { + code_hash: H256(script.code_hash().as_slice().try_into().expect("32 bytes")), + hash_type: script.hash_type().try_into().expect("valid hash type"), + args: "0x.*".to_string(), + } + } +} + #[serde_as] #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Hash)] pub struct UdtCellDep { @@ -304,3 +324,23 @@ pub fn new_default_cell_collector(rpc_url: &str) -> DefaultCellCollector { #[cfg(target_arch = "wasm32")] return DefaultCellCollector::new(rpc_url); } + +impl From<&CellDep> for UdtCellDep { + fn from(cell_dep: &CellDep) -> Self { + let index = cell_dep.out_point().index().unpack(); + UdtCellDep { + dep_type: cell_dep.dep_type().try_into().expect("valid dep type"), + out_point: OutPointWrapper { + tx_hash: H256( + cell_dep + .out_point() + .tx_hash() + .as_slice() + .try_into() + .expect("32 bytes"), + ), + index, + }, + } + } +} diff --git a/crates/fiber-lib/src/ckb/contracts.rs b/crates/fiber-lib/src/ckb/contracts.rs index 90971f250..8fc8b5a1d 100644 --- a/crates/fiber-lib/src/ckb/contracts.rs +++ b/crates/fiber-lib/src/ckb/contracts.rs @@ -28,6 +28,8 @@ pub enum Contract { CommitmentLock, Secp256k1Lock, SimpleUDT, + #[cfg(any(test, feature = "bench"))] + AlwaysSuccess, } #[derive(Clone, Debug)] @@ -425,8 +427,6 @@ fn get_contracts_context() -> &'static ContractsContext { #[cfg(any(test, feature = "bench"))] fn get_contracts_context() -> ContractsContext { super::tests::test_utils::MOCK_CONTEXT - .read() - .expect("read mock context") .contracts_context .clone() } diff --git a/crates/fiber-lib/src/ckb/tests/test_utils.rs b/crates/fiber-lib/src/ckb/tests/test_utils.rs index b9faa6b25..29525b259 100644 --- a/crates/fiber-lib/src/ckb/tests/test_utils.rs +++ b/crates/fiber-lib/src/ckb/tests/test_utils.rs @@ -1,25 +1,28 @@ use anyhow::anyhow; use ckb_sdk::{tx_builder::TxBuilderError, RpcError}; use ckb_testtool::context::Context; -use ckb_types::bytes::BufMut; use ckb_types::{ bytes::Bytes, core::{tx_pool::TxStatus, Capacity, DepType, TransactionView}, - packed::{self, CellDep, CellOutput, OutPoint, Script}, - prelude::{Builder, Entity, Pack, PackVec, Unpack}, + packed::{CellDep, CellInput, CellOutput, OutPoint, Script}, + prelude::{Builder, Entity, Pack, Unpack}, H256, }; -use molecule::bytes::BytesMut; use once_cell::sync::{Lazy, OnceCell}; - -use std::{collections::HashMap, sync::Arc, sync::RwLock}; +use std::{ + collections::{HashMap, HashSet}, + sync::{Arc, RwLock}, +}; use tokio::sync::RwLock as TokioRwLock; use crate::{ ckb::{ actor::GetTxResponse, - config::{UdtArgInfo, UdtCfgInfos, UdtScript}, - contracts::{get_cell_deps, Contract, ContractsContext, ContractsInfo, ScriptCellDep}, + config::{UdtArgInfo, UdtCfgInfos, UdtDep, UdtScript}, + contracts::{ + get_cell_deps, get_cell_deps_by_contracts, get_script_by_contract, get_udt_cell_deps, + Contract, ContractsContext, ContractsInfo, ScriptCellDep, + }, CkbTxTracer, CkbTxTracingMask, CkbTxTracingResult, FundingError, }, fiber::types::Hash256, @@ -93,10 +96,10 @@ impl BlockTimestampContext { } } -pub static MOCK_CONTEXT: Lazy> = Lazy::new(|| RwLock::new(MockContext::new())); +pub static MOCK_CONTEXT: Lazy = Lazy::new(MockContext::new); pub struct MockContext { - pub context: Context, + pub context: RwLock, pub contracts_context: ContractsContext, } @@ -138,6 +141,12 @@ impl MockContext { "../../../../../tests/deploy/contracts/simple_udt" )), ), + ( + Contract::AlwaysSuccess, + Bytes::from_static(include_bytes!( + "../../../../../tests/deploy/contracts/always_success" + )), + ), ]; let mut context = Context::new_with_deterministic_rng(); let mut contract_default_scripts: HashMap = HashMap::new(); @@ -174,24 +183,34 @@ impl MockContext { script_cell_deps.insert(contract, cell_deps); } - let mock_udt_infos = UdtCfgInfos(vec![UdtArgInfo { - name: "Mock UDT".to_string(), - script: UdtScript::default(), - auto_accept_amount: None, - cell_deps: vec![], - }]); + let udt_whitelist = [Contract::SimpleUDT, Contract::AlwaysSuccess] + .into_iter() + .map(|contract| { + let script = contract_default_scripts.get(&contract).unwrap(); + let cell_deps: Vec = script_cell_deps + .get(&contract) + .map(|x| x.iter().map(Into::into).collect()) + .unwrap_or_default(); + UdtArgInfo { + name: format!("{:?}", contract), + script: allow_all_for_script(script), + auto_accept_amount: None, + cell_deps, + } + }) + .collect(); let contracts = ContractsInfo { contract_default_scripts, script_cell_deps, - udt_whitelist: mock_udt_infos, + udt_whitelist: UdtCfgInfos(udt_whitelist), }; let contracts_context = ContractsContext { contracts, type_id_resolver: None, }; MockContext { - context, + context: RwLock::new(context), contracts_context, } } @@ -380,111 +399,171 @@ impl Actor for MockChainActor { }; use CkbChainMessage::*; match message { - Fund(tx, request, reply_port) => { - let mut fulfilled_tx = tx.clone(); - let outputs = fulfilled_tx - .as_ref() - .map(|x| x.outputs()) - .unwrap_or_default(); - - let mut ckb_amount = request.local_reserved_ckb_amount; + Fund(mut tx, request, reply_port) => { + // We only know how to build the funding transaction for SimpleUDT and AlwaysSuccess. + let contract = request.udt_type_script.as_ref().map(|script| { + let supported_contracts = [Contract::SimpleUDT, Contract::AlwaysSuccess]; + let current_args: Vec = script.args().unpack(); + supported_contracts + .into_iter() + .find(|contract| { + script == &get_script_by_contract(*contract, current_args.as_slice()) + }) + .expect("Script should be one of the supported contracts") + }); - let mut capacity = - request.local_amount + (request.local_reserved_ckb_amount as u128); - if capacity > u64::MAX as u128 { - let _ = reply_port.send(Err(FundingError::CkbTxBuilderError( - TxBuilderError::Other(anyhow!("capacity overflow")), - ))); - return Ok(()); - } + let is_udt = request.udt_type_script.is_some(); - let outputs = match outputs.get(0) { - Some(output) => { + let (first_output, first_output_data) = match tx + .as_ref() + .and_then(|x| x.output_with_data(0)) + { + Some((output, output_data)) => { if output.lock() != request.script { error!( - "funding request script ({:?}) does not match the first output lock script ({:?})", request.script, output.lock() - ); + "funding request script ({:?}) does not match the first output lock script ({:?})", request.script, output.lock() + ); return Ok(()); } - ckb_amount = ckb_amount - .checked_add(request.remote_reserved_ckb_amount) - .expect("valid ckb amount"); - - if let Some(ref udt_script) = request.udt_type_script { - let udt_output = packed::CellOutput::new_builder() - .capacity(Capacity::shannons(ckb_amount).pack()) - .type_(Some(udt_script.clone()).pack()) - .build(); - - let mut outputs_builder = outputs.as_builder(); - outputs_builder.replace(0, udt_output); - outputs_builder.build() - } else { - let current_capacity: u64 = output.capacity().unpack(); - capacity += current_capacity as u128; - if capacity > u64::MAX as u128 { - let _ = reply_port.send(Err(FundingError::CkbTxBuilderError( - TxBuilderError::Other(anyhow!("capacity overflow")), - ))); - return Ok(()); - } - - let mut outputs_builder = outputs.as_builder(); - outputs_builder.replace( - 0, - output - .as_builder() - .capacity((capacity as u64).pack()) - .build(), + if output.type_() != request.udt_type_script.pack() { + error!( + "funding request udt type script ({:?}) does not match the first output type script ({:?})", request.udt_type_script, output.type_() + ); + return Ok(()); + } + if is_udt && output_data.len() < 16 { + error!( + "funding request output data is too short: {:?}, expected at least 16 bytes", output_data ); - outputs_builder.build() + return Ok(()); } + (output, output_data) } - None => [CellOutput::new_builder() - .capacity( - (request.local_amount as u64 + request.local_reserved_ckb_amount) - .pack(), - ) - .lock(request.script.clone()) - .build()] - .pack(), + // Create a dummy output and output_data so that we can "update" existing output and output_data. + _ => ( + CellOutput::new_builder() + .capacity(Capacity::shannons(0).pack()) + .lock(request.script.clone()) + .type_(request.udt_type_script.clone().pack()) + .build(), + if is_udt { + [0u8; 16].to_vec().into() + } else { + Default::default() + }, + ), }; - let outputs_data = if let Some(ref _udt_script) = request.udt_type_script { - let udt_amount = request.local_amount + request.remote_amount; - let mut data = BytesMut::with_capacity(16); - data.put(&udt_amount.to_le_bytes()[..]); - vec![data.freeze().pack()].pack() + let (output, output_data) = if is_udt { + let mut data = [0u8; 16]; + data.copy_from_slice(&first_output_data.as_ref()[..16]); + let udt_amount = request.local_amount + u128::from_le_bytes(data); + let current_capacity: u64 = first_output.capacity().unpack(); + let ckb_amount = request.local_reserved_ckb_amount + current_capacity; + ( + first_output + .as_builder() + .capacity(ckb_amount.pack()) + .build(), + udt_amount.to_le_bytes().pack(), + ) } else { - let outputs_data = fulfilled_tx - .as_ref() - .map(|x| x.outputs_data()) - .unwrap_or_default(); - if outputs_data.is_empty() { - [Default::default()].pack() - } else { - outputs_data + let current_capacity: u64 = first_output.capacity().unpack(); + let ckb_amount = request.local_amount + + (request.local_reserved_ckb_amount as u128) + + (current_capacity as u128); + if ckb_amount > u64::MAX as u128 { + let _ = reply_port.send(Err(FundingError::CkbTxBuilderError( + TxBuilderError::Other(anyhow!("capacity overflow")), + ))); + return Ok(()); } + let ckb_amount = ckb_amount as u64; + + ( + first_output + .as_builder() + .capacity(ckb_amount.pack()) + .build(), + first_output_data.pack(), + ) }; - let tx_builder = fulfilled_tx - .take() - .map(|x| x.as_advanced_builder()) - .unwrap_or_default(); + let outputs = tx.as_ref().map(|x| x.outputs()).unwrap_or_default(); + let outputs = if outputs.is_empty() { + outputs.as_builder().push(output).build() + } else { + let mut builder = outputs.as_builder(); + builder.replace(0, output); + builder.build() + }; - fulfilled_tx.update_for_self( - tx_builder - .set_outputs(outputs.into_iter().collect()) - .set_outputs_data(outputs_data.into_iter().collect()) - .build(), - ); + let outputs_data = tx.as_ref().map(|x| x.outputs_data()).unwrap_or_default(); + let outputs_data = if outputs_data.is_empty() { + outputs_data.as_builder().push(output_data).build() + } else { + let mut builder = outputs_data.as_builder(); + builder.replace(0, output_data); + builder.build() + }; + + let cell_deps = [ + tx.as_ref().map(|x| x.cell_deps()).unwrap_or_default(), + match request.udt_type_script.as_ref() { + Some(script) => get_udt_cell_deps(script).await.ok().unwrap_or_default(), + None => Default::default(), + }, + // AlwaysSuccess is needed to unlock the input cells + get_cell_deps_by_contracts(vec![Contract::AlwaysSuccess]) + .await + .expect("get always success cell deps"), + ] + .into_iter() + .flatten() + .collect::>() + .into_iter() + .collect::>(); + + let new_tx = tx + .as_ref() + .map(|x| x.as_advanced_builder()) + .unwrap_or_default() + .set_cell_deps(cell_deps) + .set_outputs(outputs.into_iter().collect()) + .set_outputs_data(outputs_data.into_iter().collect()) + .build(); + + let new_tx = match contract { + Some(Contract::SimpleUDT) if new_tx.inputs().is_empty() => { + let context = &mut MOCK_CONTEXT.context.write().expect("get mock context"); + let outpoint = + create_deterministic_outpoint_from_seed(new_tx.hash().as_slice()); + context.create_cell_with_out_point( + outpoint.clone(), + CellOutput::new_builder() + .lock(get_script_by_contract(Contract::AlwaysSuccess, &[])) + .type_(request.udt_type_script.clone().pack()) + .build(), + u128::MAX.to_le_bytes().to_vec().into(), + ); + new_tx + .as_advanced_builder() + .set_inputs(vec![CellInput::new_builder() + .previous_output(outpoint) + .build()]) + .build() + } + _ => new_tx, + }; debug!( "Fulfilling funding request: request: {:?}, original tx: {:?}, fulfilled tx: {:?}", - request, &tx, &fulfilled_tx + request, &tx, &new_tx ); - if let Err(e) = reply_port.send(Ok(fulfilled_tx)) { + tx.update_for_self(new_tx); + + if let Err(e) = reply_port.send(Ok(tx)) { error!( "[{}] send reply failed: {:?}", myself.get_name().unwrap_or_default(), @@ -521,59 +600,75 @@ impl Actor for MockChainActor { } SendTx(tx, reply_port) => { const MAX_CYCLES: u64 = 100_000_000; - let mut f = || { - // Mark the inputs as consumed - for input in tx.input_pts_iter() { - match state.cell_status.entry(input.clone()) { - std::collections::hash_map::Entry::Occupied(mut entry) => { - if *entry.get() == CellStatus::Consumed { - return ( - TxStatus::Rejected("Cell already consumed".to_string()), - Err(ckb_sdk::RpcError::Other(anyhow!( - "Cell {:?} already consumed", - &input - ))), - ); - } - *entry.get_mut() = CellStatus::Consumed; - } - std::collections::hash_map::Entry::Vacant(entry) => { - debug!("Consuming cell {:?}", &input); - entry.insert(CellStatus::Consumed); + let (tx_status, result) = if let Some(resp) = state.txs.get(&tx.hash().into()) { + // Like a real CKB node, this mock should allow sending + // duplicate transactions. This is required because the + // `Fund(..)` branch may add inputs for UDT channels, and + // processing the same UDT funding transaction multiple + // times would otherwise lead to consumed cell errors. + ( + resp.tx_status.clone(), + match &resp.tx_status { + TxStatus::Committed(..) => Ok(()), + TxStatus::Rejected(reason) => { + Err(ckb_sdk::RpcError::Other(anyhow!("{}", reason))) } - } - } - - let context = &mut MOCK_CONTEXT.write().unwrap().context; - match context.verify_tx(&tx, MAX_CYCLES) { - Ok(c) => { - debug!("Verified transaction: {:?} with {} CPU cycles", tx, c); - // Also save the outputs to the context, so that we can refer to - // these out points later. - for outpoint in tx.output_pts().into_iter() { - let index: u32 = outpoint.index().unpack(); - let index = index as usize; - let cell = tx.outputs().get(index).unwrap(); - let data = tx.outputs_data().get(index).unwrap(); - context.create_cell_with_out_point( - outpoint.clone(), - cell, - data.as_bytes(), - ); + _ => { + unreachable!(); } - (TxStatus::Committed(0, H256::default(), 0), Ok(())) - } - Err(e) => ( - TxStatus::Rejected("Failed to verify transaction".to_string()), + }, + ) + } else { + match tx.input_pts_iter().find(|input| { + state + .cell_status + .get(input) + .is_some_and(|status| *status == CellStatus::Consumed) + }) { + Some(input) => ( + TxStatus::Rejected("Cell already consumed".to_string()), Err(ckb_sdk::RpcError::Other(anyhow!( - "Failed to verify transaction: {:?}, error: {:?}", - tx, - e + "Cell {:?} already consumed", + &input ))), ), + None => { + // Mark the inputs as consumed + for input in tx.input_pts_iter() { + state.cell_status.insert(input, CellStatus::Consumed); + } + let verify_context = + &mut MOCK_CONTEXT.context.write().expect("get mock context"); + match verify_context.verify_tx(&tx, MAX_CYCLES) { + Ok(c) => { + debug!("Verified transaction: {:?} with {} CPU cycles", tx, c); + // Also save the outputs to the context, so that we can refer to + // these out points later. + for outpoint in tx.output_pts().into_iter() { + let index: u32 = outpoint.index().unpack(); + let index = index as usize; + let cell = tx.outputs().get(index).unwrap(); + let data = tx.outputs_data().get(index).unwrap(); + verify_context.create_cell_with_out_point( + outpoint.clone(), + cell, + data.as_bytes(), + ); + } + (TxStatus::Committed(0, H256::default(), 0), Ok(())) + } + Err(e) => ( + TxStatus::Rejected("Failed to verify transaction".to_string()), + Err(ckb_sdk::RpcError::Other(anyhow!( + "Failed to verify transaction: {:?}, error: {:?}", + tx, + e + ))), + ), + } + } } }; - let (tx_status, result) = f(); debug!( "Transaction verification result: tx {:?}, status: {:?}", &tx, &tx_status @@ -665,8 +760,9 @@ pub async fn submit_tx(mock_actor: ActorRef, tx: TransactionVie if let Err(error) = call_t!(mock_actor, CkbChainMessage::SendTx, TIMEOUT, tx.clone()) .expect("chain actor alive") { - error!("submit tx failed: {:?}", error); - return TxStatus::Rejected("submit tx failed".to_string()); + let reject_reason = format!("submit tx failed: {:?}", error); + error!("{}", reject_reason); + return TxStatus::Rejected(reject_reason); } trace_tx(mock_actor, tx.hash().into()).await } @@ -706,6 +802,29 @@ pub fn complete_commitment_tx(commitment_tx: &TransactionView) -> TransactionVie .build() } +pub fn create_deterministic_outpoint_from_seed>(seed: S) -> OutPoint { + let hash = ckb_hash::blake2b_256(seed.as_ref()); + OutPoint::new_builder().tx_hash(hash.pack()).build() +} + +pub fn get_simple_udt_script() -> Script { + let args = + hex::decode("32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947").unwrap(); + get_script_by_contract(Contract::SimpleUDT, &args) +} + +pub fn get_always_success_script() -> Script { + get_script_by_contract(Contract::AlwaysSuccess, &[]) +} + +fn allow_all_for_script(script: &Script) -> UdtScript { + UdtScript { + code_hash: H256(script.code_hash().as_slice().try_into().expect("32 bytes")), + hash_type: script.hash_type().try_into().expect("valid hash type"), + args: "0x.*".to_string(), + } +} + #[cfg_attr(not(target_arch = "wasm32"), tokio::test)] #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] async fn test_set_and_get_block_timestamp() { diff --git a/crates/fiber-lib/src/config.rs b/crates/fiber-lib/src/config.rs index d21088c3c..55d11c607 100644 --- a/crates/fiber-lib/src/config.rs +++ b/crates/fiber-lib/src/config.rs @@ -20,6 +20,8 @@ pub struct Config { // ckb actor config, None represents that we should not run ckb actor pub ckb: Option, pub base_dir: PathBuf, + /// fiber config, even when fiber service is not configured. + pub fiber_fallback_config: FiberConfig, } #[derive(Serialize, Deserialize, Parser, Copy, Clone, Debug, PartialEq)] @@ -217,6 +219,7 @@ pub mod native { ckb.unwrap_or(CkbConfig::from(&mut args.ckb)), ); + let fiber_fallback_config = fiber.clone(); let fiber = services.contains(&Service::FIBER).then_some(fiber); let cch = services.contains(&Service::CCH).then_some(cch); let rpc = services.contains(&Service::RPC).then_some(rpc); @@ -228,6 +231,7 @@ pub mod native { rpc, ckb, base_dir, + fiber_fallback_config, } } } @@ -273,10 +277,10 @@ mod wasm { ) }; + let fiber_fallback_config = FiberConfig::from(fiber); let fiber = services .contains(&Service::FIBER) - .then_some(fiber) - .map(FiberConfig::from); + .then_some(fiber_fallback_config.clone()); let rpc = services .contains(&Service::RPC) .then_some(rpc) @@ -290,6 +294,7 @@ mod wasm { fiber, rpc, ckb, + fiber_fallback_config, base_dir: PathBuf::from_str(&database_prefix).unwrap(), } } diff --git a/crates/fiber-lib/src/fiber/channel.rs b/crates/fiber-lib/src/fiber/channel.rs index e8c97341d..eee18680d 100644 --- a/crates/fiber-lib/src/fiber/channel.rs +++ b/crates/fiber-lib/src/fiber/channel.rs @@ -11,6 +11,7 @@ use crate::fiber::fee::{check_open_channel_parameters, check_tlc_delta_with_epoc use crate::fiber::network::DebugEvent; use crate::fiber::network::PaymentCustomRecords; use crate::fiber::types::TxSignatures; +use crate::invoice::InvoiceChannelInfo; use crate::{debug_event, fiber::types::TxAbort, utils::tx::compute_tx_message}; #[cfg(test)] use musig2::BinaryEncoding; @@ -81,7 +82,7 @@ use musig2::{ use ractor::call; use ractor::{ concurrency::{Duration, JoinHandle}, - Actor, ActorProcessingErr, ActorRef, MessagingErr, OutputPort, RpcReplyPort, + Actor, ActorProcessingErr, ActorRef, MessagingErr, RpcReplyPort, }; use serde::{Deserialize, Serialize}; use serde_with::serde_as; @@ -170,6 +171,7 @@ pub enum ChannelCommand { BroadcastChannelUpdate(), Update(UpdateCommand, RpcReplyPort>), ForwardTlcResult(ForwardTlcResult), + SettleHeldTlc(Hash256), #[cfg(any(test, feature = "bench"))] ReloadState(ReloadParams), } @@ -186,6 +188,7 @@ impl Display for ChannelCommand { ChannelCommand::BroadcastChannelUpdate() => write!(f, "BroadcastChannelUpdate"), ChannelCommand::Update(_, _) => write!(f, "Update"), ChannelCommand::ForwardTlcResult(res) => write!(f, "ForwardTlcResult [{:?}]", res), + ChannelCommand::SettleHeldTlc(_) => write!(f, "SettleHeldTlc"), #[cfg(any(test, feature = "bench"))] ChannelCommand::ReloadState(_) => write!(f, "ReloadState"), } @@ -360,27 +363,11 @@ pub struct ChannelInitializationParameter { pub private_key: Privkey, } -#[derive(Clone)] -pub struct ChannelSubscribers { - pub pending_received_tlcs_subscribers: Arc>, - pub settled_tlcs_subscribers: Arc>, -} - -impl Default for ChannelSubscribers { - fn default() -> Self { - Self { - pending_received_tlcs_subscribers: Arc::new(OutputPort::default()), - settled_tlcs_subscribers: Arc::new(OutputPort::default()), - } - } -} - pub struct ChannelActor { local_pubkey: Pubkey, remote_pubkey: Pubkey, network: ActorRef, store: S, - subscribers: ChannelSubscribers, } impl ChannelActor @@ -392,14 +379,12 @@ where remote_pubkey: Pubkey, network: ActorRef, store: S, - subscribers: ChannelSubscribers, ) -> Self { Self { local_pubkey, remote_pubkey, network, store, - subscribers, } } @@ -822,6 +807,13 @@ where ProcessingChannelError::TlcForwardingError(tlc_err) => tlc_err, _ => { let error_detail = self.get_tlc_error(state, &error.source).await; + debug!( + payment_hash = ?payment_hash, + tlc_id = ?tlc_id, + error_source = ?error.source, + error_detail = ?error_detail, + "Processing AddTlc failed", + ); #[cfg(debug_assertions)] self.network .clone() @@ -907,6 +899,28 @@ where .await; } + // Try to settle down a held TLC (i.e., a TLC whose preimage is not available when it is received). + // This is usually a TLC associated with a hold invoice. We call of this function should ensure that + // this TLC is already in a state that can be settled down (i.e. the invoice associated with it is + // in a Received state and we have saved its preimage to the store). + async fn try_to_settle_down_held_tlc( + &self, + myself: &ActorRef, + state: &mut ChannelActorState, + hash: Hash256, + ) { + if let Some(tlc) = state.get_received_tlc_with_hash(hash) { + let tlc_id = tlc.tlc_id; + // Only settle down this TLC if it is not already settled down. + let status = self.store.get_invoice_status(&hash); + if tlc.status == TlcStatus::Inbound(InboundTlcStatus::Committed) + && status == Some(CkbInvoiceStatus::Received) + { + self.try_to_settle_down_tlc(myself, state, tlc_id).await; + } + }; + } + async fn try_to_settle_down_tlc( &self, myself: &ActorRef, @@ -929,12 +943,14 @@ where let status = self.get_invoice_status(&invoice); match status { CkbInvoiceStatus::Expired => { + debug!("invoice expired, remove tlc"); remove_reason = RemoveTlcReason::RemoveTlcFail(TlcErrPacket::new( TlcErr::new(TlcErrorCode::InvoiceExpired), &tlc.shared_secret, )); } CkbInvoiceStatus::Cancelled => { + debug!("invoice cancelled, remove tlc"); remove_reason = RemoveTlcReason::RemoveTlcFail(TlcErrPacket::new( TlcErr::new(TlcErrorCode::InvoiceCancelled), &tlc.shared_secret, @@ -1014,16 +1030,6 @@ where } } - if let Some(ref udt_type_script) = state.funding_udt_type_script { - self.subscribers - .pending_received_tlcs_subscribers - .send(TlcNotification { - tlc: add_tlc.clone().into(), - channel_id: state.get_id(), - script: udt_type_script.clone(), - }); - } - // we don't need to settle down the tlc if it is not the last hop here, // some e2e tests are calling AddTlc manually, so we can not use onion packet to // check whether it's the last hop here, maybe need to revisit in future. @@ -1043,10 +1049,11 @@ where ) -> Result<(), ProcessingChannelError> { let payment_hash = add_tlc.payment_hash; let forward_amount = peeled_onion_packet.current.amount; + let received_amount = add_tlc.amount; state.tlc_state.applied_add_tlcs.insert(add_tlc.tlc_id); if peeled_onion_packet.is_last() { - if forward_amount != add_tlc.amount { + if forward_amount != received_amount { return Err(ProcessingChannelError::FinalIncorrectHTLCAmount); } @@ -1155,17 +1162,37 @@ where self.store_preimage(payment_hash, preimage); } else { - error!("preimage is not found for payment hash: {:?}", payment_hash); - return Err(ProcessingChannelError::FinalIncorrectPaymentHash); + // Check HODL invoice. The updating of hold invoice is always done in settle_invoice rpc call. + let status = self + .store + .get_invoice_status(&payment_hash) + // The sender sent a TLC with no invoice associated with it. + .ok_or_else(|| { + error!("preimage is not found for payment hash: {:?}", payment_hash); + ProcessingChannelError::FinalIncorrectPaymentHash + })?; + if status == CkbInvoiceStatus::Open { + self.store + .update_invoice_status(&payment_hash, CkbInvoiceStatus::Received) + .expect("update invoice status failed"); + } + if let Err(e) = self.store.add_invoice_channel_info( + &payment_hash, + InvoiceChannelInfo::new(state.get_id(), received_amount), + ) { + error!("Failed to add invoice channel mapping: {:?}", e); + } } } else { + // here we don't need to check current config is public or enabled, because + // handle_add_tlc_command will check the channel state before forwarding + // and private channel can also forward TLC to public channel if add_tlc.expiry < peeled_onion_packet.current.expiry + state.local_tlc_info.tlc_expiry_delta { return Err(ProcessingChannelError::IncorrectTlcExpiry); } - let received_amount = add_tlc.amount; if received_amount < forward_amount { return Err(ProcessingChannelError::InvalidParameter( "received_amount is less than forward_amount".to_string(), @@ -1348,6 +1375,7 @@ where if matches!(remove_reason, RemoveTlcReason::RemoveTlcFulfill(_)) { if self.store.get_invoice(&tlc_info.payment_hash).is_some() { + debug!(channel = ?channel_id, hash = ?tlc_info.payment_hash, "update invoice status to paid"); self.store .update_invoice_status(&tlc_info.payment_hash, CkbInvoiceStatus::Paid) .expect("update invoice status failed"); @@ -1359,21 +1387,6 @@ where } } - if let ( - Some(ref udt_type_script), - RemoveTlcReason::RemoveTlcFulfill(RemoveTlcFulfill { payment_preimage }), - ) = (state.funding_udt_type_script.clone(), &remove_reason) - { - let mut tlc_notify_info: TlcNotifyInfo = tlc_info.clone().into(); - tlc_notify_info.payment_preimage = Some(*payment_preimage); - self.subscribers - .settled_tlcs_subscribers - .send(TlcNotification { - tlc: tlc_notify_info, - channel_id, - script: udt_type_script.clone(), - }); - } if tlc_info.previous_tlc.is_none() { // only the original sender of the TLC should send `TlcRemoveReceived` event // because only the original sender cares about the TLC event to settle the payment @@ -2177,6 +2190,10 @@ where .await; Ok(()) } + ChannelCommand::SettleHeldTlc(hash) => { + self.try_to_settle_down_held_tlc(myself, state, hash).await; + Ok(()) + } #[cfg(any(test, feature = "bench"))] ChannelCommand::ReloadState(reload_params) => { let private_key = state.private_key.clone(); @@ -5254,6 +5271,14 @@ impl ChannelActorState { self.tlc_state.get(&tlc_id) } + pub fn get_received_tlc_with_hash(&self, hash: Hash256) -> Option<&TlcInfo> { + self.tlc_state + .received_tlcs + .tlcs + .iter() + .find(|tlc| tlc.payment_hash == hash) + } + pub fn check_insert_tlc(&mut self, tlc: &TlcInfo) -> Result<(), ProcessingChannelError> { let next_tlc_id = if tlc.is_offered() { self.get_next_offering_tlc_id() @@ -8140,6 +8165,81 @@ pub trait ChannelActorStateStore { fn get_node_hold_tlcs(&self) -> HashMap>; } +/// Used for delegating the store trait +pub trait ChannelActorStateStoreDeref { + type Target: ChannelActorStateStore; + fn channel_actor_state_store_deref(&self) -> &Self::Target; +} + +impl ChannelActorStateStore for T { + fn get_channel_actor_state(&self, id: &Hash256) -> Option { + self.channel_actor_state_store_deref() + .get_channel_actor_state(id) + } + fn insert_channel_actor_state(&self, state: ChannelActorState) { + self.channel_actor_state_store_deref() + .insert_channel_actor_state(state); + } + fn delete_channel_actor_state(&self, id: &Hash256) { + self.channel_actor_state_store_deref() + .delete_channel_actor_state(id); + } + fn get_channel_ids_by_peer(&self, peer_id: &PeerId) -> Vec { + self.channel_actor_state_store_deref() + .get_channel_ids_by_peer(peer_id) + } + fn get_active_channel_ids_by_peer(&self, peer_id: &PeerId) -> Vec { + self.channel_actor_state_store_deref() + .get_active_channel_ids_by_peer(peer_id) + } + fn get_channel_states(&self, peer_id: Option) -> Vec<(PeerId, Hash256, ChannelState)> { + self.channel_actor_state_store_deref() + .get_channel_states(peer_id) + } + fn get_active_channel_states( + &self, + peer_id: Option, + ) -> Vec<(PeerId, Hash256, ChannelState)> { + self.channel_actor_state_store_deref() + .get_active_channel_states(peer_id) + } + fn get_channel_state_by_outpoint(&self, id: &OutPoint) -> Option { + self.channel_actor_state_store_deref() + .get_channel_state_by_outpoint(id) + } + fn insert_payment_custom_records( + &self, + payment_hash: &Hash256, + custom_records: PaymentCustomRecords, + ) { + self.channel_actor_state_store_deref() + .insert_payment_custom_records(payment_hash, custom_records); + } + fn get_payment_custom_records(&self, payment_hash: &Hash256) -> Option { + self.channel_actor_state_store_deref() + .get_payment_custom_records(payment_hash) + } + + fn insert_payment_hold_tlc(&self, payment_hash: Hash256, hold_tlc: HoldTlc) { + self.channel_actor_state_store_deref() + .insert_payment_hold_tlc(payment_hash, hold_tlc); + } + + fn remove_payment_hold_tlc(&self, payment_hash: &Hash256, channel_id: &Hash256, tlc_id: u64) { + self.channel_actor_state_store_deref() + .remove_payment_hold_tlc(payment_hash, channel_id, tlc_id); + } + + fn get_payment_hold_tlcs(&self, payment_hash: Hash256) -> Vec { + self.channel_actor_state_store_deref() + .get_payment_hold_tlcs(payment_hash) + } + + fn get_node_hold_tlcs(&self) -> HashMap> { + self.channel_actor_state_store_deref().get_node_hold_tlcs() + } +} + /// A wrapper on CommitmentTransaction that has a partial signature along with /// the ckb transaction. #[derive(Clone, Debug)] diff --git a/crates/fiber-lib/src/fiber/config.rs b/crates/fiber-lib/src/fiber/config.rs index 15b281c32..eff0d9b82 100644 --- a/crates/fiber-lib/src/fiber/config.rs +++ b/crates/fiber-lib/src/fiber/config.rs @@ -1,5 +1,3 @@ -#[cfg(target_arch = "wasm32")] -use crate::fiber::KeyPair; use crate::{ckb::contracts::Contract, Result}; use ckb_jsonrpc_types::{CellDep, Script}; use clap_serde_derive::{ @@ -339,7 +337,7 @@ pub struct FiberConfig { pub disable_built_in_watchtower: Option, #[cfg(target_arch = "wasm32")] #[arg(skip)] - pub wasm_key_pair: Option, + pub wasm_key_pair: Option, /// Max allowed number of channels to be accepted from one peer. [default: 20] #[arg( diff --git a/crates/fiber-lib/src/fiber/gossip.rs b/crates/fiber-lib/src/fiber/gossip.rs index fdddda826..1c314a843 100644 --- a/crates/fiber-lib/src/fiber/gossip.rs +++ b/crates/fiber-lib/src/fiber/gossip.rs @@ -245,6 +245,131 @@ pub trait GossipMessageStore { fn delete_channel_timestamps(&self, outpoint: &OutPoint); } +/// Used for delegating the store trait +pub trait GossipMessageStoreDeref { + type Target: GossipMessageStore; + fn gossip_message_store_deref(&self) -> &Self::Target; +} + +impl GossipMessageStore for T { + fn get_broadcast_messages_iter( + &self, + after_cursor: &Cursor, + ) -> impl IntoIterator { + self.gossip_message_store_deref() + .get_broadcast_messages_iter(after_cursor) + } + + fn get_broadcast_messages( + &self, + after_cursor: &Cursor, + count: Option, + ) -> Vec { + self.gossip_message_store_deref() + .get_broadcast_messages(after_cursor, count) + } + + fn query_broadcast_messages>( + &self, + queries: I, + ) -> (Vec, Vec) { + self.gossip_message_store_deref() + .query_broadcast_messages(queries) + } + + fn query_broadcast_message( + &self, + query: BroadcastMessageQuery, + ) -> Option { + self.gossip_message_store_deref() + .query_broadcast_message(query) + } + + fn get_broadcast_message_with_cursor( + &self, + cursor: &Cursor, + ) -> Option { + self.gossip_message_store_deref() + .get_broadcast_message_with_cursor(cursor) + } + + fn get_latest_broadcast_message_cursor(&self) -> Option { + self.gossip_message_store_deref() + .get_latest_broadcast_message_cursor() + } + + fn get_latest_channel_announcement_timestamp(&self, outpoint: &OutPoint) -> Option { + self.gossip_message_store_deref() + .get_latest_channel_announcement_timestamp(outpoint) + } + + fn get_latest_channel_update_timestamp( + &self, + outpoint: &OutPoint, + is_node1: bool, + ) -> Option { + self.gossip_message_store_deref() + .get_latest_channel_update_timestamp(outpoint, is_node1) + } + + fn get_latest_node_announcement_timestamp(&self, pk: &Pubkey) -> Option { + self.gossip_message_store_deref() + .get_latest_node_announcement_timestamp(pk) + } + + fn get_latest_channel_announcement( + &self, + outpoint: &OutPoint, + ) -> Option<(u64, ChannelAnnouncement)> { + self.gossip_message_store_deref() + .get_latest_channel_announcement(outpoint) + } + + fn get_latest_channel_update( + &self, + outpoint: &OutPoint, + is_node1: bool, + ) -> Option { + self.gossip_message_store_deref() + .get_latest_channel_update(outpoint, is_node1) + } + + fn get_latest_node_announcement(&self, pk: &Pubkey) -> Option { + self.gossip_message_store_deref() + .get_latest_node_announcement(pk) + } + + fn delete_broadcast_message(&self, cursor: &Cursor) { + self.gossip_message_store_deref() + .delete_broadcast_message(cursor); + } + + fn save_channel_announcement(&self, timestamp: u64, channel_announcement: ChannelAnnouncement) { + self.gossip_message_store_deref() + .save_channel_announcement(timestamp, channel_announcement); + } + + fn save_channel_update(&self, channel_update: ChannelUpdate) { + self.gossip_message_store_deref() + .save_channel_update(channel_update) + } + + fn save_node_announcement(&self, node_announcement: NodeAnnouncement) { + self.gossip_message_store_deref() + .save_node_announcement(node_announcement); + } + + fn get_channel_timestamps_iter(&self) -> impl IntoIterator { + self.gossip_message_store_deref() + .get_channel_timestamps_iter() + } + + fn delete_channel_timestamps(&self, outpoint: &OutPoint) { + self.gossip_message_store_deref() + .delete_channel_timestamps(outpoint); + } +} + // A batch of gossip messages has been added to the store since the last time // we pulled new messages/messages are pushed to us. #[derive(Clone, Debug)] diff --git a/crates/fiber-lib/src/fiber/network.rs b/crates/fiber-lib/src/fiber/network.rs index cbae9bb85..dcd7d1bf3 100644 --- a/crates/fiber-lib/src/fiber/network.rs +++ b/crates/fiber-lib/src/fiber/network.rs @@ -49,9 +49,9 @@ use tracing::{debug, error, info, trace, warn}; use super::channel::{ get_funding_and_reserved_amount, AcceptChannelParameter, ChannelActor, ChannelActorMessage, ChannelActorStateStore, ChannelCommand, ChannelCommandWithId, ChannelEvent, - ChannelInitializationParameter, ChannelState, ChannelSubscribers, ChannelTlcInfo, - OpenChannelParameter, PrevTlcInfo, ProcessingChannelError, ProcessingChannelResult, - PublicChannelInfo, RemoveTlcCommand, RevocationData, SettlementData, StopReason, TLCId, + ChannelInitializationParameter, ChannelState, ChannelTlcInfo, OpenChannelParameter, + PrevTlcInfo, ProcessingChannelError, ProcessingChannelResult, PublicChannelInfo, + RemoveTlcCommand, RevocationData, SettlementData, StopReason, TLCId, DEFAULT_MAX_TLC_VALUE_IN_FLIGHT, }; use super::config::AnnouncedNodeName; @@ -96,7 +96,10 @@ use crate::fiber::types::{ FiberChannelMessage, PeeledPaymentOnionPacket, TlcErrPacket, TxSignatures, }; use crate::fiber::KeyPair; -use crate::invoice::{CkbInvoice, CkbInvoiceStatus, InvoiceStore, PreimageStore}; +use crate::invoice::{ + add_invoice, CkbInvoice, CkbInvoiceStatus, InvoiceError, InvoiceStore, PreimageStore, + SettleInvoiceError, +}; use crate::utils::payment::is_invoice_fulfilled; use crate::{now_timestamp_as_millis_u64, unwrap_or_return, Error}; @@ -328,6 +331,18 @@ pub enum NetworkActorCommand { RpcReplyPort>, ), + AddInvoice( + CkbInvoice, + Option, + RpcReplyPort>, + ), + + SettleInvoice( + Hash256, + Hash256, + RpcReplyPort>, + ), + NodeInfo((), RpcReplyPort>), ListPeers((), RpcReplyPort, String>>), @@ -2180,6 +2195,13 @@ where let _ = rpc.send(Ok(peers)); } + NetworkActorCommand::SettleInvoice(hash, preimage, reply) => { + let _ = reply.send(self.settle_invoice(&myself, &hash, &preimage)); + } + NetworkActorCommand::AddInvoice(invoice, preimage, reply) => { + let _ = reply.send(add_invoice(&self.store, invoice, preimage)); + } + #[cfg(any(debug_assertions, feature = "bench"))] NetworkActorCommand::UpdateFeatures(features) => { state.features = features; @@ -2276,6 +2298,50 @@ where } } + pub(crate) fn settle_invoice( + &self, + myself: &ActorRef, + payment_hash: &Hash256, + payment_preimage: &Hash256, + ) -> Result<(), SettleInvoiceError> { + let invoice = self + .store + .get_invoice(payment_hash) + .ok_or(SettleInvoiceError::InvoiceNotFound)?; + + let hash_algorithm = invoice.hash_algorithm().copied().unwrap_or_default(); + let hash = hash_algorithm.hash(payment_preimage); + if hash.as_slice() != payment_hash.as_ref() { + return Err(SettleInvoiceError::HashMismatch); + } + + self.store.insert_preimage(*payment_hash, *payment_preimage); + + // We will send network actor a message to settle the invoice immediately if possible. + if let Some(CkbInvoiceStatus::Received) = self.store.get_invoice_status(payment_hash) { + let channels = self.store.get_invoice_channel_info(payment_hash); + let total_amount: u128 = channels.iter().map(|c| c.amount).sum(); + match invoice.amount() { + Some(amount) if total_amount < amount => { + return Ok(()); + } + _ => { + // Only settle the invoice if the client has paid the full amount. + for channel in channels { + let _ = myself.send_message(NetworkActorMessage::new_command( + NetworkActorCommand::ControlFiberChannel(ChannelCommandWithId { + channel_id: channel.channel_id, + command: ChannelCommand::SettleHeldTlc(*payment_hash), + }), + )); + } + } + } + } + + Ok(()) + } + async fn handle_send_onion_packet_command( &self, state: &mut NetworkActorState, @@ -2967,6 +3033,7 @@ where state: &mut NetworkActorState, payment_request: SendPaymentCommand, ) -> Result { + debug!("Received send payment request: {:?}", payment_request); let payment_data = SendPaymentData::new(payment_request.clone()).map_err(|e| { error!("Failed to validate payment request: {:?}", e); Error::InvalidParameter(format!("Failed to validate payment request: {:?}", e)) @@ -3160,7 +3227,6 @@ pub struct NetworkActorState { tlc_fee_proportional_millionths: u128, // The gossip messages actor to process and send gossip messages. gossip_actor: ActorRef, - channel_subscribers: ChannelSubscribers, max_inbound_peers: usize, min_outbound_peers: usize, // The features of the node, used to indicate the capabilities of the node. @@ -3259,6 +3325,23 @@ pub trait NetworkActorStateStore { fn insert_network_actor_state(&self, id: &PeerId, state: PersistentNetworkActorState); } +/// Used for delegating the store trait +pub trait NetworkActorStateStoreDeref { + type Target: NetworkActorStateStore; + fn network_actor_state_store_deref(&self) -> &Self::Target; +} + +impl NetworkActorStateStore for T { + fn get_network_actor_state(&self, id: &PeerId) -> Option { + self.network_actor_state_store_deref() + .get_network_actor_state(id) + } + fn insert_network_actor_state(&self, id: &PeerId, state: PersistentNetworkActorState) { + self.network_actor_state_store_deref() + .insert_network_actor_state(id, state); + } +} + static CHANNEL_ACTOR_NAME_PREFIX: AtomicU64 = AtomicU64::new(0u64); // ractor requires that the actor name is unique, so we add a prefix to the actor name. @@ -3395,13 +3478,7 @@ where let (tx, rx) = oneshot::channel::(); let channel = Actor::spawn_linked( Some(generate_channel_actor_name(&self.peer_id, &peer_id)), - ChannelActor::new( - self.get_public_key(), - remote_pubkey, - network.clone(), - store, - self.channel_subscribers.clone(), - ), + ChannelActor::new(self.get_public_key(), remote_pubkey, network.clone(), store), ChannelInitializationParameter { operation: ChannelInitializationOperation::OpenChannel(OpenChannelParameter { funding_amount, @@ -3487,13 +3564,7 @@ where let (tx, rx) = oneshot::channel::(); let channel = Actor::spawn_linked( Some(generate_channel_actor_name(&self.peer_id, &peer_id)), - ChannelActor::new( - self.get_public_key(), - remote_pubkey, - network.clone(), - store, - self.channel_subscribers.clone(), - ), + ChannelActor::new(self.get_public_key(), remote_pubkey, network.clone(), store), ChannelInitializationParameter { operation: ChannelInitializationOperation::AcceptChannel(AcceptChannelParameter { funding_amount, @@ -3872,7 +3943,6 @@ where remote_pubkey, self.network.clone(), self.store.clone(), - self.channel_subscribers.clone(), ), ChannelInitializationParameter { operation: ChannelInitializationOperation::ReestablishChannel(channel_id), @@ -4359,7 +4429,6 @@ where pub struct NetworkActorStartArguments { pub config: FiberConfig, pub tracker: TaskTracker, - pub channel_subscribers: ChannelSubscribers, pub default_shutdown_script: Script, } @@ -4391,7 +4460,6 @@ where config, #[cfg(not(target_arch = "wasm32"))] tracker, - channel_subscribers, default_shutdown_script, .. } = args; @@ -4593,7 +4661,6 @@ where tlc_min_value: config.tlc_min_value(), tlc_fee_proportional_millionths: config.tlc_fee_proportional_millionths(), gossip_actor, - channel_subscribers, max_inbound_peers: config.max_inbound_peers(), min_outbound_peers: config.min_outbound_peers(), features, @@ -4888,7 +4955,6 @@ pub async fn start_network< tracker: TaskTracker, root_actor: ActorCell, store: S, - channel_subscribers: ChannelSubscribers, network_graph: Arc>>, default_shutdown_script: Script, ) -> ActorRef { @@ -4901,7 +4967,6 @@ pub async fn start_network< NetworkActorStartArguments { config, tracker, - channel_subscribers, default_shutdown_script, }, root_actor, diff --git a/crates/fiber-lib/src/fiber/payment.rs b/crates/fiber-lib/src/fiber/payment.rs index 22c0870f9..882afd764 100644 --- a/crates/fiber-lib/src/fiber/payment.rs +++ b/crates/fiber-lib/src/fiber/payment.rs @@ -13,7 +13,7 @@ use serde_with::serde_as; /// The status of a payment, will update as the payment progresses. /// The transfer path for payment status is `Created -> Inflight -> Success | Failed`. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] pub enum PaymentStatus { /// initial status, a payment session is created, no HTLC is sent Created, diff --git a/crates/fiber-lib/src/fiber/serde_utils.rs b/crates/fiber-lib/src/fiber/serde_utils.rs index 4f9d68e67..9a0f4d854 100644 --- a/crates/fiber-lib/src/fiber/serde_utils.rs +++ b/crates/fiber-lib/src/fiber/serde_utils.rs @@ -1,42 +1,66 @@ +use anyhow::anyhow; use molecule::prelude::Entity; use musig2::{BinaryEncoding, CompactSignature, PubNonce, SCHNORR_SIGNATURE_SIZE}; use serde::{de::Error, Deserialize, Deserializer, Serializer}; use serde_with::{serde_conv, DeserializeAs, SerializeAs}; -pub fn from_hex<'de, D, E>(deserializer: D) -> Result +pub fn deserialize_entity_from_hex_str(str: &str) -> Result +where + E: Entity, +{ + let v: Vec = deserialize_from_hex_str(str)?; + E::from_slice(&v).map_err(|err| anyhow!("failed to convert slice into entity: {:?}", err)) +} + +pub fn serialize_entity_to_hex_string(entity: &E) -> String +where + E: Entity, +{ + serialize_to_hex_string(entity.as_slice()) +} + +pub fn deserialize_from_hex_str(string: &str) -> Result +where + E: TryFrom>, + E::Error: core::fmt::Debug, +{ + if string.len() < 2 { + return Err(anyhow!("hex string too short: {}", &string)); + }; + let start = if &string[..2].to_lowercase() == "0x" { + 2 + } else { + 0 + }; + let vec = hex::decode(&string[start..]) + .map_err(|err| anyhow!("failed to decode hex string {}: {:?}", &string, err))?; + vec.try_into() + .map_err(|err| anyhow!("failed to convert vector into type: {:?}", err)) +} + +pub fn serialize_to_hex_string(e: E) -> String +where + E: AsRef<[u8]>, +{ + format!("0x{}", &hex::encode(e.as_ref())) +} + +pub fn deserialize_from_hex<'de, D, E>(deserializer: D) -> Result where D: Deserializer<'de>, E: TryFrom>, E::Error: core::fmt::Debug, { String::deserialize(deserializer) - .and_then(|string| { - if string.len() < 2 || &string[..2].to_lowercase() != "0x" { - return Err(Error::custom(format!( - "hex string does not start with 0x: {}", - &string - ))); - }; - hex::decode(&string[2..]).map_err(|err| { - Error::custom(format!( - "failed to decode hex string {}: {:?}", - &string, err - )) - }) - }) - .and_then(|vec| { - vec.try_into().map_err(|err| { - Error::custom(format!("failed to convert vector into type: {:?}", err)) - }) - }) + .and_then(|string| deserialize_from_hex_str(&string).map_err(Error::custom)) } -pub fn to_hex(e: E, serializer: S) -> Result +pub fn serialize_to_hex(e: E, serializer: S) -> Result where E: AsRef<[u8]>, S: Serializer, { - serializer.serialize_str(&format!("0x{}", &hex::encode(e.as_ref()))) + serializer.serialize_str(&serialize_to_hex_string(e)) } pub struct SliceHex; @@ -49,7 +73,7 @@ where where S: Serializer, { - to_hex(source, serializer) + serialize_to_hex(source, serializer) } } @@ -62,7 +86,7 @@ where where D: Deserializer<'de>, { - from_hex(deserializer) + deserialize_from_hex(deserializer) } } @@ -76,7 +100,7 @@ where where S: Serializer, { - to_hex(source.as_slice(), serializer) + serialize_to_hex(source.as_slice(), serializer) } } @@ -88,7 +112,7 @@ where where D: Deserializer<'de>, { - let v: Vec = from_hex(deserializer)?; + let v: Vec = deserialize_from_hex(deserializer)?; T::from_slice(&v).map_err(Error::custom) } } diff --git a/crates/fiber-lib/src/fiber/tests/channel.rs b/crates/fiber-lib/src/fiber/tests/channel.rs index a5bae7cca..603c0880b 100644 --- a/crates/fiber-lib/src/fiber/tests/channel.rs +++ b/crates/fiber-lib/src/fiber/tests/channel.rs @@ -1,4 +1,4 @@ -use crate::ckb::tests::test_utils::complete_commitment_tx; +use crate::ckb::tests::test_utils::{complete_commitment_tx, get_simple_udt_script}; use crate::fiber::channel::{ AddTlcResponse, ChannelState, CloseFlags, OutboundTlcStatus, TLCId, TlcStatus, UpdateCommand, MAX_COMMITMENT_DELAY_EPOCHS, MIN_COMMITMENT_DELAY_EPOCHS, XUDT_COMPATIBLE_WITNESS, @@ -357,18 +357,26 @@ async fn test_create_channel_with_too_large_amounts() { "The total funding amount (18446744069509551614) should be less than 18446744065309551615" )); + let udt_script = get_simple_udt_script(); + let params = ChannelParameters { node_a_funding_amount: u128::MAX - 100, node_b_funding_amount: 101, - funding_udt_type_script: Some(Script::default()), + funding_udt_type_script: Some(udt_script), ..Default::default() }; let res = create_channel_with_nodes(&mut node_a, &mut node_b, params).await; - assert!(res.is_err(), "Create channel failed: {:?}", res); - assert!(res - .unwrap_err() - .to_string() - .contains("The total UDT funding amount should be less")); + assert!( + res.is_err(), + "Create channel should fail but succeeded: {:?}", + res + ); + let err = res.unwrap_err().to_string(); + assert!( + err.contains("The total UDT funding amount should be less"), + "Unmatched error: {}", + err + ); } #[tokio::test] @@ -5534,7 +5542,51 @@ async fn test_send_payment_will_succeed_with_valid_invoice() { } #[tokio::test] -async fn test_send_payment_will_fail_with_no_invoice_preimage() { +async fn test_send_payment_will_hang_with_no_invoice_preimage() { + init_tracing(); + let _span = tracing::info_span!("node", node = "test").entered(); + let (nodes, _channels) = create_n_nodes_network( + &[ + ((0, 1), (100000000000, 100000000000)), + ((1, 2), (100000000000, 100000000000)), + ((2, 3), (MIN_RESERVED_CKB + 2000, MIN_RESERVED_CKB + 1000)), + ((2, 3), (MIN_RESERVED_CKB + 1005, MIN_RESERVED_CKB + 1000)), + ], + 4, + ) + .await; + let [mut node_0, _node_1, _node_2, node_3] = nodes.try_into().expect("4 nodes"); + let source_node = &mut node_0; + let target_pubkey = node_3.pubkey; + + let payment_hash = gen_rand_sha256_hash(); + let ckb_invoice = InvoiceBuilder::new(Currency::Fibd) + .amount(Some(100)) + .payment_hash(payment_hash) + .payee_pub_key(target_pubkey.into()) + .expiry_time(Duration::from_secs(100)) + .build() + .expect("build invoice success"); + + // insert invoice without preimage + node_3.insert_invoice(ckb_invoice.clone(), None); + + let res = source_node + .send_payment(SendPaymentCommand { + target_pubkey: Some(target_pubkey), + amount: Some(100), + invoice: Some(ckb_invoice.to_string()), + ..Default::default() + }) + .await; + + // expect send payment to succeed to wait for preimage + assert!(res.is_ok()); + source_node.wait_until_inflight(payment_hash).await; +} + +#[tokio::test] +async fn test_send_payment_will_fail_with_no_invoice() { init_tracing(); let (nodes, channels) = create_n_nodes_network( @@ -5561,9 +5613,6 @@ async fn test_send_payment_will_fail_with_no_invoice_preimage() { .build() .expect("build invoice success"); - // insert invoice without preimage - node_3.insert_invoice(ckb_invoice.clone(), None); - let res = source_node .send_payment(SendPaymentCommand { target_pubkey: Some(target_pubkey), @@ -5577,8 +5626,8 @@ async fn test_send_payment_will_fail_with_no_invoice_preimage() { assert!(res.is_ok()); let payment_hash = res.unwrap().payment_hash; + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; - source_node.wait_until_failed(payment_hash).await; source_node .assert_payment_status(payment_hash, PaymentStatus::Failed, Some(1)) .await; @@ -5586,11 +5635,7 @@ async fn test_send_payment_will_fail_with_no_invoice_preimage() { let new_amount = node_3.get_local_balance_from_channel(channels[2]); assert_eq!(new_amount, old_amount); - // we should never update the invoice status if there is an error - assert_eq!( - node_3.get_invoice_status(ckb_invoice.payment_hash()), - Some(CkbInvoiceStatus::Open) - ); + assert_eq!(node_3.get_invoice_status(ckb_invoice.payment_hash()), None); } #[tokio::test] @@ -5654,6 +5699,510 @@ async fn test_send_payment_will_fail_with_cancelled_invoice() { .is_some()); } +#[tokio::test] +async fn test_send_payment_succeed_with_hold_invoice_settled_direct_payment() { + init_tracing(); + let _span = tracing::info_span!("node", node = "test").entered(); + let n_nodes = 2; + let (nodes, channels) = + create_n_nodes_network(&[((0, 1), (HUGE_CKB_AMOUNT, MIN_RESERVED_CKB))], n_nodes).await; + let last_channel = *channels.last().unwrap(); + let [mut node_0, node_1] = nodes.try_into().expect("2 nodes"); + let source_node = &mut node_0; + let target_pubkey = node_1.pubkey; + let old_amount = node_1.get_local_balance_from_channel(last_channel); + + let preimage = gen_rand_sha256_hash(); + let ckb_invoice = InvoiceBuilder::new(Currency::Fibd) + .amount(Some(100)) + .payment_preimage(preimage) + .payee_pub_key(target_pubkey.into()) + .expiry_time(Duration::from_secs(100)) + .build() + .expect("build invoice success"); + + node_1.insert_invoice(ckb_invoice.clone(), None); + + let res = source_node + .send_payment(SendPaymentCommand { + invoice: Some(ckb_invoice.to_string()), + ..Default::default() + }) + .await; + + assert!(res.is_ok()); + + let payment_hash = res.unwrap().payment_hash; + + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + source_node + .assert_payment_status(payment_hash, PaymentStatus::Inflight, Some(1)) + .await; + + assert_eq!( + node_1.get_invoice_status(ckb_invoice.payment_hash()), + Some(CkbInvoiceStatus::Received) + ); + let new_amount = node_1.get_local_balance_from_channel(last_channel); + assert_eq!(new_amount, old_amount); + + node_1 + .settle_invoice(ckb_invoice.payment_hash(), &preimage) + .await + .expect("settle invoice success"); + + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + // we should never update the invoice status if there is an error + assert_eq!( + node_1.get_invoice_status(ckb_invoice.payment_hash()), + Some(CkbInvoiceStatus::Paid) + ); + let new_amount = node_1.get_local_balance_from_channel(last_channel); + assert_eq!(new_amount, old_amount + 100); + + source_node + .assert_payment_status(payment_hash, PaymentStatus::Success, Some(1)) + .await; +} + +#[tokio::test] +async fn test_send_payment_succeed_with_hold_invoice_settled_indirect_payment() { + init_tracing(); + let _span = tracing::info_span!("node", node = "test").entered(); + let (nodes, channels) = create_n_nodes_network( + &[ + ((0, 1), (HUGE_CKB_AMOUNT, MIN_RESERVED_CKB)), + ((1, 2), (HUGE_CKB_AMOUNT, MIN_RESERVED_CKB)), + ], + 3, + ) + .await; + let [mut node_0, _node_1, node_2] = nodes.try_into().expect("3 nodes"); + let source_node = &mut node_0; + let target_pubkey = node_2.pubkey; + let old_amount = node_2.get_local_balance_from_channel(channels[1]); + + let preimage = gen_rand_sha256_hash(); + let ckb_invoice = InvoiceBuilder::new(Currency::Fibd) + .amount(Some(100)) + .payment_preimage(preimage) + .payee_pub_key(target_pubkey.into()) + .expiry_time(Duration::from_secs(100)) + .build() + .expect("build invoice success"); + + node_2.insert_invoice(ckb_invoice.clone(), None); + + let res = source_node + .send_payment(SendPaymentCommand { + invoice: Some(ckb_invoice.to_string()), + ..Default::default() + }) + .await; + + assert!(res.is_ok()); + + let payment_hash = res.unwrap().payment_hash; + + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + source_node + .assert_payment_status(payment_hash, PaymentStatus::Inflight, Some(1)) + .await; + + assert_eq!( + node_2.get_invoice_status(ckb_invoice.payment_hash()), + Some(CkbInvoiceStatus::Received) + ); + let new_amount = node_2.get_local_balance_from_channel(channels[1]); + assert_eq!(new_amount, old_amount); + + node_2 + .settle_invoice(ckb_invoice.payment_hash(), &preimage) + .await + .expect("settle invoice success"); + + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + // we should never update the invoice status if there is an error + assert_eq!( + node_2.get_invoice_status(ckb_invoice.payment_hash()), + Some(CkbInvoiceStatus::Paid) + ); + let new_amount = node_2.get_local_balance_from_channel(channels[1]); + assert_eq!(new_amount, old_amount + 100); + + source_node + .assert_payment_status(payment_hash, PaymentStatus::Success, Some(1)) + .await; +} + +#[tokio::test] +async fn test_send_payment_succeed_settle_hold_invoice_multiple_times() { + init_tracing(); + let _span = tracing::info_span!("node", node = "test").entered(); + let _span = tracing::info_span!("node", node = "test").entered(); + let (nodes, channels) = create_n_nodes_network( + &[ + ((0, 1), (HUGE_CKB_AMOUNT, MIN_RESERVED_CKB)), + ((1, 2), (HUGE_CKB_AMOUNT, MIN_RESERVED_CKB)), + ], + 3, + ) + .await; + let [mut node_0, _node_1, node_2] = nodes.try_into().expect("3 nodes"); + let source_node = &mut node_0; + let target_pubkey = node_2.pubkey; + let old_amount = node_2.get_local_balance_from_channel(channels[1]); + + let preimage = gen_rand_sha256_hash(); + let ckb_invoice = InvoiceBuilder::new(Currency::Fibd) + .amount(Some(100)) + .payment_preimage(preimage) + .payee_pub_key(target_pubkey.into()) + .expiry_time(Duration::from_secs(100)) + .build() + .expect("build invoice success"); + + node_2.insert_invoice(ckb_invoice.clone(), None); + + let res = source_node + .send_payment(SendPaymentCommand { + invoice: Some(ckb_invoice.to_string()), + ..Default::default() + }) + .await; + + assert!(res.is_ok()); + + let payment_hash = res.unwrap().payment_hash; + + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + source_node + .assert_payment_status(payment_hash, PaymentStatus::Inflight, Some(1)) + .await; + + assert_eq!( + node_2.get_invoice_status(ckb_invoice.payment_hash()), + Some(CkbInvoiceStatus::Received) + ); + let new_amount = node_2.get_local_balance_from_channel(channels[1]); + assert_eq!(new_amount, old_amount); + + for _i in 0..100 { + node_2 + .settle_invoice(ckb_invoice.payment_hash(), &preimage) + .await + .expect("settle invoice success"); + } + + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + // we should never update the invoice status if there is an error + assert_eq!( + node_2.get_invoice_status(ckb_invoice.payment_hash()), + Some(CkbInvoiceStatus::Paid) + ); + let new_amount = node_2.get_local_balance_from_channel(channels[1]); + assert_eq!(new_amount, old_amount + 100); + + source_node + .assert_payment_status(payment_hash, PaymentStatus::Success, Some(1)) + .await; +} + +// TODO: This test does not work because we currently will not persistently retry forwarding +// RemoveTlc packet. For example, say there is a payment path A -> B -> C, and we have successfully +// sent AddTlc packet from A to B and B to C. When C has also sent RemoveTlc packet to B, and A +// is offline, in this case, the current implementation will not persistently retry forwarding +// RemoveTlc packet from B to C. So the payment will be stuck in the inflight state. +#[ignore] +#[tokio::test] +async fn test_send_payment_succeed_settle_hold_invoice_when_sender_offline() { + init_tracing(); + let _span = tracing::info_span!("node", node = "test").entered(); + let _span = tracing::info_span!("node", node = "test").entered(); + let (nodes, channels) = create_n_nodes_network( + &[ + ((0, 1), (HUGE_CKB_AMOUNT, MIN_RESERVED_CKB)), + ((1, 2), (HUGE_CKB_AMOUNT, MIN_RESERVED_CKB)), + ], + 3, + ) + .await; + let [node_0, _node_1, node_2] = nodes.try_into().expect("3 nodes"); + let mut source_node = node_0; + let target_pubkey = node_2.pubkey; + let old_amount = node_2.get_local_balance_from_channel(channels[1]); + + let preimage = gen_rand_sha256_hash(); + let ckb_invoice = InvoiceBuilder::new(Currency::Fibd) + .amount(Some(100)) + .payment_preimage(preimage) + .payee_pub_key(target_pubkey.into()) + .expiry_time(Duration::from_secs(100)) + .build() + .expect("build invoice success"); + + node_2.insert_invoice(ckb_invoice.clone(), None); + + let res = source_node + .send_payment(SendPaymentCommand { + invoice: Some(ckb_invoice.to_string()), + ..Default::default() + }) + .await; + + assert!(res.is_ok()); + + let payment_hash = res.unwrap().payment_hash; + + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + source_node + .assert_payment_status(payment_hash, PaymentStatus::Inflight, Some(1)) + .await; + + assert_eq!( + node_2.get_invoice_status(ckb_invoice.payment_hash()), + Some(CkbInvoiceStatus::Received) + ); + let new_amount = node_2.get_local_balance_from_channel(channels[1]); + assert_eq!(new_amount, old_amount); + + source_node.stop().await; + node_2 + .settle_invoice(ckb_invoice.payment_hash(), &preimage) + .await + .expect("settle invoice success"); + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + source_node.start().await; + + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + // we should never update the invoice status if there is an error + assert_eq!( + node_2.get_invoice_status(ckb_invoice.payment_hash()), + Some(CkbInvoiceStatus::Paid) + ); + let new_amount = node_2.get_local_balance_from_channel(channels[1]); + assert_eq!(new_amount, old_amount + 100); + + source_node + .assert_payment_status(payment_hash, PaymentStatus::Success, Some(1)) + .await; +} + +#[tokio::test] +async fn test_send_payment_succeed_settle_hold_invoice_when_forwarder_offline() { + init_tracing(); + let _span = tracing::info_span!("node", node = "test").entered(); + let _span = tracing::info_span!("node", node = "test").entered(); + let (nodes, channels) = create_n_nodes_network( + &[ + ((0, 1), (HUGE_CKB_AMOUNT, MIN_RESERVED_CKB)), + ((1, 2), (HUGE_CKB_AMOUNT, MIN_RESERVED_CKB)), + ], + 3, + ) + .await; + let [mut node_0, mut node_1, node_2] = nodes.try_into().expect("3 nodes"); + let source_node = &mut node_0; + let target_pubkey = node_2.pubkey; + let old_amount = node_2.get_local_balance_from_channel(channels[1]); + + let preimage = gen_rand_sha256_hash(); + let ckb_invoice = InvoiceBuilder::new(Currency::Fibd) + .amount(Some(100)) + .payment_preimage(preimage) + .payee_pub_key(target_pubkey.into()) + .expiry_time(Duration::from_secs(100)) + .build() + .expect("build invoice success"); + + node_2.insert_invoice(ckb_invoice.clone(), None); + + let res = source_node + .send_payment(SendPaymentCommand { + invoice: Some(ckb_invoice.to_string()), + ..Default::default() + }) + .await; + + assert!(res.is_ok()); + + let payment_hash = res.unwrap().payment_hash; + + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + source_node + .assert_payment_status(payment_hash, PaymentStatus::Inflight, Some(1)) + .await; + + assert_eq!( + node_2.get_invoice_status(ckb_invoice.payment_hash()), + Some(CkbInvoiceStatus::Received) + ); + let new_amount = node_2.get_local_balance_from_channel(channels[1]); + assert_eq!(new_amount, old_amount); + + node_1.stop().await; + node_2 + .settle_invoice(ckb_invoice.payment_hash(), &preimage) + .await + .expect("settle invoice success"); + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + node_1.start().await; + + source_node.wait_until_success(payment_hash).await; + assert_eq!( + node_2.get_invoice_status(ckb_invoice.payment_hash()), + Some(CkbInvoiceStatus::Paid) + ); + let new_amount = node_2.get_local_balance_from_channel(channels[1]); + assert_eq!(new_amount, old_amount + 100); +} + +#[tokio::test] +async fn test_send_payment_succeed_settle_invoice_before_send_payment() { + init_tracing(); + let _span = tracing::info_span!("node", node = "test").entered(); + let _span = tracing::info_span!("node", node = "test").entered(); + let (nodes, channels) = create_n_nodes_network( + &[ + ((0, 1), (HUGE_CKB_AMOUNT, MIN_RESERVED_CKB)), + ((1, 2), (HUGE_CKB_AMOUNT, MIN_RESERVED_CKB)), + ], + 3, + ) + .await; + let [mut node_0, _node_1, node_2] = nodes.try_into().expect("3 nodes"); + let source_node = &mut node_0; + let target_pubkey = node_2.pubkey; + let old_amount = node_2.get_local_balance_from_channel(channels[1]); + + let preimage = gen_rand_sha256_hash(); + let ckb_invoice = InvoiceBuilder::new(Currency::Fibd) + .amount(Some(100)) + .payment_preimage(preimage) + .payee_pub_key(target_pubkey.into()) + .expiry_time(Duration::from_secs(100)) + .build() + .expect("build invoice success"); + + node_2.insert_invoice(ckb_invoice.clone(), None); + node_2 + .settle_invoice(ckb_invoice.payment_hash(), &preimage) + .await + .expect("settle invoice success"); + + let res = source_node + .send_payment(SendPaymentCommand { + invoice: Some(ckb_invoice.to_string()), + ..Default::default() + }) + .await; + + assert!(res.is_ok()); + + let payment_hash = res.unwrap().payment_hash; + + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + // we should never update the invoice status if there is an error + assert_eq!( + node_2.get_invoice_status(ckb_invoice.payment_hash()), + Some(CkbInvoiceStatus::Paid) + ); + let new_amount = node_2.get_local_balance_from_channel(channels[1]); + assert_eq!(new_amount, old_amount + 100); + + source_node + .assert_payment_status(payment_hash, PaymentStatus::Success, Some(1)) + .await; +} + +#[tokio::test] +async fn test_send_payment_succeed_settle_invoice_with_wrong_then_right_hash() { + init_tracing(); + let _span = tracing::info_span!("node", node = "test").entered(); + let _span = tracing::info_span!("node", node = "test").entered(); + let (nodes, channels) = create_n_nodes_network( + &[ + ((0, 1), (HUGE_CKB_AMOUNT, MIN_RESERVED_CKB)), + ((1, 2), (HUGE_CKB_AMOUNT, MIN_RESERVED_CKB)), + ], + 3, + ) + .await; + let [mut node_0, _node_1, node_2] = nodes.try_into().expect("3 nodes"); + let source_node = &mut node_0; + let target_pubkey = node_2.pubkey; + let old_amount = node_2.get_local_balance_from_channel(channels[1]); + + let preimage = gen_rand_sha256_hash(); + let bogus_preimage = gen_rand_sha256_hash(); + let ckb_invoice = InvoiceBuilder::new(Currency::Fibd) + .amount(Some(100)) + .payment_preimage(preimage) + .payee_pub_key(target_pubkey.into()) + .expiry_time(Duration::from_secs(100)) + .build() + .expect("build invoice success"); + + node_2.insert_invoice(ckb_invoice.clone(), None); + let result = node_2 + .settle_invoice(ckb_invoice.payment_hash(), &bogus_preimage) + .await; + assert!(result.is_err(), "settle with wrong preimage should fail"); + + let res = source_node + .send_payment(SendPaymentCommand { + invoice: Some(ckb_invoice.to_string()), + ..Default::default() + }) + .await; + + assert!(res.is_ok()); + + let payment_hash = res.unwrap().payment_hash; + + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + source_node + .assert_payment_status(payment_hash, PaymentStatus::Inflight, Some(1)) + .await; + + assert_eq!( + node_2.get_invoice_status(ckb_invoice.payment_hash()), + Some(CkbInvoiceStatus::Received) + ); + let new_amount = node_2.get_local_balance_from_channel(channels[1]); + assert_eq!(new_amount, old_amount); + + node_2 + .settle_invoice(ckb_invoice.payment_hash(), &preimage) + .await + .expect("settle invoice success"); + + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + // we should never update the invoice status if there is an error + assert_eq!( + node_2.get_invoice_status(ckb_invoice.payment_hash()), + Some(CkbInvoiceStatus::Paid) + ); + let new_amount = node_2.get_local_balance_from_channel(channels[1]); + assert_eq!(new_amount, old_amount + 100); + + source_node + .assert_payment_status(payment_hash, PaymentStatus::Success, Some(1)) + .await; +} + #[tokio::test] async fn test_send_payment_will_succeed_with_large_tlc_expiry_limit() { init_tracing(); diff --git a/crates/fiber-lib/src/fiber/tests/mpp.rs b/crates/fiber-lib/src/fiber/tests/mpp.rs index c3a5f0584..f393d8a70 100644 --- a/crates/fiber-lib/src/fiber/tests/mpp.rs +++ b/crates/fiber-lib/src/fiber/tests/mpp.rs @@ -2979,7 +2979,7 @@ async fn test_send_mpp_with_generated_invoice() { let too_large_amount_invoice = nodes[1] .gen_invoice(NewInvoiceParams { amount: 20000000001, - payment_preimage: gen_rand_sha256_hash(), + payment_preimage: Some(gen_rand_sha256_hash()), allow_mpp: Some(true), ..Default::default() }) @@ -2999,7 +2999,7 @@ async fn test_send_mpp_with_generated_invoice() { let ok_invoice = nodes[1] .gen_invoice(NewInvoiceParams { amount: 20000000000, - payment_preimage: gen_rand_sha256_hash(), + payment_preimage: Some(gen_rand_sha256_hash()), allow_mpp: Some(true), ..Default::default() }) diff --git a/crates/fiber-lib/src/fiber/tests/network.rs b/crates/fiber-lib/src/fiber/tests/network.rs index 2c4f68516..d43b12723 100644 --- a/crates/fiber-lib/src/fiber/tests/network.rs +++ b/crates/fiber-lib/src/fiber/tests/network.rs @@ -153,22 +153,17 @@ async fn test_set_announced_addrs_with_invalid_peer_id() { async fn test_set_announced_addrs_with_valid_peer_id() { let mut node = NetworkNode::new().await; tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; - node.stop().await; let peer_id = node.get_peer_id(); let addr = format!("/ip4/1.1.1.1/tcp/8346/p2p/{}", peer_id); let multiaddr = Multiaddr::from_str(&addr).expect("valid multiaddr"); - let mut node = NetworkNode::new_with_config( - NetworkNodeConfigBuilder::new() - .base_dir(node.base_dir.clone()) - .fiber_config_updater(move |config| { - config.announced_addrs = vec![addr.clone()]; - }) - .build(), - ) - .await; + node.stop().await; + node.fiber_config.announced_addrs = vec![addr.clone()]; + node.start().await; + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; node.stop().await; + let nodes = node.get_network_graph_nodes().await; assert_eq!(nodes.len(), 1); assert_eq!(nodes[0].node_id, node.get_public_key()); diff --git a/crates/fiber-lib/src/fiber/tests/payment.rs b/crates/fiber-lib/src/fiber/tests/payment.rs index 05d0a91d9..003216eea 100644 --- a/crates/fiber-lib/src/fiber/tests/payment.rs +++ b/crates/fiber-lib/src/fiber/tests/payment.rs @@ -1,4 +1,5 @@ #![allow(clippy::needless_range_loop)] +use crate::ckb::tests::test_utils::get_simple_udt_script; use crate::fiber::channel::*; use crate::fiber::config::DEFAULT_FINAL_TLC_EXPIRY_DELTA; use crate::fiber::config::DEFAULT_TLC_EXPIRY_DELTA; @@ -23,7 +24,6 @@ use crate::tasks::cancel_tasks_and_wait_for_completion; use crate::test_utils::init_tracing; use crate::tests::test_utils::*; use crate::NetworkServiceEvent; -use ckb_types::packed::Script; use ckb_types::{core::tx_pool::TxStatus, packed::OutPoint}; use ractor::call; use secp256k1::Secp256k1; @@ -953,7 +953,7 @@ async fn test_send_payment_hophint_for_mixed_channels_with_udt() { node_a_funding_amount: HUGE_CKB_AMOUNT, node_b_funding_amount: HUGE_CKB_AMOUNT, public: true, // not a private channel - funding_udt_type_script: Some(Script::default()), // a UDT channel + funding_udt_type_script: Some(get_simple_udt_script()), // a UDT channel ..Default::default() }, ), @@ -3383,6 +3383,7 @@ async fn test_send_payment_self_with_two_nodes() { async fn test_send_payment_self_with_mixed_channel() { // #678, payself with mixed channel got wrong init_tracing(); + let udt_script = get_simple_udt_script(); let funding_amount = HUGE_CKB_AMOUNT; let (nodes, _channels) = create_n_nodes_network_with_params( @@ -3402,7 +3403,7 @@ async fn test_send_payment_self_with_mixed_channel() { public: true, node_a_funding_amount: funding_amount, node_b_funding_amount: funding_amount, - funding_udt_type_script: Some(Script::default()), + funding_udt_type_script: Some(udt_script.clone()), ..Default::default() }, ), @@ -3432,7 +3433,7 @@ async fn test_send_payment_self_with_mixed_channel() { public: true, node_a_funding_amount: funding_amount, node_b_funding_amount: funding_amount, - funding_udt_type_script: Some(Script::default()), + funding_udt_type_script: Some(udt_script.clone()), ..Default::default() }, ), @@ -3464,7 +3465,7 @@ async fn test_send_payment_self_with_mixed_channel() { public: true, node_a_funding_amount: funding_amount, node_b_funding_amount: funding_amount, - funding_udt_type_script: Some(Script::default()), + funding_udt_type_script: Some(udt_script.clone()), ..Default::default() }, ), @@ -3474,7 +3475,7 @@ async fn test_send_payment_self_with_mixed_channel() { public: true, node_a_funding_amount: funding_amount, node_b_funding_amount: funding_amount, - funding_udt_type_script: Some(Script::default()), + funding_udt_type_script: Some(udt_script.clone()), ..Default::default() }, ), @@ -3484,7 +3485,7 @@ async fn test_send_payment_self_with_mixed_channel() { public: true, node_a_funding_amount: funding_amount, node_b_funding_amount: funding_amount, - funding_udt_type_script: Some(Script::default()), + funding_udt_type_script: Some(udt_script.clone()), ..Default::default() }, ), @@ -3500,7 +3501,7 @@ async fn test_send_payment_self_with_mixed_channel() { amount: Some(1000), keysend: Some(true), allow_self_payment: true, - udt_type_script: Some(Script::default()), + udt_type_script: Some(udt_script.clone()), ..Default::default() }) .await; @@ -4972,6 +4973,7 @@ async fn test_send_payment_no_preimage_invoice_will_make_payment_failed() { async fn test_send_payment_with_mixed_channel_hops() { init_tracing(); let _span = tracing::info_span!("node", node = "test").entered(); + let udt_script = get_simple_udt_script(); let (nodes, channels) = create_n_nodes_network( &[ ((0, 1), (HUGE_CKB_AMOUNT, HUGE_CKB_AMOUNT)), @@ -4990,7 +4992,7 @@ async fn test_send_payment_with_mixed_channel_hops() { public: false, node_a_funding_amount: HUGE_CKB_AMOUNT, node_b_funding_amount: HUGE_CKB_AMOUNT, - funding_udt_type_script: Some(Script::default()), // UDT type + funding_udt_type_script: Some(udt_script.clone()), // UDT type ..Default::default() }, ) diff --git a/crates/fiber-lib/src/fiber/tests/rpc.rs b/crates/fiber-lib/src/fiber/tests/rpc.rs index cf94c17cb..40a41bbcf 100644 --- a/crates/fiber-lib/src/fiber/tests/rpc.rs +++ b/crates/fiber-lib/src/fiber/tests/rpc.rs @@ -1,6 +1,7 @@ #![allow(clippy::needless_range_loop)] use crate::fiber::channel::CloseFlags; use crate::fiber::network::PeerDisconnectReason; +use crate::fiber::types::Hash256; use crate::fiber::{NetworkActorCommand, NetworkActorMessage}; use crate::gen_rand_sha256_hash; use crate::invoice::CkbInvoice; @@ -9,7 +10,6 @@ use crate::rpc::config::RpcConfig; use crate::rpc::info::NodeInfoResult; use crate::tests::*; use crate::{ - fiber::types::Hash256, invoice::Currency, rpc::{ channel::{ListChannelsParams, ListChannelsResult}, @@ -95,7 +95,8 @@ async fn test_rpc_basic() { fallback_address: None, final_expiry_delta: Some(900000 + 1234), udt_type_script: Some(Script::default().into()), - payment_preimage: Hash256::default(), + payment_hash: None, + payment_preimage: Some(Hash256::default()), hash_algorithm: Some(crate::fiber::hash_algorithm::HashAlgorithm::CkbHash), allow_mpp: Some(true), }; @@ -146,7 +147,8 @@ async fn test_rpc_basic() { fallback_address: None, final_expiry_delta: Some(900000 + 1234), udt_type_script: Some(Script::default().into()), - payment_preimage: gen_rand_sha256_hash(), + payment_preimage: Some(gen_rand_sha256_hash()), + payment_hash: None, hash_algorithm: Some(crate::fiber::hash_algorithm::HashAlgorithm::CkbHash), allow_mpp: Some(false), }; @@ -537,7 +539,8 @@ async fn test_rpc_basic_with_auth() { fallback_address: None, final_expiry_delta: Some(900000 + 1234), udt_type_script: Some(Script::default().into()), - payment_preimage: Hash256::default(), + payment_preimage: None, + payment_hash: None, hash_algorithm: Some(crate::fiber::hash_algorithm::HashAlgorithm::CkbHash), allow_mpp: None, }, diff --git a/crates/fiber-lib/src/fiber/types.rs b/crates/fiber-lib/src/fiber/types.rs index bb4614570..73a46bd8a 100644 --- a/crates/fiber-lib/src/fiber/types.rs +++ b/crates/fiber-lib/src/fiber/types.rs @@ -13,6 +13,7 @@ use super::serde_utils::{EntityHex, PubNonceAsBytes, SliceBase58, SliceHex}; use crate::ckb::config::{UdtArgInfo, UdtCellDep, UdtCfgInfos, UdtDep, UdtScript}; use crate::ckb::contracts::get_udt_whitelist; use crate::fiber::network::USER_CUSTOM_RECORDS_MAX_INDEX; +use bitcoin::hashes::Hash; use ckb_jsonrpc_types::CellOutput; use ckb_types::H256; use num_enum::IntoPrimitive; @@ -202,6 +203,31 @@ impl From for Hash256 { } } +impl From for Hash256 { + fn from(value: lightning_invoice::Sha256) -> Self { + Hash256(value.0.to_byte_array()) + } +} + +impl From for Hash256 { + fn from(value: bitcoin::hashes::sha256::Hash) -> Self { + Hash256(value.to_byte_array()) + } +} + +impl TryFrom<&[u8]> for Hash256 { + type Error = anyhow::Error; + + fn try_from(value: &[u8]) -> Result { + if value.len() != 32 { + return Err(anyhow!("Invalid hash length")); + } + let mut data = [0u8; 32]; + data.copy_from_slice(value); + Ok(Hash256(data)) + } +} + fn u8_32_as_byte_32(value: &[u8; 32]) -> MByte32 { MByte32::from_slice(value.as_slice()).expect("[u8; 32] to Byte32") } @@ -243,6 +269,18 @@ impl FromStr for Hash256 { } } +impl From for [u8; 32] { + fn from(val: Hash256) -> Self { + val.0 + } +} + +impl From for Vec { + fn from(val: Hash256) -> Self { + val.0.to_vec() + } +} + impl Privkey { pub fn from_slice(key: &[u8]) -> Self { SecretKey::from_slice(key) diff --git a/crates/fiber-lib/src/invoice/errors.rs b/crates/fiber-lib/src/invoice/errors.rs index 2c283d1e9..89347decd 100644 --- a/crates/fiber-lib/src/invoice/errors.rs +++ b/crates/fiber-lib/src/invoice/errors.rs @@ -50,6 +50,9 @@ pub enum InvoiceError { /// Both set payment_hash and payment_preimage #[error("Both payment_hash and payment_preimage are set")] BothPaymenthashAndPreimage, + /// Neither payment_hash nor payment_preimage is set + #[error("Neither payment_hash nor payment_preimage is set")] + NeitherPaymenthashNorPreimage, /// An error occurred during signing #[error("Sign error")] SignError, @@ -61,4 +64,6 @@ pub enum InvoiceError { DescriptionTooLong(usize), #[error("Invoice not found")] InvoiceNotFound, + #[error("Invoice already exists")] + InvoiceAlreadyExists, } diff --git a/crates/fiber-lib/src/invoice/invoice_impl.rs b/crates/fiber-lib/src/invoice/invoice_impl.rs index 7348eed96..23c381767 100644 --- a/crates/fiber-lib/src/invoice/invoice_impl.rs +++ b/crates/fiber-lib/src/invoice/invoice_impl.rs @@ -5,7 +5,6 @@ use crate::fiber::gen::invoice::{self as gen_invoice, *}; use crate::fiber::hash_algorithm::HashAlgorithm; use crate::fiber::serde_utils::{duration_hex, EntityHex, U128Hex, U64Hex}; use crate::fiber::types::Hash256; -use crate::gen_rand_sha256_hash; use crate::invoice::InvoiceError; use bech32::{encode, u5, FromBase32, ToBase32, Variant, WriteBase32}; use bitcoin::hashes::{sha256::Hash as Sha256, Hash as _}; @@ -646,11 +645,19 @@ impl InvoiceBuilder { self } + /// The hash of the preimage. If hash is set, preimage must be absent. + /// This condition indicates a 'hold invoice' for which the tlc must be + /// accepted and held until the preimage becomes known. pub fn payment_hash(mut self, payment_hash: Hash256) -> Self { self.payment_hash = Some(payment_hash); self } + /// The preimage to settle an incoming TLC payable to this invoice. + /// If preimage is set, hash must be absent. If both preimage and hash + /// are absent, a random preimage should be generated and passed into + /// the invoice builder, otherwise NeitherPaymenthashNorPreimage error + /// is thrown. pub fn payment_preimage(mut self, payment_preimage: Hash256) -> Self { self.payment_preimage = Some(payment_preimage); self @@ -690,27 +697,22 @@ impl InvoiceBuilder { } pub fn build(self) -> Result { - let preimage = self.payment_preimage; - - if self.payment_hash.is_some() && preimage.is_some() { - return Err(InvoiceError::BothPaymenthashAndPreimage); - } - let payment_hash: Hash256 = if let Some(preimage) = preimage { - let algo = self - .attrs - .iter() - .find_map(|attr| match attr { - Attribute::HashAlgorithm(algo) => Some(algo), - _ => None, - }) - .copied() - .unwrap_or_default(); - algo.hash(preimage.as_ref()).into() - } else if let Some(payment_hash) = self.payment_hash { - payment_hash - } else { - // generate a random payment hash if not provided - gen_rand_sha256_hash() + let payment_hash: Hash256 = match (self.payment_hash, self.payment_preimage) { + (Some(payment_hash), None) => payment_hash, + (None, Some(preimage)) => { + let algo = self + .attrs + .iter() + .find_map(|attr| match attr { + Attribute::HashAlgorithm(algo) => Some(algo), + _ => None, + }) + .copied() + .unwrap_or_default(); + algo.hash(preimage.as_ref()).into() + } + (Some(_), Some(_)) => return Err(InvoiceError::BothPaymenthashAndPreimage), + (None, None) => return Err(InvoiceError::NeitherPaymenthashNorPreimage), }; self.check_attrs_valid()?; diff --git a/crates/fiber-lib/src/invoice/store.rs b/crates/fiber-lib/src/invoice/store.rs index 26b9f7103..c4ef037b5 100644 --- a/crates/fiber-lib/src/invoice/store.rs +++ b/crates/fiber-lib/src/invoice/store.rs @@ -1,3 +1,6 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; + use super::{CkbInvoiceStatus, InvoiceError}; use crate::{fiber::types::Hash256, invoice::CkbInvoice}; @@ -14,6 +17,51 @@ pub trait InvoiceStore { status: CkbInvoiceStatus, ) -> Result<(), InvoiceError>; fn get_invoice_status(&self, id: &Hash256) -> Option; + // A payment to an invoice is made by sending a TLC over some channels + // (possibly multiple when atomic multi-path payment support is out). + // This function returns all the channels that were used to pay an invoice. + fn get_invoice_channel_info(&self, payment_hash: &Hash256) -> Vec; + // This function is used to add a channel (with the amount paid through this channel) + // to the list of channels that were used to pay an invoice. + fn add_invoice_channel_info( + &self, + payment_hash: &Hash256, + invoice_channel_info: InvoiceChannelInfo, + ) -> Result, InvoiceError>; +} + +#[derive(Copy, Clone, Serialize, Deserialize)] +pub struct InvoiceChannelInfo { + pub channel_id: Hash256, + pub amount: u128, +} + +impl InvoiceChannelInfo { + pub fn new(channel_id: Hash256, amount: u128) -> Self { + Self { channel_id, amount } + } +} + +#[derive(Error, Debug)] +pub enum SettleInvoiceError { + #[error("Invoice not found")] + InvoiceNotFound, + #[error("Hash mismatch")] + HashMismatch, + #[error("Internal error: {0}")] + InternalError(String), +} + +pub(crate) fn add_invoice( + store: &S, + invoice: CkbInvoice, + preimage: Option, +) -> Result<(), InvoiceError> { + let hash = invoice.payment_hash(); + if store.get_invoice(hash).is_some() { + return Err(InvoiceError::InvoiceAlreadyExists); + } + store.insert_invoice(invoice, preimage) } pub trait PreimageStore { @@ -26,3 +74,24 @@ pub trait PreimageStore { /// Get a preimage from the store. fn get_preimage(&self, payment_hash: &Hash256) -> Option; } + +/// Used for delegating the store trait +pub trait PreimageStoreDeref { + type Target: PreimageStore; + fn preimage_store_deref(&self) -> &Self::Target; +} + +impl PreimageStore for T { + fn insert_preimage(&self, payment_hash: Hash256, preimage: Hash256) { + self.preimage_store_deref() + .insert_preimage(payment_hash, preimage); + } + + fn remove_preimage(&self, payment_hash: &Hash256) { + self.preimage_store_deref().remove_preimage(payment_hash); + } + + fn get_preimage(&self, payment_hash: &Hash256) -> Option { + self.preimage_store_deref().get_preimage(payment_hash) + } +} diff --git a/crates/fiber-lib/src/invoice/tests/invoice_impl.rs b/crates/fiber-lib/src/invoice/tests/invoice_impl.rs index e4f8ff4d3..e2d9a3221 100644 --- a/crates/fiber-lib/src/invoice/tests/invoice_impl.rs +++ b/crates/fiber-lib/src/invoice/tests/invoice_impl.rs @@ -415,6 +415,20 @@ fn test_invoice_builder_both_payment_hash_preimage() { ); } +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn test_invoice_builder_neither_payment_hash_not_preimage() { + let private_key = gen_rand_secp256k1_private_key(); + let invoice = InvoiceBuilder::new(Currency::Fibb) + .amount(Some(1280)) + .build_with_sign(|hash| Secp256k1::new().sign_ecdsa_recoverable(hash, &private_key)); + + assert_eq!( + invoice.err(), + Some(InvoiceError::NeitherPaymenthashNorPreimage) + ); +} + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] #[cfg_attr(not(target_arch = "wasm32"), test)] fn test_invoice_serialize() { @@ -485,16 +499,6 @@ fn test_invoice_gen_payment_hash() { assert_eq!(expected_hash, *payment_hash); } -#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] -#[cfg_attr(not(target_arch = "wasm32"), test)] -fn test_invoice_rand_payment_hash() { - let private_key = gen_rand_secp256k1_private_key(); - let invoice = InvoiceBuilder::new(Currency::Fibb) - .amount(Some(1280)) - .build_with_sign(|hash| Secp256k1::new().sign_ecdsa_recoverable(hash, &private_key)); - assert!(invoice.is_ok()); -} - #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] #[cfg_attr(not(target_arch = "wasm32"), test)] fn test_invoice_udt_script() { diff --git a/crates/fiber-lib/src/lib.rs b/crates/fiber-lib/src/lib.rs index 1fc5aa2d6..5e1698f72 100644 --- a/crates/fiber-lib/src/lib.rs +++ b/crates/fiber-lib/src/lib.rs @@ -15,7 +15,7 @@ pub use fiber::{start_network, FiberConfig, NetworkServiceEvent}; #[cfg(not(target_arch = "wasm32"))] pub mod cch; #[cfg(not(target_arch = "wasm32"))] -pub use cch::{start_cch, CchActor, CchConfig}; +pub use cch::{start_cch, CchActor, CchArgs, CchConfig}; pub mod invoice; pub mod rpc; diff --git a/crates/fiber-lib/src/rpc/README.md b/crates/fiber-lib/src/rpc/README.md index dbec37575..b14e6fc55 100644 --- a/crates/fiber-lib/src/rpc/README.md +++ b/crates/fiber-lib/src/rpc/README.md @@ -17,7 +17,7 @@ You may refer to the e2e test cases in the `tests/bruno/e2e` directory for examp * [Module Cch](#module-cch) * [Method `send_btc`](#cch-send_btc) * [Method `receive_btc`](#cch-receive_btc) - * [Method `get_receive_btc_order`](#cch-get_receive_btc_order) + * [Method `get_cch_order`](#cch-get_cch_order) * [Module Channel](#module-channel) * [Method `open_channel`](#channel-open_channel) * [Method `accept_channel`](#channel-accept_channel) @@ -41,6 +41,7 @@ You may refer to the e2e test cases in the `tests/bruno/e2e` directory for examp * [Method `parse_invoice`](#invoice-parse_invoice) * [Method `get_invoice`](#invoice-get_invoice) * [Method `cancel_invoice`](#invoice-cancel_invoice) + * [Method `settle_invoice`](#invoice-settle_invoice) * [Module Payment](#module-payment) * [Method `send_payment`](#payment-send_payment) * [Method `get_payment`](#payment-get_payment) @@ -60,6 +61,7 @@ You may refer to the e2e test cases in the `tests/bruno/e2e` directory for examp * [RPC Types](#rpc-types) * [Type `Attribute`](#type-attribute) + * [Type `CchInvoice`](#type-cchinvoice) * [Type `CchOrderStatus`](#type-cchorderstatus) * [Type `Channel`](#type-channel) * [Type `ChannelInfo`](#type-channelinfo) @@ -116,11 +118,10 @@ Send BTC to a address. * `timestamp` - `u64`, Seconds since epoch when the order is created * `expiry` - `u64`, Seconds after timestamp that the order expires * `ckb_final_tlc_expiry_delta` - `u64`, The minimal expiry in seconds of the final TLC in the CKB network -* `currency` - [Currency](#type-currency), Request currency * `wrapped_btc_type_script` - `ckb_jsonrpc_types::Script`, Wrapped BTC type script -* `btc_pay_req` - `String`, Payment request for BTC -* `ckb_pay_req` - `String`, Payment request for CKB -* `payment_hash` - `String`, Payment hash for the HTLC for both CKB and BTC. +* `incoming_invoice` - [CchInvoice](#type-cchinvoice), Generated invoice for the incoming payment +* `outgoing_pay_req` - `String`, The final payee to accept the payment. It has the different network with incoming invoice. +* `payment_hash` - [Hash256](#type-hash256), Payment hash for the HTLC for both CKB and BTC. * `amount_sats` - `u128`, Amount required to pay in Satoshis, including fee * `fee_sats` - `u128`, Fee in Satoshis * `status` - [CchOrderStatus](#type-cchorderstatus), Order status @@ -136,10 +137,7 @@ Receive BTC from a payment hash. ##### Params -* `payment_hash` - `String`, Payment hash for the HTLC for both CKB and BTC. -* `channel_id` - [Hash256](#type-hash256), Channel ID for the CKB payment. -* `amount_sats` - `u128`, How many satoshis to receive, excluding cross-chain hub fee. -* `final_tlc_expiry` - `u64`, Expiry set for the HTLC for the CKB payment to the payee. +* `fiber_pay_req` - `String`, Fiber payment request string ##### Returns @@ -147,11 +145,10 @@ Receive BTC from a payment hash. * `expiry` - `u64`, Seconds after timestamp that the order expires * `ckb_final_tlc_expiry_delta` - `u64`, The minimal expiry in seconds of the final TLC in the CKB network * `wrapped_btc_type_script` - `ckb_jsonrpc_types::Script`, Wrapped BTC type script -* `btc_pay_req` - `String`, Payment request for BTC -* `payment_hash` - `String`, Payment hash for the HTLC for both CKB and BTC. -* `channel_id` - [Hash256](#type-hash256), Channel ID for the CKB payment. -* `tlc_id` - `Option`, TLC ID for the CKB payment. -* `amount_sats` - `u128`, Amount will be received by the payee +* `incoming_invoice` - [CchInvoice](#type-cchinvoice), Generated invoice for the incoming payment +* `outgoing_pay_req` - `String`, The final payee to accept the payment. It has the different network with incoming invoice. +* `payment_hash` - [Hash256](#type-hash256), Payment hash for the HTLC for both CKB and BTC. +* `amount_sats` - `u128`, Amount required to pay in Satoshis, including fee * `fee_sats` - `u128`, Fee in Satoshis * `status` - [CchOrderStatus](#type-cchorderstatus), Order status @@ -159,14 +156,14 @@ Receive BTC from a payment hash. - -#### Method `get_receive_btc_order` + +#### Method `get_cch_order` Get receive BTC order by payment hash. ##### Params -* `payment_hash` - `String`, Payment hash for the HTLC for both CKB and BTC. +* `payment_hash` - [Hash256](#type-hash256), Payment hash for the HTLC for both CKB and BTC. ##### Returns @@ -174,11 +171,10 @@ Get receive BTC order by payment hash. * `expiry` - `u64`, Seconds after timestamp that the order expires * `ckb_final_tlc_expiry_delta` - `u64`, The minimal expiry in seconds of the final TLC in the CKB network * `wrapped_btc_type_script` - `ckb_jsonrpc_types::Script`, Wrapped BTC type script -* `btc_pay_req` - `String`, Payment request for BTC -* `payment_hash` - `String`, Payment hash for the HTLC for both CKB and BTC. -* `channel_id` - [Hash256](#type-hash256), Channel ID for the CKB payment. -* `tlc_id` - `Option`, TLC ID for the CKB payment. -* `amount_sats` - `u128`, Amount will be received by the payee +* `incoming_invoice` - [CchInvoice](#type-cchinvoice), Generated invoice for the incoming payment +* `outgoing_pay_req` - `String`, The final payee to accept the payment. It has the different network with incoming invoice. +* `payment_hash` - [Hash256](#type-hash256), Payment hash for the HTLC for both CKB and BTC. +* `amount_sats` - `u128`, Amount required to pay in Satoshis, including fee * `fee_sats` - `u128`, Fee in Satoshis * `status` - [CchOrderStatus](#type-cchorderstatus), Order status @@ -539,7 +535,8 @@ Generates a new invoice. * `amount` - `u128`, The amount of the invoice. * `description` - `Option`, The description of the invoice. * `currency` - [Currency](#type-currency), The currency of the invoice. -* `payment_preimage` - [Hash256](#type-hash256), The payment preimage of the invoice. +* `payment_preimage` - Option<[Hash256](#type-hash256)>, The preimage to settle an incoming TLC payable to this invoice. If preimage is set, hash must be absent. If both preimage and hash are absent, a random preimage is generated. +* `payment_hash` - Option<[Hash256](#type-hash256)>, The hash of the preimage. If hash is set, preimage must be absent. This condition indicates a 'hold invoice' for which the tlc must be accepted and held until the preimage becomes known. * `expiry` - `Option`, The expiry time of the invoice, in seconds. * `fallback_address` - `Option`, The fallback address of the invoice. * `final_expiry_delta` - `Option`, The final HTLC timeout of the invoice, in milliseconds. @@ -612,6 +609,24 @@ Cancels an invoice, only when invoice is in status `Open` can be canceled. + +#### Method `settle_invoice` + +Settles an invoice by saving the preimage to this invoice. + +##### Params + +* `payment_hash` - [Hash256](#type-hash256), The payment hash of the invoice. +* `payment_preimage` - [Hash256](#type-hash256), The payment preimage of the invoice. + +##### Returns + +* None + +--- + + + ### Module `Payment` RPC module for channel management. @@ -993,6 +1008,24 @@ The attributes of the invoice * `PaymentSecret` - [Hash256](#type-hash256), The payment secret of the invoice --- + +### Type `CchInvoice` + +The generated proxy invoice for the incoming payment. + + The JSON representation: + + ```text + { "Fiber": String } | { "Lightning": String } + ``` + + +#### Enum with values of + +* `Fiber` - [CkbInvoice](#type-ckbinvoice), Fiber invoice that once paid, the hub will send the outgoing payment to Lightning +* `Lightning` - `Bolt11Invoice`, Lightning invoice that once paid, the hub will send the outgoing payment to Fiber +--- + ### Type `CchOrderStatus` @@ -1002,9 +1035,10 @@ The status of a cross-chain hub order, will update as the order progresses. #### Enum with values of * `Pending` - Order is created and has not send out payments yet. -* `Accepted` - HTLC in the first half is accepted. -* `InFlight` - There's an outgoing payment in flight for the second half. -* `Succeeded` - Order is settled. +* `IncomingAccepted` - HTLC in the incoming payment is accepted. +* `OutgoingInFlight` - There's an outgoing payment in flight. +* `OutgoingSettled` - The outgoing payment is settled. +* `Succeeded` - Both payments are settled and the order succeeds. * `Failed` - Order is failed. --- diff --git a/crates/fiber-lib/src/rpc/biscuit.rs b/crates/fiber-lib/src/rpc/biscuit.rs index d85ea70d7..e5c5b463c 100644 --- a/crates/fiber-lib/src/rpc/biscuit.rs +++ b/crates/fiber-lib/src/rpc/biscuit.rs @@ -77,7 +77,8 @@ fn build_rules() -> HashMap<&'static str, AuthRule> { // Cch b.rule("send_btc", r#"allow if write("cch");"#); b.rule("receive_btc", r#"allow if read("cch");"#); - b.rule("get_receive_btc_order", r#"allow if read("cch");"#); + b.rule("get_cch_order", r#"allow if read("cch");"#); + b.rule("subscribe_store_changes", r#"allow if read("cch");"#); // channels b.rule("open_channel", r#"allow if write("channels");"#); b.rule("accept_channel", r#"allow if write("channels");"#); @@ -104,6 +105,7 @@ fn build_rules() -> HashMap<&'static str, AuthRule> { b.rule("parse_invoice", r#"allow if read("invoices");"#); b.rule("get_invoice", r#"allow if read("invoices");"#); b.rule("cancel_invoice", r#"allow if write("invoices");"#); + b.rule("settle_invoice", r#"allow if write("invoices");"#); // payment b.rule("send_payment", r#"allow if write("payments");"#); diff --git a/crates/fiber-lib/src/rpc/cch.rs b/crates/fiber-lib/src/rpc/cch.rs index eed329d44..c2754e5f9 100644 --- a/crates/fiber-lib/src/rpc/cch.rs +++ b/crates/fiber-lib/src/rpc/cch.rs @@ -1,6 +1,7 @@ #[cfg(not(target_arch = "wasm32"))] -use crate::cch::{CchMessage, CchOrderStatus, ReceiveBTCOrder}; +use crate::cch::{CchMessage, CchOrder, CchOrderStatus}; use crate::{ + cch::CchInvoice, fiber::{ serde_utils::{U128Hex, U64Hex}, types::Hash256, @@ -17,7 +18,7 @@ use serde::{Deserialize, Serialize}; use serde_with::serde_as; #[derive(Serialize, Deserialize)] -pub struct SendBtcParams { +pub struct SendBTCParams { /// Bitcoin payment request string pub btc_pay_req: String, /// Request currency @@ -26,7 +27,7 @@ pub struct SendBtcParams { #[serde_as] #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SendBTCResponse { +pub struct CchOrderResponse { /// Seconds since epoch when the order is created #[serde_as(as = "U64Hex")] pub timestamp: u64, @@ -37,17 +38,15 @@ pub struct SendBTCResponse { #[serde_as(as = "U64Hex")] pub ckb_final_tlc_expiry_delta: u64, - /// Request currency - pub currency: Currency, /// Wrapped BTC type script pub wrapped_btc_type_script: ckb_jsonrpc_types::Script, - /// Payment request for BTC - pub btc_pay_req: String, - /// Payment request for CKB - pub ckb_pay_req: String, + /// Generated invoice for the incoming payment + pub incoming_invoice: CchInvoice, + /// The final payee to accept the payment. It has the different network with incoming invoice. + pub outgoing_pay_req: String, /// Payment hash for the HTLC for both CKB and BTC. - pub payment_hash: String, + pub payment_hash: Hash256, /// Amount required to pay in Satoshis, including fee #[serde_as(as = "U128Hex")] pub amount_sats: u128, @@ -60,60 +59,15 @@ pub struct SendBTCResponse { #[serde_as] #[derive(Serialize, Deserialize)] -pub struct ReceiveBtcParams { - /// Payment hash for the HTLC for both CKB and BTC. - pub payment_hash: String, - /// Channel ID for the CKB payment. - pub channel_id: Hash256, - /// How many satoshis to receive, excluding cross-chain hub fee. - #[serde_as(as = "U128Hex")] - pub amount_sats: u128, - /// Expiry set for the HTLC for the CKB payment to the payee. - #[serde_as(as = "U64Hex")] - pub final_tlc_expiry: u64, +pub struct ReceiveBTCParams { + /// Fiber payment request string + pub fiber_pay_req: String, } #[derive(Serialize, Deserialize)] -pub struct GetReceiveBtcOrderParams { +pub struct GetCchOrderParams { /// Payment hash for the HTLC for both CKB and BTC. - pub payment_hash: String, -} - -#[serde_as] -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReceiveBTCResponse { - /// Seconds since epoch when the order is created - #[serde_as(as = "U64Hex")] - pub timestamp: u64, - /// Seconds after timestamp that the order expires - #[serde_as(as = "U64Hex")] - pub expiry: u64, - /// The minimal expiry in seconds of the final TLC in the CKB network - #[serde_as(as = "U64Hex")] - pub ckb_final_tlc_expiry_delta: u64, - - /// Wrapped BTC type script - pub wrapped_btc_type_script: ckb_jsonrpc_types::Script, - - /// Payment request for BTC - pub btc_pay_req: String, - /// Payment hash for the HTLC for both CKB and BTC. - pub payment_hash: String, - /// Channel ID for the CKB payment. - pub channel_id: Hash256, - /// TLC ID for the CKB payment. - #[serde_as(as = "Option")] - pub tlc_id: Option, - - /// Amount will be received by the payee - #[serde_as(as = "U128Hex")] - pub amount_sats: u128, - /// Fee in Satoshis - #[serde_as(as = "U128Hex")] - pub fee_sats: u128, - - /// Order status - pub status: CchOrderStatus, + pub payment_hash: Hash256, } /// RPC module for cross chain hub demonstration. @@ -123,21 +77,21 @@ pub struct ReceiveBTCResponse { trait CchRpc { /// Send BTC to a address. #[method(name = "send_btc")] - async fn send_btc(&self, params: SendBtcParams) -> Result; + async fn send_btc(&self, params: SendBTCParams) -> Result; /// Receive BTC from a payment hash. #[method(name = "receive_btc")] async fn receive_btc( &self, - params: ReceiveBtcParams, - ) -> Result; + params: ReceiveBTCParams, + ) -> Result; /// Get receive BTC order by payment hash. - #[method(name = "get_receive_btc_order")] - async fn get_receive_btc_order( + #[method(name = "get_cch_order")] + async fn get_cch_order( &self, - params: GetReceiveBtcOrderParams, - ) -> Result; + params: GetCchOrderParams, + ) -> Result; } pub struct CchRpcServerImpl { @@ -155,7 +109,7 @@ const TIMEOUT: u64 = 1000; #[async_trait::async_trait] impl CchRpcServer for CchRpcServerImpl { /// Send BTC to a address. - async fn send_btc(&self, params: SendBtcParams) -> Result { + async fn send_btc(&self, params: SendBTCParams) -> Result { // ::send_btc(self, params).await self.send_btc(params).await } @@ -163,23 +117,23 @@ impl CchRpcServer for CchRpcServerImpl { /// Receive BTC from a payment hash. async fn receive_btc( &self, - params: ReceiveBtcParams, - ) -> Result { + params: ReceiveBTCParams, + ) -> Result { self.receive_btc(params).await } - /// Get receive BTC order by payment hash. - async fn get_receive_btc_order( + /// Get order by payment hash. + async fn get_cch_order( &self, - params: GetReceiveBtcOrderParams, - ) -> Result { - self.get_receive_btc_order(params).await + params: GetCchOrderParams, + ) -> Result { + self.get_cch_order(params).await } } // #[async_trait::async_trait(?Send)] impl CchRpcServerImpl { - async fn send_btc(&self, params: SendBtcParams) -> Result { + async fn send_btc(&self, params: SendBTCParams) -> Result { let result = call_t!( self.cch_actor, CchMessage::SendBTC, @@ -197,36 +151,19 @@ impl CchRpcServerImpl { ) })?; - result - .map(|order| SendBTCResponse { - timestamp: order.created_at, - expiry: order.expires_after, - ckb_final_tlc_expiry_delta: order.ckb_final_tlc_expiry_delta, - currency: order.currency, - wrapped_btc_type_script: order.wrapped_btc_type_script, - btc_pay_req: order.btc_pay_req, - ckb_pay_req: order.ckb_pay_req, - payment_hash: order.payment_hash, - amount_sats: order.amount_sats, - fee_sats: order.fee_sats, - status: order.status, - }) - .map_err(Into::into) + result.map(Into::into).map_err(Into::into) } async fn receive_btc( &self, - params: ReceiveBtcParams, - ) -> Result { + params: ReceiveBTCParams, + ) -> Result { let result = call_t!( self.cch_actor, CchMessage::ReceiveBTC, TIMEOUT, crate::cch::ReceiveBTC { - payment_hash: params.payment_hash, - channel_id: params.channel_id, - amount_sats: params.amount_sats, - final_tlc_expiry: params.final_tlc_expiry, + fiber_pay_req: params.fiber_pay_req, } ) .map_err(|ractor_error| { @@ -240,13 +177,13 @@ impl CchRpcServerImpl { result.map(Into::into).map_err(Into::into) } - async fn get_receive_btc_order( + async fn get_cch_order( &self, - params: GetReceiveBtcOrderParams, - ) -> Result { + params: GetCchOrderParams, + ) -> Result { let result = call_t!( self.cch_actor, - CchMessage::GetReceiveBTCOrder, + CchMessage::GetCchOrder, TIMEOUT, params.payment_hash ) @@ -262,17 +199,16 @@ impl CchRpcServerImpl { } } -impl From for ReceiveBTCResponse { - fn from(value: ReceiveBTCOrder) -> Self { +impl From for CchOrderResponse { + fn from(value: CchOrder) -> Self { Self { timestamp: value.created_at, expiry: value.expires_after, ckb_final_tlc_expiry_delta: value.ckb_final_tlc_expiry_delta, wrapped_btc_type_script: value.wrapped_btc_type_script, - btc_pay_req: value.btc_pay_req, + outgoing_pay_req: value.outgoing_pay_req, + incoming_invoice: value.incoming_invoice, payment_hash: value.payment_hash, - channel_id: value.channel_id, - tlc_id: value.tlc_id, amount_sats: value.amount_sats, fee_sats: value.fee_sats, status: value.status, diff --git a/crates/fiber-lib/src/rpc/config.rs b/crates/fiber-lib/src/rpc/config.rs index d12ab1cfd..edadf41e2 100644 --- a/crates/fiber-lib/src/rpc/config.rs +++ b/crates/fiber-lib/src/rpc/config.rs @@ -1,9 +1,10 @@ use clap_serde_derive::ClapSerde; #[cfg(not(feature = "watchtower"))] -const DEFAULT_ENABLED_MODULES: &str = "cch,channel,graph,payment,info,invoice,peer"; +const DEFAULT_ENABLED_MODULES: &str = "cch,channel,graph,payment,info,invoice,peer,pubsub"; #[cfg(feature = "watchtower")] -const DEFAULT_ENABLED_MODULES: &str = "cch,channel,graph,payment,info,invoice,peer,watchtower"; +const DEFAULT_ENABLED_MODULES: &str = + "cch,channel,graph,payment,info,invoice,peer,pubsub,watchtower"; #[derive(ClapSerde, Debug, Clone)] pub struct RpcConfig { diff --git a/crates/fiber-lib/src/rpc/invoice.rs b/crates/fiber-lib/src/rpc/invoice.rs index 86c0f6057..e0923173e 100644 --- a/crates/fiber-lib/src/rpc/invoice.rs +++ b/crates/fiber-lib/src/rpc/invoice.rs @@ -8,17 +8,20 @@ use crate::fiber::features::FeatureVector; use crate::fiber::hash_algorithm::HashAlgorithm; use crate::fiber::serde_utils::{duration_hex, U128Hex, U64Hex}; use crate::fiber::types::{Hash256, Privkey}; +use crate::fiber::{NetworkActorCommand, NetworkActorMessage}; use crate::invoice::{ - Attribute as InternalAttribute, CkbInvoice as InternalCkbInvoice, CkbInvoiceStatus, CkbScript, - Currency, InvoiceBuilder, InvoiceData as InternalInvoiceData, InvoiceSignature, InvoiceStore, + add_invoice, Attribute as InternalAttribute, CkbInvoice as InternalCkbInvoice, + CkbInvoiceStatus, CkbScript, Currency, InvoiceBuilder, InvoiceData as InternalInvoiceData, + InvoiceSignature, InvoiceStore, }; -use crate::FiberConfig; +use crate::{gen_rand_sha256_hash, handle_actor_call, log_and_error, FiberConfig}; use ckb_jsonrpc_types::Script; use jsonrpsee::types::{error::CALL_EXECUTION_FAILED_CODE, ErrorObjectOwned}; #[cfg(not(target_arch = "wasm32"))] use jsonrpsee::proc_macros::rpc; +use ractor::{call, ActorRef}; use rand::Rng; use secp256k1::{PublicKey, Secp256k1, SecretKey}; use serde::{Deserialize, Serialize}; @@ -142,8 +145,10 @@ pub struct NewInvoiceParams { pub description: Option, /// The currency of the invoice. pub currency: Currency, - /// The payment preimage of the invoice. - pub payment_preimage: Hash256, + /// The preimage to settle an incoming TLC payable to this invoice. If preimage is set, hash must be absent. If both preimage and hash are absent, a random preimage is generated. + pub payment_preimage: Option, + /// The hash of the preimage. If hash is set, preimage must be absent. This condition indicates a 'hold invoice' for which the tlc must be accepted and held until the preimage becomes known. + pub payment_hash: Option, /// The expiry time of the invoice, in seconds. #[serde_as(as = "Option")] pub expiry: Option, @@ -187,6 +192,17 @@ pub struct InvoiceParams { pub payment_hash: Hash256, } +#[derive(Serialize, Deserialize, Debug)] +pub struct SettleInvoiceParams { + /// The payment hash of the invoice. + pub payment_hash: Hash256, + /// The payment preimage of the invoice. + pub payment_preimage: Hash256, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SettleInvoiceResult {} + /// The status of the invoice. #[derive(Clone, Serialize, Deserialize)] pub struct GetInvoiceResult { @@ -229,17 +245,29 @@ trait InvoiceRpc { &self, payment_hash: InvoiceParams, ) -> Result; + + /// Settles an invoice by saving the preimage to this invoice. + #[method(name = "settle_invoice")] + async fn settle_invoice( + &self, + settle_invoice: SettleInvoiceParams, + ) -> Result; } pub struct InvoiceRpcServerImpl { store: S, + network_actor: Option>, keypair: Option<(PublicKey, SecretKey)>, currency: Option, node_features: Option, } impl InvoiceRpcServerImpl { - pub fn new(store: S, config: Option) -> Self { + pub fn new( + store: S, + network_actor: Option>, + config: Option, + ) -> Self { let (keypair, currency, node_features) = if let Some(config) = config { let kp = config .read_or_generate_secret_key() @@ -270,6 +298,7 @@ impl InvoiceRpcServerImpl { }; Self { store, + network_actor, keypair, currency, node_features, @@ -313,6 +342,13 @@ where ) -> Result { self.cancel_invoice(payment_hash).await } + + async fn settle_invoice( + &self, + settle_invoice: SettleInvoiceParams, + ) -> Result { + self.settle_invoice(settle_invoice).await + } } impl InvoiceRpcServerImpl @@ -339,9 +375,20 @@ where )); } } - let mut invoice_builder = InvoiceBuilder::new(params.currency) - .amount(Some(params.amount)) - .payment_preimage(params.payment_preimage); + + let mut invoice_builder = InvoiceBuilder::new(params.currency).amount(Some(params.amount)); + + // If both preimage and hash are absent, a random preimage is generated. + let preimage_opt = params + .payment_preimage + .or_else(|| params.payment_hash.is_none().then(gen_rand_sha256_hash)); + + if let Some(preimage) = preimage_opt { + invoice_builder = invoice_builder.payment_preimage(preimage); + } + if let Some(hash) = params.payment_hash { + invoice_builder = invoice_builder.payment_hash(hash); + } if let Some(description) = params.description.clone() { invoice_builder = invoice_builder.description(description); }; @@ -399,10 +446,7 @@ where }; match invoice { - Ok(invoice) => match self - .store - .insert_invoice(invoice.clone(), Some(params.payment_preimage)) - { + Ok(invoice) => match add_invoice(&self.store, invoice.clone(), preimage_opt) { Ok(_) => Ok(InvoiceResult { invoice_address: invoice.to_string(), invoice: invoice.into(), @@ -508,4 +552,30 @@ where )), } } + + pub async fn settle_invoice( + &self, + params: SettleInvoiceParams, + ) -> Result { + let network_actor = self.network_actor.as_ref().ok_or(ErrorObjectOwned::owned( + CALL_EXECUTION_FAILED_CODE, + "network actor not initialized".to_string(), + Option::<()>::None, + ))?; + + let SettleInvoiceParams { + ref payment_hash, + ref payment_preimage, + } = params; + + let message = move |rpc_reply| -> NetworkActorMessage { + NetworkActorMessage::Command(NetworkActorCommand::SettleInvoice( + *payment_hash, + *payment_preimage, + rpc_reply, + )) + }; + + handle_actor_call!(network_actor, message, params).map(|_| SettleInvoiceResult {}) + } } diff --git a/crates/fiber-lib/src/rpc/mod.rs b/crates/fiber-lib/src/rpc/mod.rs index 79488013e..2caffa0a0 100644 --- a/crates/fiber-lib/src/rpc/mod.rs +++ b/crates/fiber-lib/src/rpc/mod.rs @@ -35,6 +35,7 @@ pub mod server { use crate::rpc::payment::PaymentRpcServer; use crate::rpc::payment::PaymentRpcServerImpl; use crate::rpc::peer::{PeerRpcServer, PeerRpcServerImpl}; + use crate::store::pub_sub::{register_pub_sub_rpc, Subscribe}; use crate::{ cch::CchMessage, fiber::{ @@ -60,7 +61,7 @@ pub mod server { }; use jsonrpsee::ws_client::RpcServiceBuilder; use jsonrpsee::{Methods, RpcModule}; - use ractor::ActorRef; + use ractor::{ActorCell, ActorRef}; #[cfg(debug_assertions)] use std::collections::HashMap; use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; @@ -80,6 +81,7 @@ pub mod server { + GossipMessageStore + WatchtowerStore + PreimageStore + + Subscribe { } #[cfg(feature = "watchtower")] @@ -90,16 +92,25 @@ pub mod server { + GossipMessageStore + WatchtowerStore + PreimageStore + + Subscribe { } #[cfg(not(feature = "watchtower"))] pub trait RpcServerStore: - ChannelActorStateStore + InvoiceStore + NetworkGraphStateStore + GossipMessageStore + ChannelActorStateStore + + InvoiceStore + + NetworkGraphStateStore + + GossipMessageStore + + Subscribe { } #[cfg(not(feature = "watchtower"))] impl RpcServerStore for T where - T: ChannelActorStateStore + InvoiceStore + NetworkGraphStateStore + GossipMessageStore + T: ChannelActorStateStore + + InvoiceStore + + NetworkGraphStateStore + + GossipMessageStore + + Subscribe { } @@ -221,7 +232,8 @@ pub mod server { network_actor: Option>, cch_actor: Option>, store: S, - network_graph: Arc>>, + network_graph: Option>>>, + supervisor: ActorCell, #[cfg(debug_assertions)] ckb_chain_actor: Option>, #[cfg(debug_assertions)] rpc_dev_module_commitment_txs: Option< Arc>>, @@ -243,14 +255,32 @@ pub mod server { let mut modules = RpcModule::new(()); if config.is_module_enabled("invoice") { + if network_actor.is_none() { + tracing::warn!("network_actor should be set when invoice module is enabled"); + } modules - .merge(InvoiceRpcServerImpl::new(store.clone(), fiber_config).into_rpc()) + .merge( + InvoiceRpcServerImpl::new(store.clone(), network_actor.clone(), fiber_config) + .into_rpc(), + ) .unwrap(); } if config.is_module_enabled("graph") { - modules - .merge(GraphRpcServerImpl::new(network_graph, store.clone()).into_rpc()) - .unwrap(); + match network_graph { + Some(network_graph) => { + modules + .merge(GraphRpcServerImpl::new(network_graph, store.clone()).into_rpc()) + .unwrap(); + } + None => { + tracing::error!( + "rpc graph module is enabled, but fiber service is not enabled" + ); + } + } + } + if config.is_module_enabled("pubsub") { + register_pub_sub_rpc(&mut modules, &store, supervisor).await?; } if let Some(network_actor) = network_actor { if config.is_module_enabled("info") { @@ -310,11 +340,14 @@ pub mod server { } } if let Some(cch_actor) = cch_actor { + println!("cch enabled!"); if config.is_module_enabled("cch") { modules .merge(CchRpcServerImpl::new(cch_actor).into_rpc()) .unwrap(); } + } else { + println!("cch not enabled!"); } let (handle, addr) = start_server(listening_addr, auth, modules).await?; diff --git a/crates/fiber-lib/src/rpc/payment.rs b/crates/fiber-lib/src/rpc/payment.rs index beecef490..cc45c9de4 100644 --- a/crates/fiber-lib/src/rpc/payment.rs +++ b/crates/fiber-lib/src/rpc/payment.rs @@ -95,7 +95,7 @@ impl From for crate::fiber::PaymentCustomRecords { } #[serde_as] -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct SendPaymentCommandParams { /// the identifier of the payment target pub target_pubkey: Option, diff --git a/crates/fiber-lib/src/store/mod.rs b/crates/fiber-lib/src/store/mod.rs index 5db821068..29c6c99ce 100644 --- a/crates/fiber-lib/src/store/mod.rs +++ b/crates/fiber-lib/src/store/mod.rs @@ -1,7 +1,11 @@ pub mod db_migrate; pub mod migration; +#[cfg(not(target_arch = "wasm32"))] +pub mod pub_sub; mod schema; pub mod store_impl; + pub use store_impl::Store; + #[cfg(test)] mod tests; diff --git a/crates/fiber-lib/src/store/pub_sub/mod.rs b/crates/fiber-lib/src/store/pub_sub/mod.rs new file mode 100644 index 000000000..6024ec678 --- /dev/null +++ b/crates/fiber-lib/src/store/pub_sub/mod.rs @@ -0,0 +1,13 @@ +mod pub_sub_rpc; +mod store_with_pub_sub; +mod subscription; + +pub use pub_sub_rpc::{register_pub_sub_rpc, PubSubClient}; +pub use store_with_pub_sub::{StoreWithPubSub, Subscribe}; +pub use subscription::{ + InvoiceUpdatedEvent, InvoiceUpdatedPayload, PaymentUpdatedEvent, PaymentUpdatedPayload, + StorePublisher, StoreUpdatedEvent, +}; + +#[cfg(test)] +mod tests; diff --git a/crates/fiber-lib/src/store/pub_sub/pub_sub_rpc.rs b/crates/fiber-lib/src/store/pub_sub/pub_sub_rpc.rs new file mode 100644 index 000000000..74928df19 --- /dev/null +++ b/crates/fiber-lib/src/store/pub_sub/pub_sub_rpc.rs @@ -0,0 +1,170 @@ +//! Store Pub/Sub via RPC +use super::StoreUpdatedEvent; +use crate::store::pub_sub::{StorePublisher, Subscribe}; + +use jsonrpsee::{ + core::client::{Subscription, SubscriptionClientT}, + rpc_params, + ws_client::WsClientBuilder, + RpcModule, SubscriptionSink, +}; +use ractor::{port::OutputPortSubscriber, Actor, ActorCell, ActorProcessingErr, ActorRef}; +use tokio::select; +use tokio_util::sync::CancellationToken; + +pub struct PubSubServerActor; + +#[derive(Default)] +pub struct PubSubServerState { + sinks: Vec, +} + +pub enum PubSubServerMessage { + Publish(StoreUpdatedEvent), + AddSink(SubscriptionSink), +} + +impl From for PubSubServerMessage { + fn from(event: StoreUpdatedEvent) -> Self { + PubSubServerMessage::Publish(event) + } +} + +#[async_trait::async_trait] +impl Actor for PubSubServerActor { + type State = PubSubServerState; + type Msg = PubSubServerMessage; + type Arguments = (); + + async fn pre_start( + &self, + _myself: ActorRef, + _args: Self::Arguments, + ) -> Result { + Ok(PubSubServerState::default()) + } + + async fn handle( + &self, + _myself: ActorRef, + message: Self::Msg, + state: &mut Self::State, + ) -> Result<(), ActorProcessingErr> { + match message { + PubSubServerMessage::AddSink(sink) => state.sinks.push(sink), + PubSubServerMessage::Publish(event) => { + let subscription_message = + serde_json::value::to_raw_value(&event).expect("serialize to JSON"); + let sinks = std::mem::take(&mut state.sinks); + for sink in sinks { + if sink.send(subscription_message.clone()).await.is_ok() { + state.sinks.push(sink); + } + } + } + } + Ok(()) + } +} + +const SUBSCRIBE_STORE_CHANGES_NAME: &str = "subscribe_store_changes"; +const SUBSCRIBE_STORE_CHANGES_NOTIF_NAME: &str = "store_changes"; +const UNSUBSCRIBE_STORE_CHANGES_NAME: &str = "unsubscribe_store_changes"; + +pub async fn register_pub_sub_rpc( + modules: &mut RpcModule<()>, + publisher: &S, + supervisor: ActorCell, +) -> anyhow::Result<()> { + let (pub_sub_actor, _) = + ractor::Actor::spawn_linked(None, PubSubServerActor, (), supervisor).await?; + publisher.subscribe(Box::new(pub_sub_actor.clone())); + modules.register_subscription( + SUBSCRIBE_STORE_CHANGES_NAME, + SUBSCRIBE_STORE_CHANGES_NOTIF_NAME, + UNSUBSCRIBE_STORE_CHANGES_NAME, + move |_, pending, _, _| { + let pub_sub_actor = pub_sub_actor.clone(); + async move { + let sink = pending.accept().await?; + let _ = pub_sub_actor.send_message(PubSubServerMessage::AddSink(sink)); + Ok(()) + } + }, + )?; + Ok(()) +} + +pub struct PubSubClient { + ws_url: String, + publisher: StorePublisher, +} + +impl PubSubClient { + pub fn new(ws_url: String) -> Self { + Self { + ws_url, + publisher: Default::default(), + } + } + + pub fn subscribe(&self, subscriber: OutputPortSubscriber) { + self.publisher.subscribe(subscriber); + } + + async fn run_inner(&self, token: &CancellationToken) -> anyhow::Result<()> { + let client = WsClientBuilder::default().build(&self.ws_url).await?; + let mut subscription: Subscription = client + .subscribe( + SUBSCRIBE_STORE_CHANGES_NAME, + rpc_params![], + UNSUBSCRIBE_STORE_CHANGES_NAME, + ) + .await?; + + loop { + select! { + notif = subscription.next() => { + match notif { + Some(Ok(event)) => { + self.publisher.publish(event); + }, + Some(Err(err)) => { + // ignore errors from server + tracing::error!("unexpected store changes stream error: {}", err); + }, + None => { + return Err(anyhow::anyhow!("reconnect")); + } + } + } + _ = token.cancelled() => { + break; + } + } + } + Ok(()) + } + + pub async fn run(self, token: CancellationToken) { + loop { + select! { + result = self.run_inner(&token) => { + if let Err(err) = result { + tracing::error!( + "restart pub sub client to {} because of error: {}", + self.ws_url, + err + ); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } else { + break; + } + } + _ = token.cancelled() => { + break; + } + } + } + } +} diff --git a/crates/fiber-lib/src/store/pub_sub/store_with_pub_sub.rs b/crates/fiber-lib/src/store/pub_sub/store_with_pub_sub.rs new file mode 100644 index 000000000..53b722c09 --- /dev/null +++ b/crates/fiber-lib/src/store/pub_sub/store_with_pub_sub.rs @@ -0,0 +1,319 @@ +use ckb_types::packed; +use ractor::port::OutputPortSubscriber; + +use super::{InvoiceUpdatedPayload, PaymentUpdatedPayload, StorePublisher, StoreUpdatedEvent}; +#[cfg(feature = "watchtower")] +use crate::watchtower::{WatchtowerStore, WatchtowerStoreDeref}; +use crate::{ + cch::{CchOrderStore, CchOrderStoreDeref}, + fiber::{ + channel::{ChannelActorStateStore, ChannelActorStateStoreDeref}, + gossip::{GossipMessageStore, GossipMessageStoreDeref}, + graph::NetworkGraphStateStore, + history::{Direction, TimedResult}, + network::{NetworkActorStateStore, NetworkActorStateStoreDeref}, + payment::{Attempt, AttemptStatus, PaymentSession, PaymentStatus}, + types::Hash256, + }, + invoice::{ + CkbInvoice, CkbInvoiceStatus, InvoiceChannelInfo, InvoiceError, InvoiceStore, PreimageStore, + }, +}; + +#[derive(Clone, Debug)] +pub struct StoreWithPubSub { + pub(crate) inner: S, + publisher: StorePublisher, +} + +impl StoreWithPubSub { + pub fn new(store: S) -> Self { + Self::new_with_publisher(store, StorePublisher::default()) + } + + pub fn new_with_publisher(store: S, publisher: StorePublisher) -> Self { + Self { + inner: store, + publisher, + } + } + + pub(crate) fn publish(&self, event: StoreUpdatedEvent) { + self.publisher.publish(event); + } + + pub fn subscribe(&self, subscriber: OutputPortSubscriber) { + self.publisher.subscribe(subscriber); + } +} + +pub trait Subscribe { + fn subscribe(&self, subscriber: OutputPortSubscriber); +} + +impl Subscribe for StoreWithPubSub { + fn subscribe(&self, subscriber: OutputPortSubscriber) { + self.subscribe(subscriber); + } +} + +impl NetworkActorStateStoreDeref for StoreWithPubSub { + type Target = T; + + fn network_actor_state_store_deref(&self) -> &Self::Target { + &self.inner + } +} + +impl ChannelActorStateStoreDeref for StoreWithPubSub { + type Target = T; + + fn channel_actor_state_store_deref(&self) -> &Self::Target { + &self.inner + } +} + +impl InvoiceStore for StoreWithPubSub +where + S: InvoiceStore, +{ + fn get_invoice(&self, id: &Hash256) -> Option { + self.inner.get_invoice(id) + } + + fn insert_invoice( + &self, + invoice: CkbInvoice, + preimage: Option, + ) -> Result<(), InvoiceError> { + let invoice_hash = *invoice.payment_hash(); + self.inner.insert_invoice(invoice, preimage)?; + + self.publish(StoreUpdatedEvent::new_invoice_updated_event( + invoice_hash, + InvoiceUpdatedPayload::Open, + )); + Ok(()) + } + + fn update_invoice_status( + &self, + id: &Hash256, + status: CkbInvoiceStatus, + ) -> Result<(), InvoiceError> { + let _span = tracing::info_span!("update_invoice_status", invoice_hash = ?id).entered(); + + self.inner.update_invoice_status(id, status)?; + let payload_opt = status.try_into().ok().or_else(|| match status { + CkbInvoiceStatus::Received => { + // TODO: We should save received amount to the store. This is useful to + // determine if the invoice is fully paid. + // Currently we assume that the invoice is fully paid if the status is + // Received. + let payload_opt = + self.get_invoice(id) + .and_then(|invoice| invoice.amount) + .map(|amount| InvoiceUpdatedPayload::Received { + amount, + is_finished: true, + }); + if payload_opt.is_none() { + tracing::error!("Fail to get payment amount for a received invoice"); + } + payload_opt + } + _ => { + tracing::error!( + "Expect convert CkbInvoiceStatus {} to InvoiceUpdatedPayload", + status + ); + None + } + }); + if let Some(payload) = payload_opt { + self.publish(StoreUpdatedEvent::new_invoice_updated_event(*id, payload)) + } + Ok(()) + } + + fn get_invoice_status(&self, id: &Hash256) -> Option { + self.inner.get_invoice_status(id) + } + + fn get_invoice_channel_info(&self, payment_hash: &Hash256) -> Vec { + self.inner.get_invoice_channel_info(payment_hash) + } + + fn add_invoice_channel_info( + &self, + id: &Hash256, + channel_info: InvoiceChannelInfo, + ) -> Result, InvoiceError> { + self.inner.add_invoice_channel_info(id, channel_info) + } +} + +// The PaymentUpdatedEvent requires preimage when the payment status is Received. Hooks are added +// in both `insert_preimage` and `insert_payment_session` so App does not need to worry about +// sequence to update the store. +impl StoreWithPubSub +where + S: NetworkGraphStateStore + PreimageStore, +{ + fn publish_payment_updated_event_when_inserting_payment_session( + &self, + payment_hash: Hash256, + status: PaymentStatus, + ) { + let payload_opt = status.try_into().ok().or_else(|| match status { + // If preimage is not available, defer the notification on `insert_preimage`. + // See `publish_payment_updated_event_when_removing_preimage`. + PaymentStatus::Success => self + .get_preimage(&payment_hash) + .map(|preimage| PaymentUpdatedPayload::Success { preimage }), + _ => { + tracing::error!( + "Expect convert PaymentStatus {:?} to PaymentUpdatedPayload", + status + ); + None + } + }); + if let Some(payload) = payload_opt { + self.publish(StoreUpdatedEvent::new_payment_updated_event( + payment_hash, + payload, + )) + } + } + + fn publish_payment_updated_event_when_removing_preimage( + &self, + payment_hash: Hash256, + preimage: Hash256, + ) { + // Check whether the payment session status is Success or Inflight. + // TODO: It is tricky to publish the success payment session status event when removing the + // preimage from the store. This is the last chance since channel actor automatically clean the preimage before the + // payment session is marked as success. + if self + .inner + .get_payment_session(payment_hash) + .is_some_and(|session| { + matches!( + session.status, + PaymentStatus::Inflight | PaymentStatus::Success + ) + }) + { + self.publish(StoreUpdatedEvent::new_payment_updated_event( + payment_hash, + PaymentUpdatedPayload::Success { preimage }, + )) + } + } +} + +impl PreimageStore for StoreWithPubSub +where + T: NetworkGraphStateStore + PreimageStore, +{ + fn insert_preimage(&self, payment_hash: Hash256, preimage: Hash256) { + self.inner.insert_preimage(payment_hash, preimage); + } + + fn remove_preimage(&self, payment_hash: &Hash256) { + if let Some(preimage) = self.inner.get_preimage(payment_hash) { + self.publish_payment_updated_event_when_removing_preimage(*payment_hash, preimage); + } + self.inner.remove_preimage(payment_hash); + } + + fn get_preimage(&self, payment_hash: &Hash256) -> Option { + self.inner.get_preimage(payment_hash) + } +} + +impl NetworkGraphStateStore for StoreWithPubSub +where + S: NetworkGraphStateStore + PreimageStore, +{ + fn get_payment_session(&self, payment_hash: Hash256) -> Option { + self.inner.get_payment_session(payment_hash) + } + + fn get_payment_sessions_with_status(&self, status: PaymentStatus) -> Vec { + self.inner.get_payment_sessions_with_status(status) + } + + fn insert_payment_session(&self, session: PaymentSession) { + let payment_hash = session.payment_hash(); + let status = session.status; + + self.inner.insert_payment_session(session); + self.publish_payment_updated_event_when_inserting_payment_session(payment_hash, status); + } + + fn insert_payment_history_result( + &mut self, + channel_outpoint: packed::OutPoint, + direction: Direction, + result: TimedResult, + ) { + self.inner + .insert_payment_history_result(channel_outpoint, direction, result) + } + + fn get_payment_history_results(&self) -> Vec<(packed::OutPoint, Direction, TimedResult)> { + self.inner.get_payment_history_results() + } + + fn remove_channel_history(&mut self, channel_outpoint: &packed::OutPoint) { + self.inner.remove_channel_history(channel_outpoint); + } + + fn get_attempt(&self, payment_hash: Hash256, attempt_id: u64) -> Option { + self.inner.get_attempt(payment_hash, attempt_id) + } + + fn insert_attempt(&self, attempt: Attempt) { + self.inner.insert_attempt(attempt); + } + + fn get_attempts(&self, payment_hash: Hash256) -> Vec { + self.inner.get_attempts(payment_hash) + } + + fn delete_attempts(&self, payment_hash: Hash256) { + self.inner.delete_attempts(payment_hash); + } + + fn get_attempts_with_statuses(&self, status: &[AttemptStatus]) -> Vec { + self.inner.get_attempts_with_statuses(status) + } +} + +#[cfg(feature = "watchtower")] +impl WatchtowerStoreDeref for StoreWithPubSub { + type Target = T; + + fn watchtower_store_deref(&self) -> &Self::Target { + &self.inner + } +} + +impl GossipMessageStoreDeref for StoreWithPubSub { + type Target = T; + + fn gossip_message_store_deref(&self) -> &Self::Target { + &self.inner + } +} + +impl CchOrderStoreDeref for StoreWithPubSub { + type Target = T; + + fn cch_order_store_deref(&self) -> &Self::Target { + &self.inner + } +} diff --git a/crates/fiber-lib/src/store/pub_sub/subscription.rs b/crates/fiber-lib/src/store/pub_sub/subscription.rs new file mode 100644 index 000000000..e39ee1cea --- /dev/null +++ b/crates/fiber-lib/src/store/pub_sub/subscription.rs @@ -0,0 +1,145 @@ +//! Publish-subscribe notifications for the store. + +use std::sync::Arc; + +use ractor::{port::OutputPortSubscriber, OutputPort}; +use serde::{Deserialize, Serialize}; + +use crate::{ + fiber::{payment::PaymentStatus, types::Hash256}, + invoice::CkbInvoiceStatus, +}; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum InvoiceUpdatedPayload { + /// The invoice is open and can be paid. + Open, + /// The invoice is cancelled. + Cancelled, + /// The invoice is expired. + Expired, + /// The invoice is received, but not settled yet. + Received { + /// The amount of the invoice. + amount: u128, + /// Depending on whether AMP is supported, the invoice may have multiple parts, + /// this field indicates if we received all parts. + is_finished: bool, + }, + /// The invoice is paid. + Paid, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct InvoiceUpdatedEvent { + pub invoice_hash: Hash256, + pub payload: InvoiceUpdatedPayload, +} + +impl TryFrom for InvoiceUpdatedPayload { + type Error = String; + + fn try_from(value: CkbInvoiceStatus) -> Result { + let payload = match value { + CkbInvoiceStatus::Open => InvoiceUpdatedPayload::Open, + CkbInvoiceStatus::Cancelled => InvoiceUpdatedPayload::Cancelled, + CkbInvoiceStatus::Expired => InvoiceUpdatedPayload::Expired, + CkbInvoiceStatus::Received => { + return Err("InvoiceUpdatedPayload::Received requires extra data".to_string()) + } + CkbInvoiceStatus::Paid => InvoiceUpdatedPayload::Paid, + }; + Ok(payload) + } +} + +impl InvoiceUpdatedEvent { + pub fn new(invoice_hash: Hash256, payload: InvoiceUpdatedPayload) -> Self { + Self { + invoice_hash, + payload, + } + } +} + +// but with additional information for downstream services. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum PaymentUpdatedPayload { + /// initial status, payment session is created, no HTLC is sent + Created, + /// the first hop AddTlc is sent successfully and waiting for the response + Inflight, + /// related HTLC is successfully settled + Success { preimage: Hash256 }, + /// related HTLC is failed + Failed, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PaymentUpdatedEvent { + pub payment_hash: Hash256, + pub payload: PaymentUpdatedPayload, +} + +impl TryFrom for PaymentUpdatedPayload { + type Error = String; + + fn try_from(value: PaymentStatus) -> Result { + let payload = match value { + PaymentStatus::Created => PaymentUpdatedPayload::Created, + PaymentStatus::Inflight => PaymentUpdatedPayload::Inflight, + PaymentStatus::Success => { + return Err("PaymentUpdatedPayload::Created requires extra data".to_string()) + } + PaymentStatus::Failed => PaymentUpdatedPayload::Failed, + }; + Ok(payload) + } +} + +impl PaymentUpdatedEvent { + pub fn new(payment_hash: Hash256, payload: PaymentUpdatedPayload) -> Self { + Self { + payment_hash, + payload, + } + } +} + +/// Message sent from Store to publisher. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum StoreUpdatedEvent { + InvoiceUpdated(InvoiceUpdatedEvent), + PaymentUpdated(PaymentUpdatedEvent), +} + +impl StoreUpdatedEvent { + pub fn new_invoice_updated_event( + invoice_hash: Hash256, + payload: InvoiceUpdatedPayload, + ) -> Self { + StoreUpdatedEvent::InvoiceUpdated(InvoiceUpdatedEvent::new(invoice_hash, payload)) + } + pub fn new_payment_updated_event( + payment_hash: Hash256, + payload: PaymentUpdatedPayload, + ) -> Self { + StoreUpdatedEvent::PaymentUpdated(PaymentUpdatedEvent::new(payment_hash, payload)) + } +} + +/// This ractor receives the notification StoreUpdateMessage and sends out StoreRichUpdateMessage +#[derive(Default, Clone, Debug)] +pub struct StorePublisher(Arc>); + +impl StorePublisher { + pub fn new() -> Self { + Self::default() + } + + pub(crate) fn publish(&self, event: StoreUpdatedEvent) { + self.0.send(event); + } + + pub fn subscribe(&self, subscriber: OutputPortSubscriber) { + subscriber.subscribe_to_port(&self.0); + } +} diff --git a/crates/fiber-lib/src/store/pub_sub/tests/mod.rs b/crates/fiber-lib/src/store/pub_sub/tests/mod.rs new file mode 100644 index 000000000..3b9639be4 --- /dev/null +++ b/crates/fiber-lib/src/store/pub_sub/tests/mod.rs @@ -0,0 +1,2 @@ +mod store_with_pub_sub_unit_tests; +mod subscription_unit_tests; diff --git a/crates/fiber-lib/src/store/pub_sub/tests/store_with_pub_sub_unit_tests.rs b/crates/fiber-lib/src/store/pub_sub/tests/store_with_pub_sub_unit_tests.rs new file mode 100644 index 000000000..6b9d73461 --- /dev/null +++ b/crates/fiber-lib/src/store/pub_sub/tests/store_with_pub_sub_unit_tests.rs @@ -0,0 +1,147 @@ +use super::subscription_unit_tests::{ + mock_invoice, mock_payment_session, MockStore, StoreTestSubscriber, +}; +use crate::{ + fiber::{graph::NetworkGraphStateStore, payment::PaymentStatus}, + gen_rand_sha256_hash, + invoice::{CkbInvoiceStatus, InvoiceStore, PreimageStore}, + store::pub_sub::{ + InvoiceUpdatedPayload, PaymentUpdatedPayload, StoreUpdatedEvent, StoreWithPubSub, + }, +}; +use ractor::{concurrency::Duration, Actor}; + +#[tokio::test] +async fn test_insert_invoice() { + let invoice = mock_invoice(100); + let invoice_hash = *invoice.payment_hash(); + let expected = + StoreUpdatedEvent::new_invoice_updated_event(invoice_hash, InvoiceUpdatedPayload::Open); + + let store = StoreWithPubSub::new(MockStore::default()); + + let (subscriber_ref, subscriber_handle) = Actor::spawn(None, StoreTestSubscriber, expected) + .await + .unwrap(); + + store.subscribe(Box::new(subscriber_ref)); + store.insert_invoice(invoice, None).unwrap(); + + ractor::concurrency::timeout(Duration::from_millis(100), subscriber_handle) + .await + .expect("Test actor failed in exit") + .unwrap(); +} + +#[tokio::test] +async fn test_update_invoice_status_to_received() { + let invoice = mock_invoice(100); + let invoice_hash = *invoice.payment_hash(); + let expected = StoreUpdatedEvent::new_invoice_updated_event( + invoice_hash, + InvoiceUpdatedPayload::Received { + amount: 100, + is_finished: true, + }, + ); + + let store = StoreWithPubSub::new(MockStore { + invoice_status: Some(CkbInvoiceStatus::Received), + invoice: Some(mock_invoice(100)), + ..Default::default() + }); + + let (subscriber_ref, subscriber_handle) = Actor::spawn(None, StoreTestSubscriber, expected) + .await + .unwrap(); + + store.subscribe(Box::new(subscriber_ref)); + store + .update_invoice_status(&invoice_hash, CkbInvoiceStatus::Received) + .unwrap(); + + ractor::concurrency::timeout(Duration::from_millis(100), subscriber_handle) + .await + .expect("Test actor failed in exit") + .unwrap(); +} + +#[tokio::test] +async fn test_insert_payment_session() { + let payment_hash = gen_rand_sha256_hash(); + let session = mock_payment_session(payment_hash, PaymentStatus::Created); + let expected = + StoreUpdatedEvent::new_payment_updated_event(payment_hash, PaymentUpdatedPayload::Created); + + let store = StoreWithPubSub::new(MockStore::default()); + + let (subscriber_ref, subscriber_handle) = Actor::spawn(None, StoreTestSubscriber, expected) + .await + .unwrap(); + + store.subscribe(Box::new(subscriber_ref)); + store.insert_payment_session(session); + + ractor::concurrency::timeout(Duration::from_millis(100), subscriber_handle) + .await + .expect("Test actor failed in exit") + .unwrap(); +} + +#[tokio::test] +async fn test_insert_payment_session_with_success_status() { + let payment_hash = gen_rand_sha256_hash(); + let preimage = gen_rand_sha256_hash(); + let session = mock_payment_session(payment_hash, PaymentStatus::Success); + let expected = StoreUpdatedEvent::new_payment_updated_event( + payment_hash, + PaymentUpdatedPayload::Success { preimage }, + ); + + let store = StoreWithPubSub::new(MockStore { + payment_session: Some(session.clone()), + preimage: Some(preimage), + ..Default::default() + }); + + let (subscriber_ref, subscriber_handle) = Actor::spawn(None, StoreTestSubscriber, expected) + .await + .unwrap(); + + store.subscribe(Box::new(subscriber_ref)); + store.insert_payment_session(session); + + ractor::concurrency::timeout(Duration::from_millis(100), subscriber_handle) + .await + .expect("Test actor failed in exit") + .unwrap(); +} + +#[tokio::test] +async fn test_remove_preimage_with_inflight_status() { + let payment_hash = gen_rand_sha256_hash(); + let preimage = gen_rand_sha256_hash(); + let session = mock_payment_session(payment_hash, PaymentStatus::Inflight); + let expected = StoreUpdatedEvent::new_payment_updated_event( + payment_hash, + PaymentUpdatedPayload::Success { preimage }, + ); + + let store = StoreWithPubSub::new(MockStore { + payment_session: Some(session), + preimage: Some(preimage), + ..Default::default() + }); + + let (subscriber_ref, subscriber_handle) = Actor::spawn(None, StoreTestSubscriber, expected) + .await + .unwrap(); + + store.subscribe(Box::new(subscriber_ref)); + store.remove_preimage(&payment_hash); + + ractor::concurrency::timeout(Duration::from_millis(100), subscriber_handle) + .await + .expect("Test actor failed in exit") + .unwrap(); +} diff --git a/crates/fiber-lib/src/store/pub_sub/tests/subscription_unit_tests.rs b/crates/fiber-lib/src/store/pub_sub/tests/subscription_unit_tests.rs new file mode 100644 index 000000000..c55b0bd32 --- /dev/null +++ b/crates/fiber-lib/src/store/pub_sub/tests/subscription_unit_tests.rs @@ -0,0 +1,265 @@ +use crate::{ + fiber::{ + graph::NetworkGraphStateStore, + history::{Direction, TimedResult}, + network::SendPaymentData, + payment::{Attempt, AttemptStatus, PaymentSession, PaymentStatus}, + types::Hash256, + }, + gen_rand_fiber_public_key, gen_rand_sha256_hash, + invoice::{ + CkbInvoice, CkbInvoiceStatus, Currency, InvoiceBuilder, InvoiceStore, PreimageStore, + }, + store::pub_sub::{ + InvoiceUpdatedPayload, PaymentUpdatedPayload, StoreUpdatedEvent, StoreWithPubSub, + }, +}; +use ckb_types::packed; +use ractor::{async_trait, concurrency::Duration, Actor, ActorProcessingErr, ActorRef}; + +#[derive(Default, Clone)] +pub struct MockStore { + pub invoice: Option, + pub invoice_status: Option, + pub payment_session: Option, + pub preimage: Option, +} + +impl NetworkGraphStateStore for MockStore { + fn get_payment_session(&self, _payment_hash: Hash256) -> Option { + self.payment_session.clone() + } + + fn get_payment_sessions_with_status(&self, _status: PaymentStatus) -> Vec { + unimplemented!() + } + + fn insert_payment_session(&self, _session: PaymentSession) { + // skip + } + + fn insert_payment_history_result( + &mut self, + _channel_outpoint: packed::OutPoint, + _direction: Direction, + _result: TimedResult, + ) { + unimplemented!() + } + + fn get_payment_history_results(&self) -> Vec<(packed::OutPoint, Direction, TimedResult)> { + unimplemented!() + } + + fn remove_channel_history(&mut self, _channel_outpoint: &packed::OutPoint) { + unimplemented!() + } + + fn get_attempt(&self, _payment_hash: Hash256, _attempt_id: u64) -> Option { + unimplemented!() + } + + fn insert_attempt(&self, _attempt: Attempt) { + unimplemented!() + } + + fn get_attempts(&self, _payment_hash: Hash256) -> Vec { + unimplemented!() + } + + fn delete_attempts(&self, _payment_hash: Hash256) { + unimplemented!() + } + + fn get_attempts_with_statuses( + &self, + _status: &[AttemptStatus], + ) -> Vec { + unimplemented!() + } +} + +impl InvoiceStore for MockStore { + fn get_invoice(&self, _id: &Hash256) -> Option { + self.invoice.clone() + } + + fn insert_invoice( + &self, + _invoice: CkbInvoice, + _preimage: Option, + ) -> Result<(), crate::invoice::InvoiceError> { + // skip + Ok(()) + } + + fn update_invoice_status( + &self, + _id: &Hash256, + _status: CkbInvoiceStatus, + ) -> Result<(), crate::invoice::InvoiceError> { + // skip + Ok(()) + } + + fn get_invoice_status(&self, _id: &Hash256) -> Option { + self.invoice_status + } + + fn get_invoice_channel_info( + &self, + _payment_hash: &Hash256, + ) -> Vec { + unimplemented!() + } + + fn add_invoice_channel_info( + &self, + _payment_hash: &Hash256, + _invoice_channel_info: crate::invoice::InvoiceChannelInfo, + ) -> Result, crate::invoice::InvoiceError> { + unimplemented!() + } +} + +impl PreimageStore for MockStore { + fn insert_preimage(&self, _payment_hash: Hash256, _preimage: Hash256) { + // skip + } + + fn remove_preimage(&self, _payment_hash: &Hash256) { + // skip + } + + fn get_preimage(&self, _payment_hash: &Hash256) -> Option { + self.preimage + } +} + +pub struct StoreTestSubscriber; + +#[async_trait] +impl Actor for StoreTestSubscriber { + type Msg = StoreUpdatedEvent; + type Arguments = StoreUpdatedEvent; + type State = StoreUpdatedEvent; + + async fn pre_start( + &self, + _myself: ActorRef, + args: Self::State, + ) -> Result { + Ok(args) + } + + async fn handle( + &self, + myself: ActorRef, + message: Self::Msg, + state: &mut Self::State, + ) -> Result<(), ActorProcessingErr> { + // Exit once received the expected message + if message == *state { + myself.stop(None); + } else { + eprintln!("expected {:?}, got {:?}", state, message); + } + Ok(()) + } +} + +pub fn mock_invoice(amount: u128) -> CkbInvoice { + InvoiceBuilder::new(Currency::Fibb) + .amount(Some(amount)) + .payment_hash(gen_rand_sha256_hash()) + .build() + .expect("mock invoice") +} + +pub fn mock_payment_session(payment_hash: Hash256, status: PaymentStatus) -> PaymentSession { + let payment_data = SendPaymentData { + target_pubkey: gen_rand_fiber_public_key(), + amount: 100, + payment_hash, + invoice: None, + final_tlc_expiry_delta: 0, + tlc_expiry_limit: 0, + timeout: Some(10), + max_fee_amount: Some(1000), + max_parts: None, + keysend: false, + udt_type_script: None, + preimage: None, + allow_self_payment: false, + hop_hints: vec![], + dry_run: false, + custom_records: None, + router: vec![], + allow_mpp: false, + channel_stats: Default::default(), + }; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("Duration since unix epoch") + .as_millis() as u64; + let mut session = PaymentSession { + request: payment_data, + last_error: None, + try_limit: 10, + status, + created_at: now, + last_updated_at: now, + cached_attempts: Default::default(), + }; + session.status = status; + session +} + +#[tokio::test] +async fn test_invoice_open() { + let invoice_hash = gen_rand_sha256_hash(); + let input = + StoreUpdatedEvent::new_invoice_updated_event(invoice_hash, InvoiceUpdatedPayload::Open); + + let store = StoreWithPubSub::new(MockStore::default()); + let (subscriber_ref, subscriber_handle) = + Actor::spawn(None, StoreTestSubscriber, input.clone()) + .await + .unwrap(); + + store.subscribe(Box::new(subscriber_ref)); + store.publish(input); + + ractor::concurrency::timeout(Duration::from_millis(100), subscriber_handle) + .await + .expect("Test actor failed in exit") + .unwrap(); +} + +#[tokio::test] +async fn test_payment_success() { + let payment_hash = gen_rand_sha256_hash(); + let preimage = gen_rand_sha256_hash(); + let input = StoreUpdatedEvent::new_payment_updated_event( + payment_hash, + PaymentUpdatedPayload::Success { preimage }, + ); + + let store = StoreWithPubSub::new(MockStore { + payment_session: Some(mock_payment_session(payment_hash, PaymentStatus::Success)), + preimage: Some(preimage), + ..Default::default() + }); + let (subscriber_ref, subscriber_handle) = + Actor::spawn(None, StoreTestSubscriber, input.clone()) + .await + .unwrap(); + + store.subscribe(Box::new(subscriber_ref)); + store.publish(input); + + ractor::concurrency::timeout(Duration::from_millis(100), subscriber_handle) + .await + .expect("Test actor failed in exit") + .unwrap(); +} diff --git a/crates/fiber-lib/src/store/schema.rs b/crates/fiber-lib/src/store/schema.rs index 0506671cf..04aa36c72 100644 --- a/crates/fiber-lib/src/store/schema.rs +++ b/crates/fiber-lib/src/store/schema.rs @@ -7,6 +7,7 @@ //! | 32 | Hash256 | CkbInvoice | //! | 33 | Payment_hash | CkbInvoice Preimage | //! | 34 | Payment_hash | CkbInvoice Status | +//! | 35 | Payment_hash | Vec | //! | 64 | PeerId | Hash256 | ChannelState | //! | 65 | OutPoint | ChannelId | //! | 96 | Cursor | BroadcastMessage | @@ -15,6 +16,7 @@ //! | 193 | OutPoint | Direction | TimedResult | //! | 194 | Hash256 | PaymentCustomRecords | //! | 224 | Hash256 | ChannelData | +//! | 232 | Payment_hash | CchOrder | //! +--------------+----------------------+-----------------------------+ pub(crate) const CHANNEL_ACTOR_STATE_PREFIX: u8 = 0; @@ -22,6 +24,7 @@ pub(crate) const PEER_ID_NETWORK_ACTOR_STATE_PREFIX: u8 = 16; pub(crate) const CKB_INVOICE_PREFIX: u8 = 32; pub(crate) const PREIMAGE_PREFIX: u8 = 33; pub(crate) const CKB_INVOICE_STATUS_PREFIX: u8 = 34; +pub(crate) const CKB_INVOICE_CHANNELS_PREFIX: u8 = 35; pub(crate) const PEER_ID_CHANNEL_ID_PREFIX: u8 = 64; pub(crate) const CHANNEL_OUTPOINT_CHANNEL_ID_PREFIX: u8 = 65; pub(crate) const BROADCAST_MESSAGE_PREFIX: u8 = 96; @@ -39,3 +42,4 @@ mod watchtower { } #[cfg(feature = "watchtower")] pub(crate) use watchtower::*; +pub(crate) const CCH_ORDER_PREFIX: u8 = 232; diff --git a/crates/fiber-lib/src/store/store_impl/mod.rs b/crates/fiber-lib/src/store/store_impl/mod.rs index 9c676b1b0..0e159e36d 100644 --- a/crates/fiber-lib/src/store/store_impl/mod.rs +++ b/crates/fiber-lib/src/store/store_impl/mod.rs @@ -17,9 +17,12 @@ use std::path::Path; use super::db_migrate::DbMigrate; use super::schema::*; +#[cfg(not(target_arch = "wasm32"))] +use crate::cch::{CchOrder, CchOrderStore, CchStoreError}; use crate::fiber::gossip::GossipMessageStore; use crate::fiber::payment::{Attempt, AttemptStatus, PaymentSession, PaymentStatus}; use crate::fiber::types::{HoldTlc, CURSOR_SIZE}; +use crate::invoice::InvoiceChannelInfo; use crate::{ fiber::{ channel::{ChannelActorState, ChannelActorStateStore, ChannelState}, @@ -173,6 +176,9 @@ impl Store { &mut errors, ); } + CCH_ORDER_PREFIX => { + check_deserialization::(&value, "CCH_ORDER_PREFIX", &mut errors); + } _ => {} } } @@ -218,6 +224,7 @@ pub enum KeyValue { CkbInvoice(Hash256, CkbInvoice), Preimage(Hash256, Hash256), CkbInvoiceStatus(Hash256, CkbInvoiceStatus), + CkbInvoiceChannels(Hash256, Vec), PeerIdChannelId((PeerId, Hash256), ChannelState), OutPointChannelId(OutPoint, Hash256), BroadcastMessageTimestamp(BroadcastMessageID, u64), @@ -236,6 +243,8 @@ pub enum KeyValue { NetworkActorState(PeerId, PersistentNetworkActorState), Attempt((Hash256, u64), Attempt), HoldTlc((Hash256, Hash256, u64), u64), + #[cfg(not(target_arch = "wasm32"))] + CchOrder(Hash256, CchOrder), } pub trait StoreKeyValue { @@ -254,6 +263,9 @@ impl StoreKeyValue for KeyValue { KeyValue::CkbInvoiceStatus(id, _) => { [&[CKB_INVOICE_STATUS_PREFIX], id.as_ref()].concat() } + KeyValue::CkbInvoiceChannels(id, _) => { + [&[CKB_INVOICE_CHANNELS_PREFIX], id.as_ref()].concat() + } KeyValue::PeerIdChannelId((peer_id, channel_id), _) => [ &[PEER_ID_CHANNEL_ID_PREFIX], peer_id.as_bytes(), @@ -321,6 +333,10 @@ impl StoreKeyValue for KeyValue { &tlc_id.to_le_bytes(), ] .concat(), + #[cfg(not(target_arch = "wasm32"))] + KeyValue::CchOrder(payment_hash, _data) => { + [&[CCH_ORDER_PREFIX], payment_hash.as_ref()].concat() + } } } @@ -330,6 +346,9 @@ impl StoreKeyValue for KeyValue { KeyValue::CkbInvoice(_, invoice) => serialize_to_vec(invoice, "CkbInvoice"), KeyValue::Preimage(_, preimage) => serialize_to_vec(preimage, "Hash256"), KeyValue::CkbInvoiceStatus(_, status) => serialize_to_vec(status, "CkbInvoiceStatus"), + KeyValue::CkbInvoiceChannels(_, channel) => { + serialize_to_vec(channel, "CkbInvoiceChannels") + } KeyValue::PeerIdChannelId(_, state) => serialize_to_vec(state, "ChannelState"), KeyValue::OutPointChannelId(_, channel_id) => serialize_to_vec(channel_id, "ChannelId"), KeyValue::PaymentSession(_, payment_session) => { @@ -359,6 +378,8 @@ impl StoreKeyValue for KeyValue { serialize_to_vec(custom_records, "PaymentCustomRecord") } KeyValue::HoldTlc(_, expired_at) => serialize_to_vec(expired_at, "HoldTlc"), + #[cfg(not(target_arch = "wasm32"))] + KeyValue::CchOrder(_, cch_order) => serialize_to_vec(cch_order, "CchOrder"), } } } @@ -546,7 +567,7 @@ impl InvoiceStore for Store { fn update_invoice_status( &self, id: &Hash256, - status: crate::invoice::CkbInvoiceStatus, + status: CkbInvoiceStatus, ) -> Result<(), InvoiceError> { self.get_invoice(id).ok_or(InvoiceError::InvoiceNotFound)?; let mut batch = self.batch(); @@ -560,6 +581,40 @@ impl InvoiceStore for Store { self.get(key) .map(|v| deserialize_from(v.as_ref(), "CkbInvoiceStatus")) } + + fn get_invoice_channel_info(&self, payment_hash: &Hash256) -> Vec { + let key = [&[CKB_INVOICE_CHANNELS_PREFIX], payment_hash.as_ref()].concat(); + self.get(key) + .map(|v| deserialize_from(&v, "CkbInvoiceChannels")) + .unwrap_or_default() + } + + fn add_invoice_channel_info( + &self, + id: &Hash256, + channel_info: InvoiceChannelInfo, + ) -> Result, InvoiceError> { + let mut batch = self.batch(); + let key = [&[CKB_INVOICE_CHANNELS_PREFIX], id.as_ref()].concat(); + let mut channels: Vec = batch + .get(&key) + .map(|v| deserialize_from(&v, "CkbInvoiceChannels")) + .unwrap_or_default(); + match channels + .iter_mut() + .find(|info| info.channel_id == channel_info.channel_id) + { + Some(info) => { + info.amount += channel_info.amount; + } + None => { + channels.push(channel_info); + } + } + batch.put_kv(KeyValue::CkbInvoiceChannels(*id, channels.clone())); + batch.commit(); + Ok(channels) + } } impl PreimageStore for Store { @@ -1115,6 +1170,48 @@ impl GossipMessageStore for Store { } } +#[cfg(not(target_arch = "wasm32"))] +impl CchOrderStore for Store { + fn get_cch_order(&self, payment_hash: &Hash256) -> Result { + let key = [&[CCH_ORDER_PREFIX], payment_hash.as_ref()].concat(); + self.get(key) + .map(|v| deserialize_from(&v, "CchOrder")) + .ok_or(CchStoreError::NotFound(*payment_hash)) + } + + fn insert_cch_order(&self, order: CchOrder) -> Result<(), CchStoreError> { + let mut batch = self.batch(); + let key = [&[CCH_ORDER_PREFIX], order.payment_hash.as_ref()].concat(); + if batch.get(key).is_none() { + batch.put_kv(KeyValue::CchOrder(order.payment_hash, order)); + batch.commit(); + Ok(()) + } else { + Err(CchStoreError::Duplicated(order.payment_hash)) + } + } + + fn update_cch_order(&self, order: CchOrder) { + let mut batch = self.batch(); + batch.put_kv(KeyValue::CchOrder(order.payment_hash, order)); + batch.commit(); + } + + fn get_cch_order_keys_iter(&self) -> impl IntoIterator { + const PREFIX_LEN: usize = 1; + const PREFIX: [u8; PREFIX_LEN] = [CCH_ORDER_PREFIX]; + self.prefix_iterator(&PREFIX).map(|(key, _)| { + Hash256::try_from(&key[PREFIX_LEN..]).expect("CchOrder key must be Hash256") + }) + } + + fn delete_cch_order(&self, payment_hash: &Hash256) { + let mut batch = self.batch(); + batch.delete([&[CCH_ORDER_PREFIX], payment_hash.as_ref()].concat()); + batch.commit(); + } +} + // All timestamps are saved in a 24-byte array, with BroadcastMessageID::ChannelAnnouncement(outpoint) as the key. // the first 8 bytes in the 24 bytes is the timestamp for channel announcement, the second 8 bytes // is the timestamp for channel update of node 1 and the last 8 bytes for channel update of node 2. diff --git a/crates/fiber-lib/src/tests/test_utils.rs b/crates/fiber-lib/src/tests/test_utils.rs index a402fd0e6..b0f0de7cd 100644 --- a/crates/fiber-lib/src/tests/test_utils.rs +++ b/crates/fiber-lib/src/tests/test_utils.rs @@ -1,3 +1,7 @@ +#[cfg(not(target_arch = "wasm32"))] +use crate::cch::{ + start_cch, tests::lnd_test_utils::LndNode, CchArgs, CchConfig, CchMessage, CchOrderStore, +}; use crate::ckb::tests::test_utils::get_tx_from_hash; use crate::ckb::tests::test_utils::MockChainActorMiddleware; use crate::ckb::CkbConfig; @@ -7,7 +11,9 @@ use crate::fiber::config::CKB_SHANNONS; use crate::fiber::features::FeatureVector; use crate::fiber::gossip::get_gossip_actor_name; use crate::fiber::gossip::GossipActorMessage; +use crate::fiber::graph::ChannelInfo; use crate::fiber::graph::NetworkGraphStateStore; +use crate::fiber::graph::NodeInfo; use crate::fiber::network::*; use crate::fiber::payment::Attempt; use crate::fiber::payment::PaymentSession; @@ -17,8 +23,11 @@ use crate::fiber::types::EcdsaSignature; use crate::fiber::types::FiberMessage; use crate::fiber::types::GossipMessage; use crate::fiber::types::Init; +use crate::fiber::types::Privkey; use crate::fiber::types::Pubkey; use crate::fiber::types::Shutdown; +#[cfg(not(target_arch = "wasm32"))] +use crate::fiber::KeyPair; use crate::fiber::ASSUME_NETWORK_ACTOR_ALIVE; use crate::gen_rand_sha256_hash; use crate::invoice::*; @@ -27,6 +36,19 @@ use crate::rpc::config::RpcConfig; use crate::rpc::invoice::{InvoiceResult, NewInvoiceParams}; #[cfg(not(target_arch = "wasm32"))] use crate::rpc::server::start_rpc; +#[cfg(not(target_arch = "wasm32"))] +use crate::store::pub_sub::{StoreWithPubSub, Subscribe}; +use crate::store::Store; +use crate::{ + actors::{RootActor, RootActorMessage}, + ckb::tests::test_utils::{submit_tx, trace_tx, MockChainActor}, + ckb::CkbChainMessage, + fiber::graph::NetworkGraph, + fiber::types::Hash256, + tasks::{new_tokio_cancellation_token, new_tokio_task_tracker}, + FiberConfig, NetworkServiceEvent, +}; + use ckb_sdk::core::TransactionBuilder; use ckb_types::core::FeeRate; use ckb_types::{ @@ -41,6 +63,8 @@ use jsonrpsee::{ rpc_params, server::ServerHandle, }; +#[cfg(not(target_arch = "wasm32"))] +use lnd::bitcoind::BitcoinD; use ractor::{call, Actor, ActorRef}; use rand::distributions::Alphanumeric; use rand::rngs::OsRng; @@ -74,24 +98,6 @@ use tracing::error; use tracing::info; use tracing::warn; -use crate::fiber::graph::ChannelInfo; -use crate::fiber::graph::NodeInfo; -use crate::fiber::network::{AcceptChannelCommand, OpenChannelCommand}; -use crate::fiber::types::Privkey; -use crate::store::Store; -use crate::{ - actors::{RootActor, RootActorMessage}, - ckb::tests::test_utils::{submit_tx, trace_tx, MockChainActor}, - ckb::CkbChainMessage, - fiber::graph::NetworkGraph, - fiber::network::{ - NetworkActor, NetworkActorCommand, NetworkActorMessage, NetworkActorStartArguments, - }, - fiber::types::Hash256, - tasks::{new_tokio_cancellation_token, new_tokio_task_tracker}, - FiberConfig, NetworkServiceEvent, -}; - static RETAIN_VAR: &str = "TEST_TEMP_RETAIN"; pub const MIN_RESERVED_CKB: u128 = 42 * CKB_SHANNONS as u128; pub const HUGE_CKB_AMOUNT: u128 = MIN_RESERVED_CKB + 1000000 * CKB_SHANNONS as u128; @@ -221,12 +227,17 @@ pub fn generate_store() -> (Store, ()) { (store.expect("create store"), ()) } +#[cfg(not(target_arch = "wasm32"))] +pub type TestStore = StoreWithPubSub; +#[cfg(target_arch = "wasm32")] +pub type TestStore = Store; + #[derive(Debug)] pub struct NetworkNode { /// The base directory of the node, will be deleted after this struct dropped. pub base_dir: Arc, pub node_name: Option, - pub store: Store, + pub store: TestStore, pub channels_tx_map: HashMap, pub fiber_config: FiberConfig, pub rpc_config: Option, @@ -234,7 +245,9 @@ pub struct NetworkNode { pub listening_addrs: Vec, pub network_actor: ActorRef, pub ckb_chain_actor: ActorRef, - pub network_graph: Arc>>, + #[cfg(not(target_arch = "wasm32"))] + pub cch: Option, + pub network_graph: Arc>>, pub chain_actor: ActorRef, pub mock_chain_actor_middleware: Option>, pub gossip_actor: ActorRef, @@ -249,14 +262,82 @@ pub struct NetworkNode { pub auth_token: Option, } +#[cfg(not(target_arch = "wasm32"))] +pub struct Cch { + pub actor: ActorRef, + pub config: CchConfig, + pub lnd_node: Option, +} + +#[cfg(not(target_arch = "wasm32"))] +impl Cch { + pub async fn start( + config: CchConfig, + should_start_lnd: bool, + network_actor: ActorRef, + node_keypair: KeyPair, + store: S, + ) -> Self { + let (config, lnd_node) = if should_start_lnd { + let lnd_node = LndNode::new(Default::default(), Default::default()).await; + let mut config = config; + // Override the lnd config with the lnd node we just created. + config.lnd_rpc_url = lnd_node.lnd.grpc_url.clone(); + config.lnd_cert_hex = Some(lnd_node.lnd.tls_cert.clone()); + config.lnd_macaroon_hex = Some(lnd_node.lnd.admin_macaroon.clone()); + (config, Some(lnd_node)) + } else { + (config, None) + }; + let root_actor = network_actor.get_cell(); + let actor = start_cch( + CchArgs { + config: config.clone(), + tracker: new_tokio_task_tracker(), + token: new_tokio_cancellation_token(), + network_actor: Some(network_actor), + node_keypair: Some(node_keypair), + store, + }, + root_actor, + ) + .await + .expect("start cch actor"); + + Cch { + actor, + config, + lnd_node, + } + } + + fn stop_actor(&self) { + self.actor.stop(Some("stop cch actor".to_string())); + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl std::fmt::Debug for Cch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Cch") + .field("actor", &self.actor) + .field("config", &self.config) + .finish() + } +} + pub struct NetworkNodeConfig { base_dir: Arc, node_name: Option, - store: Store, + store: TestStore, fiber_config: FiberConfig, rpc_config: Option, ckb_config: Option, mock_chain_actor_middleware: Option>, + #[cfg(not(target_arch = "wasm32"))] + should_start_lnd: bool, + #[cfg(not(target_arch = "wasm32"))] + cch_config: Option, } impl NetworkNodeConfig { @@ -265,6 +346,7 @@ impl NetworkNodeConfig { } } +#[derive(Default)] pub struct NetworkNodeConfigBuilder { base_dir: Option>, node_name: Option, @@ -274,23 +356,15 @@ pub struct NetworkNodeConfigBuilder { #[allow(clippy::type_complexity)] fiber_config_updater: Option>, mock_chain_actor_middleware: Option>, -} - -impl Default for NetworkNodeConfigBuilder { - fn default() -> Self { - Self::new() - } + #[cfg(not(target_arch = "wasm32"))] + should_start_lnd: Option, + #[cfg(not(target_arch = "wasm32"))] + cch_config: Option, } impl NetworkNodeConfigBuilder { pub fn new() -> Self { - Self { - base_dir: None, - node_name: None, - rpc_config: None, - fiber_config_updater: None, - mock_chain_actor_middleware: None, - } + Self::default() } pub fn base_dir(mut self, base_dir: Arc) -> Self { @@ -328,6 +402,18 @@ impl NetworkNodeConfigBuilder { self } + #[cfg(not(target_arch = "wasm32"))] + pub fn should_start_lnd(mut self, should_start_lnd: bool) -> Self { + self.should_start_lnd = Some(should_start_lnd); + self + } + + #[cfg(not(target_arch = "wasm32"))] + pub fn cch_config(mut self, cch_config: CchConfig) -> Self { + self.cch_config = Some(cch_config); + self + } + pub fn build(self) -> NetworkNodeConfig { let base_dir = self .base_dir @@ -344,6 +430,8 @@ impl NetworkNodeConfigBuilder { .collect(); let rand_db_dir = Path::new(base_dir.to_str()).join(rand_name); let store = Store::new(rand_db_dir).expect("create store"); + #[cfg(not(target_arch = "wasm32"))] + let store = StoreWithPubSub::new(store); let fiber_config = get_fiber_config(base_dir.as_ref(), node_name.as_deref()); let ckb_config = if self.rpc_config.is_some() { let ckb_dir = Path::new(base_dir.to_str()).join("ckb"); @@ -369,6 +457,10 @@ impl NetworkNodeConfigBuilder { fiber_config, rpc_config, mock_chain_actor_middleware: self.mock_chain_actor_middleware, + #[cfg(not(target_arch = "wasm32"))] + should_start_lnd: self.should_start_lnd.unwrap_or(false), + #[cfg(not(target_arch = "wasm32"))] + cch_config: self.cch_config, }; if let Some(updater) = self.fiber_config_updater { @@ -610,7 +702,11 @@ pub(crate) async fn create_n_nodes_network_with_params( for node in nodes.iter_mut() { let res = node.submit_tx(funding_tx.clone()).await; node.add_channel_tx(channel_id, funding_tx.hash().into()); - assert!(matches!(res, TxStatus::Committed(..))); + assert!( + matches!(res, TxStatus::Committed(..)), + "expect committed tx, got {:?}", + res + ); } } wait_for_network_graph_update(&nodes[0], amounts.len()).await; @@ -704,6 +800,22 @@ impl NetworkNode { self.store.get_preimage(payment_hash) } + #[allow(private_interfaces)] + pub async fn settle_invoice( + &self, + payment_hash: &Hash256, + preimage: &Hash256, + ) -> Result<(), SettleInvoiceError> { + let message = move |rpc_reply| -> NetworkActorMessage { + NetworkActorMessage::Command(NetworkActorCommand::SettleInvoice( + *payment_hash, + *preimage, + rpc_reply, + )) + }; + call!(&self.network_actor, message).expect("call network actor") + } + pub async fn send_payment( &self, command: SendPaymentCommand, @@ -1307,35 +1419,38 @@ impl NetworkNode { } pub async fn new_with_config(config: NetworkNodeConfig) -> Self { - let NetworkNodeConfig { - base_dir, - node_name, - store, - fiber_config, - ckb_config, - rpc_config, - mock_chain_actor_middleware, - } = config; + let base_dir = config.base_dir; + let node_name = config.node_name; + let store = config.store; + let fiber_config = config.fiber_config; + let ckb_config = config.ckb_config; + let rpc_config = config.rpc_config; + let mock_chain_actor_middleware = config.mock_chain_actor_middleware; + #[cfg(not(target_arch = "wasm32"))] + let should_start_lnd = config.should_start_lnd; + #[cfg(not(target_arch = "wasm32"))] + let cch_config = config.cch_config; let _span = tracing::info_span!("NetworkNode", node_name = &node_name).entered(); - let root = get_test_root_actor().await; + let root_actor = get_test_root_actor().await; + let (event_sender, mut event_receiver) = mpsc::channel(10000); let chain_actor = Actor::spawn_linked( None, MockChainActor::new(), mock_chain_actor_middleware.clone(), - root.get_cell(), + root_actor.get_cell(), ) .await .expect("start mock chain actor") .0; - let private_key: Privkey = fiber_config + let keypair = fiber_config .read_or_generate_secret_key() - .expect("must generate key") - .into(); + .expect("must generate key"); + let private_key: Privkey = keypair.clone().into(); let pubkey = private_key.pubkey(); let network_graph = Arc::new(TokioRwLock::new(NetworkGraph::new( @@ -1355,10 +1470,9 @@ impl NetworkNode { NetworkActorStartArguments { config: fiber_config.clone(), tracker: new_tokio_task_tracker(), - channel_subscribers: Default::default(), default_shutdown_script: Default::default(), }, - root.get_cell(), + root_actor.get_cell(), ) .await .expect("start network actor") @@ -1420,6 +1534,23 @@ impl NetworkNode { base_dir.as_ref() ); + #[cfg(not(target_arch = "wasm32"))] + let cch = match cch_config { + Some(config) => { + let cch = Cch::start( + config, + should_start_lnd, + network_actor.clone(), + keypair, + store.clone(), + ) + .await; + store.subscribe(Box::new(cch.actor.clone())); + Some(cch) + } + None => None, + }; + let gossip_actor = ractor::registry::where_is(get_gossip_actor_name(&peer_id)) .expect("gossip actor should have been started") .into(); @@ -1433,7 +1564,8 @@ impl NetworkNode { Some(network_actor.clone()), None, store.clone(), - network_graph.clone(), + Some(network_graph.clone()), + root_actor.get_cell(), #[cfg(debug_assertions)] None, #[cfg(debug_assertions)] @@ -1457,6 +1589,8 @@ impl NetworkNode { listening_addrs: announced_addrs, network_actor, ckb_chain_actor: chain_actor.clone(), + #[cfg(not(target_arch = "wasm32"))] + cch, mock_chain_actor_middleware, network_graph, chain_actor, @@ -1482,6 +1616,10 @@ impl NetworkNode { fiber_config: self.fiber_config.clone(), rpc_config: self.rpc_config.clone(), mock_chain_actor_middleware: self.mock_chain_actor_middleware.clone(), + #[cfg(not(target_arch = "wasm32"))] + should_start_lnd: false, + #[cfg(not(target_arch = "wasm32"))] + cch_config: self.cch.as_ref().map(|cch| cch.config.clone()), } } @@ -1541,6 +1679,10 @@ impl NetworkNode { |event| matches!(event, NetworkServiceEvent::NetworkStopped(id) if id == &my_peer_id), ) .await; + #[cfg(not(target_arch = "wasm32"))] + if let Some(cch) = self.cch.as_ref() { + cch.stop_actor(); + } } pub async fn restart(&mut self) { @@ -1734,7 +1876,7 @@ impl NetworkNode { .and_then(|response| response.transaction) } - pub fn get_network_graph(&self) -> &Arc>> { + pub fn get_network_graph(&self) -> &Arc>> { &self.network_graph } @@ -1744,7 +1886,7 @@ impl NetworkNode { pub async fn with_network_graph(&self, f: F) -> T where - F: FnOnce(&NetworkGraph) -> T, + F: FnOnce(&NetworkGraph) -> T, { let graph = self.get_network_graph().read().await; f(&graph) @@ -1752,7 +1894,7 @@ impl NetworkNode { pub async fn with_network_graph_mut(&self, f: F) -> T where - F: FnOnce(&mut NetworkGraph) -> T, + F: FnOnce(&mut NetworkGraph) -> T, { let mut graph = self.get_network_graph().write().await; f(&mut graph) @@ -1794,9 +1936,36 @@ impl NetworkNode { )); } - pub fn get_store(&self) -> &Store { + pub fn get_store(&self) -> &TestStore { &self.store } + + #[cfg(not(target_arch = "wasm32"))] + pub fn get_cch_actor(&self) -> &ActorRef { + &self.cch.as_ref().expect("cch started").actor + } + + #[cfg(not(target_arch = "wasm32"))] + pub fn get_bitcoind(&self) -> Arc { + self.cch + .as_ref() + .expect("cch started") + .lnd_node + .as_ref() + .expect("lnd started") + .bitcoind + .clone() + } + + #[cfg(not(target_arch = "wasm32"))] + pub fn get_lnd_node_mut(&mut self) -> &mut LndNode { + self.cch + .as_mut() + .expect("cch started") + .lnd_node + .as_mut() + .expect("lnd started") + } } pub async fn create_mock_chain_actor() -> ActorRef { @@ -1856,3 +2025,39 @@ async fn test_restart_network_node() { let mut node = NetworkNode::new().await; node.restart().await; } + +#[cfg(not(target_arch = "wasm32"))] +#[cfg_attr(not(feature = "lnd-tests"), ignore)] +#[tokio::test] +async fn test_start_node_with_cch_connected_to_internal_lnd_node() { + let mut node = NetworkNode::new_with_config( + NetworkNodeConfigBuilder::new() + .cch_config(CchConfig::default()) + .should_start_lnd(true) + .build(), + ) + .await; + let cch = node.cch.as_mut().expect("cch started"); + let lnd_node = cch.lnd_node.as_mut().expect("lnd node started"); + println!("lnd_node: {:?}", lnd_node.get_info().await); +} + +#[cfg(not(target_arch = "wasm32"))] +#[cfg_attr(not(feature = "lnd-tests"), ignore)] +#[tokio::test] +async fn test_start_node_with_cch_connected_to_external_lnd_node() { + let cch_config = CchConfig { + // The default lnd_rpc_url may actually work, so we need to set it to an invalid value. + // Let's assume that the following URL is not a valid lnd RPC URL. + lnd_rpc_url: "http://1.1.1.1:1".to_string(), + ..Default::default() + }; + let mut node = NetworkNode::new_with_config( + NetworkNodeConfigBuilder::new() + .cch_config(cch_config) + .build(), + ) + .await; + let cch = node.cch.as_mut().expect("cch started"); + assert!(cch.lnd_node.is_none()); +} diff --git a/crates/fiber-lib/src/watchtower/mod.rs b/crates/fiber-lib/src/watchtower/mod.rs index 0dcbc1049..c698821c4 100644 --- a/crates/fiber-lib/src/watchtower/mod.rs +++ b/crates/fiber-lib/src/watchtower/mod.rs @@ -2,4 +2,4 @@ mod actor; mod store; pub use actor::{WatchtowerActor, WatchtowerMessage, DEFAULT_WATCHTOWER_CHECK_INTERVAL_SECONDS}; -pub use store::{ChannelData, WatchtowerStore}; +pub use store::{ChannelData, WatchtowerStore, WatchtowerStoreDeref}; diff --git a/crates/fiber-lib/src/watchtower/store.rs b/crates/fiber-lib/src/watchtower/store.rs index 71d38717c..058de74c1 100644 --- a/crates/fiber-lib/src/watchtower/store.rs +++ b/crates/fiber-lib/src/watchtower/store.rs @@ -51,6 +51,86 @@ pub trait WatchtowerStore { fn search_preimage(&self, payment_hash_prefix: &[u8]) -> Option; } +/// Used for delegating the store trait +pub trait WatchtowerStoreDeref { + type Target: WatchtowerStore; + fn watchtower_store_deref(&self) -> &Self::Target; +} + +impl WatchtowerStore for T { + fn get_watch_channels(&self) -> Vec { + self.watchtower_store_deref().get_watch_channels() + } + + fn insert_watch_channel( + &self, + node_id: NodeId, + channel_id: Hash256, + funding_tx_lock: Script, + remote_settlement_data: SettlementData, + ) { + self.watchtower_store_deref().insert_watch_channel( + node_id, + channel_id, + funding_tx_lock, + remote_settlement_data, + ); + } + + fn remove_watch_channel(&self, node_id: NodeId, channel_id: Hash256) { + self.watchtower_store_deref() + .remove_watch_channel(node_id, channel_id); + } + + fn update_revocation( + &self, + node_id: NodeId, + channel_id: Hash256, + revocation_data: RevocationData, + remote_settlement_data: SettlementData, + ) { + self.watchtower_store_deref().update_revocation( + node_id, + channel_id, + revocation_data, + remote_settlement_data, + ); + } + + fn update_local_settlement( + &self, + node_id: NodeId, + channel_id: Hash256, + local_settlement_data: SettlementData, + ) { + self.watchtower_store_deref().update_local_settlement( + node_id, + channel_id, + local_settlement_data, + ); + } + + fn insert_watch_preimage(&self, node_id: NodeId, payment_hash: Hash256, preimage: Hash256) { + self.watchtower_store_deref() + .insert_watch_preimage(node_id, payment_hash, preimage); + } + + fn remove_watch_preimage(&self, node_id: NodeId, payment_hash: Hash256) { + self.watchtower_store_deref() + .remove_watch_preimage(node_id, payment_hash); + } + + fn get_watch_preimage(&self, payment_hash: &Hash256) -> Option { + self.watchtower_store_deref() + .get_watch_preimage(payment_hash) + } + + fn search_preimage(&self, payment_hash_prefix: &[u8]) -> Option { + self.watchtower_store_deref() + .search_preimage(payment_hash_prefix) + } +} + /// The data of a channel that the watchtower is monitoring #[serde_as] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/crates/fiber-wasm/src/api.rs b/crates/fiber-wasm/src/api.rs index 4f90190ef..1232e680c 100644 --- a/crates/fiber-wasm/src/api.rs +++ b/crates/fiber-wasm/src/api.rs @@ -195,6 +195,15 @@ pub mod invoice { .map(result) .map_err(error) } + #[wasm_bindgen] + pub async fn settle_invoice(params: JsValue) -> Result { + fiber_wasm()? + .invoice + .settle_invoice(param(params)?) + .await + .map(result) + .map_err(error) + } } pub mod payment { use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; diff --git a/crates/fiber-wasm/src/lib.rs b/crates/fiber-wasm/src/lib.rs index 4609c3900..117b79875 100644 --- a/crates/fiber-wasm/src/lib.rs +++ b/crates/fiber-wasm/src/lib.rs @@ -13,7 +13,7 @@ use fnn::{ CkbChainActor, contracts::{TypeIDResolver, try_init_contracts_context}, }, - fiber::{KeyPair, channel::ChannelSubscribers, graph::NetworkGraph, network::init_chain_hash}, + fiber::{KeyPair, graph::NetworkGraph, network::init_chain_hash}, rpc::{ channel::ChannelRpcServerImpl, graph::GraphRpcServerImpl, @@ -140,7 +140,6 @@ pub async fn fiber( let token = new_tokio_cancellation_token(); let root_actor = RootActor::start(tracker, token).await; ROOT_ACTOR.set(root_actor.clone()).unwrap(); - let subscribers = ChannelSubscribers::default(); #[allow(unused_variables)] let (network_actor, ckb_chain_actor, network_graph) = match config.fiber.clone() { @@ -211,7 +210,6 @@ pub async fn fiber( new_tokio_task_tracker(), root_actor.get_cell(), store.clone(), - subscribers.clone(), network_graph.clone(), default_shutdown_script, ) @@ -281,7 +279,11 @@ pub async fn fiber( channel: ChannelRpcServerImpl::new(network_actor.clone(), store.clone()), graph: GraphRpcServerImpl::new(network_graph.clone(), store.clone()), info: InfoRpcServerImpl::new(network_actor.clone(), config.ckb.unwrap_or_default()), - invoice: InvoiceRpcServerImpl::new(store.clone(), config.fiber), + invoice: InvoiceRpcServerImpl::new( + store.clone(), + Some(network_actor.clone()), + config.fiber, + ), payment: PaymentRpcServerImpl::new(network_actor.clone(), store.clone()), peer: PeerRpcServerImpl::new(network_actor.clone()), }) diff --git a/docs/biscuit-auth.md b/docs/biscuit-auth.md index e2c84ddc2..991f2b55f 100644 --- a/docs/biscuit-auth.md +++ b/docs/biscuit-auth.md @@ -37,7 +37,7 @@ The current rules for each RPC methods: // Cch rule("send_btc", r#"allow if write("cch");"#); rule("receive_btc", r#"allow if read("cch");"#); -rule("get_receive_btc_order", r#"allow if read("cch");"#); +rule("get_cch_order", r#"allow if read("cch");"#); // channels rule("open_channel", r#"allow if write("channels");"#); rule("accept_channel", r#"allow if write("channels");"#); @@ -67,6 +67,7 @@ rule("new_invoice", r#"allow if write("invoices");"#); rule("parse_invoice", r#"allow if read("invoices");"#); rule("get_invoice", r#"allow if read("invoices");"#); rule("cancel_invoice", r#"allow if write("invoices");"#); +rule("settle_invoice", r#"allow if write("invoices");"#); // payment rule("send_payment", r#"allow if write("payments");"#); diff --git a/fiber-js/src/index.ts b/fiber-js/src/index.ts index f89916c05..337f61f4f 100644 --- a/fiber-js/src/index.ts +++ b/fiber-js/src/index.ts @@ -6,7 +6,7 @@ import { DbWorkerInitializationOptions, FiberInvokeRequest, FiberInvokeResponse, import { AbandonChannelParams, AcceptChannelParams, AcceptChannelResult, ListChannelsParams, ListChannelsResult, OpenChannelParams, OpenChannelResult, ShutdownChannelParams, UpdateChannelParams } from "./types/channel.ts"; import { GraphChannelsParams, GraphChannelsResult, GraphNodesParams, GraphNodesResult } from "./types/graph.ts"; import { NodeInfoResult } from "./types/info.ts"; -import { GetInvoiceResult, InvoiceParams, InvoiceResult, NewInvoiceParams, ParseInvoiceParams, ParseInvoiceResult } from "./types/invoice.ts"; +import { GetInvoiceResult, InvoiceParams, InvoiceResult, NewInvoiceParams, ParseInvoiceParams, ParseInvoiceResult, SettleInvoiceParams, SettleInvoiceResult } from "./types/invoice.ts"; import { BuildPaymentRouterResult, BuildRouterParams, GetPaymentCommandParams, GetPaymentCommandResult, SendPaymentCommandParams, SendPaymentWithRouterParams } from "./types/payment.ts"; import { ConnectPeerParams, DisconnectPeerParams, ListPeerResult } from "./types/peer.ts"; @@ -155,6 +155,9 @@ class Fiber { async cancelInvoice(params: InvoiceParams): Promise { return await this.invokeCommand("cancel_invoice", [params]); } + async settleInvoice(params: SettleInvoiceParams): Promise { + return await this.invokeCommand("cancel_invoice", [params]); + } async sendPayment(params: SendPaymentCommandParams): Promise { return await this.invokeCommand("send_payment", [params]); } diff --git a/fiber-js/src/types/invoice.ts b/fiber-js/src/types/invoice.ts index ee290dc34..140609c95 100644 --- a/fiber-js/src/types/invoice.ts +++ b/fiber-js/src/types/invoice.ts @@ -59,6 +59,14 @@ interface GetInvoiceResult { status: CkbInvoiceStatus; } +interface SettleInvoiceParams { + payment_hash: HexString, + payment_preimage: HexString, +} + +interface SettleInvoiceResult { +} + export type { NewInvoiceParams, InvoiceResult, @@ -71,5 +79,7 @@ export type { InvoiceData, InvoiceParams, ParseInvoiceParams, - ParseInvoiceResult + ParseInvoiceResult, + SettleInvoiceParams, + SettleInvoiceResult } diff --git a/tests/bruno/e2e/cross-chain-hub-separate/01-add-btc-invoice.bru b/tests/bruno/e2e/cross-chain-hub-separate/01-add-btc-invoice.bru new file mode 100644 index 000000000..4427847be --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub-separate/01-add-btc-invoice.bru @@ -0,0 +1,30 @@ +meta { + name: 01-add-btc-invoice + type: http + seq: 1 +} + +post { + url: {{LND_BOB_RPC_URL}}/v1/invoices + body: json + auth: none +} + +body:json { + {"value":100000} +} + +assert { + res.status: eq 200 +} + +script:post-response { + bru.setVar("BTC_PAY_REQ", res.body.payment_request); + const payment_hash = Buffer.from(res.body.r_hash, 'base64').toString('hex'); + console.log(payment_hash); + bru.setVar("PAYMENT_HASH", `0x${payment_hash}`); +} + +docs { + BTC user generates an invoice via lnd. +} diff --git a/tests/bruno/e2e/cross-chain-hub-separate/02-create-send-btc-order.bru b/tests/bruno/e2e/cross-chain-hub-separate/02-create-send-btc-order.bru new file mode 100644 index 000000000..167c74151 --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub-separate/02-create-send-btc-order.bru @@ -0,0 +1,47 @@ +meta { + name: 02-create-send-btc-order + type: http + seq: 2 +} + +post { + url: {{NODE_CCH_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "send_btc", + "params": [ + { + "btc_pay_req": "{{BTC_PAY_REQ}}", + "currency": "Fibd" + } + ] + } +} + +assert { + res.status: eq 200 + res.body.error: isUndefined + res.body.result.incoming_invoice.Fiber: isDefined +} + +script:post-response { + if (res.body.result) { + bru.setVar("FIBER_PAY_INVOICE", res.body.result.incoming_invoice.Fiber); + console.log(res.body.result.payment_hash); + } +} + +docs { + CKB user sends the received BTC invoice to the cross-chain hub to exchange a CKB invoice. +} diff --git a/tests/bruno/e2e/cross-chain-hub/12-remove-tlc-for-receive-btc-order.bru b/tests/bruno/e2e/cross-chain-hub-separate/03-node1-connect-node3.bru similarity index 51% rename from tests/bruno/e2e/cross-chain-hub/12-remove-tlc-for-receive-btc-order.bru rename to tests/bruno/e2e/cross-chain-hub-separate/03-node1-connect-node3.bru index a01fe13de..29067bba7 100644 --- a/tests/bruno/e2e/cross-chain-hub/12-remove-tlc-for-receive-btc-order.bru +++ b/tests/bruno/e2e/cross-chain-hub-separate/03-node1-connect-node3.bru @@ -1,7 +1,7 @@ meta { - name: 12-remove-tlc-for-receive-btc-order + name: 03-node1-connect-node3 type: http - seq: 12 + seq: 3 } post { @@ -14,20 +14,13 @@ headers { Content-Type: application/json Accept: application/json } - body:json { { "id": "42", "jsonrpc": "2.0", - "method": "remove_tlc", + "method": "connect_peer", "params": [ - { - "channel_id": "{{N1N3_CHANNEL_ID}}", - "tlc_id": "{{N3N1_TLC_ID1}}", - "reason": { - "payment_preimage": "{{PAYMENT_PREIMAGE}}" - } - } + {"address": "{{NODE3_ADDR}}"} ] } } @@ -36,3 +29,9 @@ assert { res.body.error: isUndefined res.body.result: isNull } + +script:post-response { + // Dialing a peer is async in tentacle. Sleep for some time to make sure + // we're connected to the peer. + await new Promise(r => setTimeout(r, 1000)); +} diff --git a/tests/bruno/e2e/cross-chain-hub-separate/04-node1-open-channel-to-node3.bru b/tests/bruno/e2e/cross-chain-hub-separate/04-node1-open-channel-to-node3.bru new file mode 100644 index 000000000..cd33372a0 --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub-separate/04-node1-open-channel-to-node3.bru @@ -0,0 +1,47 @@ +meta { + name: 04-node1-open-channel-to-node3 + type: http + seq: 4 +} + +post { + url: {{NODE1_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "open_channel", + "params": [ + { + "peer_id": "{{NODE3_PEERID}}", + "funding_amount": "0x30d40", + "funding_udt_type_script": { + "code_hash": "{{UDT_CODE_HASH}}", + "hash_type": "data1", + "args": "{{UDT_SCRIPT_ARGS}}" + } + } + ] + } +} + +assert { + res.body.error: isUndefined + res.body.result.temporary_channel_id: isDefined +} + +script:post-response { + await new Promise(r => setTimeout(r, 1000)); + console.log("N1N3 response: ", res.body); + console.log("N1N3 response: ", res.body.result.temporary_channel_id); + bru.setVar("N1N3_TEMP_CHANNEL_ID", res.body.result.temporary_channel_id); +} diff --git a/tests/bruno/e2e/cross-chain-hub-separate/05-node3-accept-channel.bru b/tests/bruno/e2e/cross-chain-hub-separate/05-node3-accept-channel.bru new file mode 100644 index 000000000..9c365961a --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub-separate/05-node3-accept-channel.bru @@ -0,0 +1,41 @@ +meta { + name: 05-node3-accept-channel + type: http + seq: 5 +} + +post { + url: {{NODE3_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "list_channels", + "params": [ + { + "peer_id": "{{NODE1_PEERID}}" + } + ] + } +} + +assert { + res.body.error: isUndefined + res.body.result.channels: isDefined +} + +script:post-response { + // Sleep for sometime to make sure current operation finishes before next request starts. + await new Promise(r => setTimeout(r, 2000)); + console.log("accept channel result: ", res.body); + bru.setVar("N1N3_CHANNEL_ID", res.body.result.channels[0].channel_id); +} diff --git a/tests/bruno/e2e/cross-chain-hub-separate/06-ckb-generate-blocks.bru b/tests/bruno/e2e/cross-chain-hub-separate/06-ckb-generate-blocks.bru new file mode 100644 index 000000000..1c4e93c25 --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub-separate/06-ckb-generate-blocks.bru @@ -0,0 +1,33 @@ +meta { + name: 06-ckb-generate-blocks + type: http + seq: 6 +} + +post { + url: {{CKB_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": 42, + "jsonrpc": "2.0", + "method": "generate_epochs", + "params": ["0x2"] + } +} + +assert { + res.status: eq 200 +} + +script:post-response { + await new Promise(r => setTimeout(r, 5000)); +} diff --git a/tests/bruno/e2e/cross-chain-hub/07-node1-add-tlc.bru b/tests/bruno/e2e/cross-chain-hub-separate/07-node1-send-payment.bru similarity index 66% rename from tests/bruno/e2e/cross-chain-hub/07-node1-add-tlc.bru rename to tests/bruno/e2e/cross-chain-hub-separate/07-node1-send-payment.bru index da62b44e4..88631a27b 100644 --- a/tests/bruno/e2e/cross-chain-hub/07-node1-add-tlc.bru +++ b/tests/bruno/e2e/cross-chain-hub-separate/07-node1-send-payment.bru @@ -1,5 +1,5 @@ meta { - name: 07-node1-add-tlc + name: 07-node1-send-payment type: http seq: 7 } @@ -19,14 +19,10 @@ body:json { { "id": "42", "jsonrpc": "2.0", - "method": "add_tlc", + "method": "send_payment", "params": [ { - "channel_id": "{{N1N3_CHANNEL_ID}}", - "amount": "0x4e20", - "payment_hash": "{{PAYMENT_HASH}}", - "expiry": "{{expiry}}", - "hash_algorithm": "sha256" + "invoice": "{{FIBER_PAY_INVOICE}}" } ] } @@ -34,7 +30,6 @@ body:json { assert { res.body.error: isUndefined - res.body.result.tlc_id: isDefined } script:pre-request { @@ -46,5 +41,4 @@ script:post-response { // Sleep for sometime to make sure current operation finishes before next request starts. await new Promise(r => setTimeout(r, 100)); console.log("response from node1 AddTlc:", res.body); - bru.setVar("N1N3_TLC_ID1", res.body.result.tlc_id); } diff --git a/tests/bruno/e2e/cross-chain-hub-separate/08-check-btc-received.bru b/tests/bruno/e2e/cross-chain-hub-separate/08-check-btc-received.bru new file mode 100644 index 000000000..b8b0bfe36 --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub-separate/08-check-btc-received.bru @@ -0,0 +1,45 @@ +meta { + name: 08-check-btc-received + type: http + seq: 8 +} + +get { + url: {{LND_BOB_RPC_URL}}/v1/balance/channels + body: none + auth: none +} + +vars:post-response { + max_iterations: 10 +} + +assert { + res.status: eq 200 +} + +script:pre-request { + if(bru.getVar("iteration") === undefined){ + bru.setVar("iteration", 0); + } +} + +script:post-response { + const i = bru.getVar("iteration"); + const n = bru.getVar("max_iterations"); + if (i < n) { + console.log(`Try ${i+1}/${n}`); + } + + if (parseInt(res.body.local_balance.sat, 10) > 0) { + console.log("Bob has received the payment"); + bru.setVar("iteration", 0); + } else if (i+1 < n) { + await new Promise(r => setTimeout(r, 100)); + bru.setVar("iteration", i + 1); + bru.setNextRequest("08-check-btc-received"); + } else { + bru.setVar("iteration", 0); + throw new Error("Bob has not received the payment"); + } +} diff --git a/tests/bruno/e2e/cross-chain-hub-separate/09-create-fiber-invoice.bru b/tests/bruno/e2e/cross-chain-hub-separate/09-create-fiber-invoice.bru new file mode 100644 index 000000000..4473af461 --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub-separate/09-create-fiber-invoice.bru @@ -0,0 +1,66 @@ +meta { + name: 09-create-fiber-invoice + type: http + seq: 9 +} + +post { + url: {{NODE1_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "new_invoice", + "params": [ + { + "amount": "0xc350", + "currency": "Fibd", + "description": "test invoice", + "udt_type_script": { + "args": "{{UDT_SCRIPT_ARGS}}", + "code_hash": "0xe1e354d6d643ad42724d40967e334984534e0367405c5ae42a9d7d63d77df419", + "hash_type": "data1" + }, + "payment_preimage": "{{payment_preimage}}" + } + ] + } +} + +script:pre-request { + // generate random preimage + function generateRandomPreimage() { + let hash = '0x'; + for (let i = 0; i < 64; i++) { + hash += Math.floor(Math.random() * 16).toString(16); + } + return hash; + } + const payment_preimage = generateRandomPreimage(); + bru.setVar("payment_preimage", payment_preimage); +} + +assert { + res.status: eq 200 + res.body.error: isUndefined + res.body.result.invoice_address: isDefined + res.body.result.invoice.data.payment_hash: isDefined +} + +script:post-response { + bru.setVar("FIBER_PAY_REQ", res.body.result.invoice_address); + bru.setVar("PAYMENT_HASH", res.body.result.invoice.data.payment_hash); +} + +docs { + Create a fiber invoice to receive money +} diff --git a/tests/bruno/e2e/cross-chain-hub-separate/10-create-receive-btc-order.bru b/tests/bruno/e2e/cross-chain-hub-separate/10-create-receive-btc-order.bru new file mode 100644 index 000000000..2d60e63e3 --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub-separate/10-create-receive-btc-order.bru @@ -0,0 +1,44 @@ +meta { + name: 10-create-receive-btc-order + type: http + seq: 10 +} + +post { + url: {{NODE_CCH_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "receive_btc", + "params": [ + { + "fiber_pay_req": "{{FIBER_PAY_REQ}}" + } + ] + } +} + +assert { + res.status: eq 200 + res.body.error: isUndefined + res.body.result.incoming_invoice.Lightning: isDefined +} + +script:post-response { + bru.setVar("BTC_PAY_REQ", res.body.result.incoming_invoice.Lightning); + console.log(res.body.result.incoming_invoice.Lightning); +} + +docs { + CKB user requests a BTC invoice to receive BTC from Bitcoin user. +} diff --git a/tests/bruno/e2e/cross-chain-hub-separate/11-get-receive-btc-order-tlc-id.bru b/tests/bruno/e2e/cross-chain-hub-separate/11-get-receive-btc-order-tlc-id.bru new file mode 100644 index 000000000..b621a23ab --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub-separate/11-get-receive-btc-order-tlc-id.bru @@ -0,0 +1,34 @@ +meta { + name: 11-check-receive-btc-order + type: http + seq: 11 +} + +post { + url: {{NODE_CCH_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "get_cch_order", + "params": [ + { + "payment_hash": "{{PAYMENT_HASH}}" + } + ] + } +} + +assert { + res.body.error: isUndefined + res.status: eq 200 +} diff --git a/tests/bruno/e2e/cross-chain-hub/10-pay-btc-invoice.bru b/tests/bruno/e2e/cross-chain-hub-separate/12-pay-btc-invoice.bru similarity index 96% rename from tests/bruno/e2e/cross-chain-hub/10-pay-btc-invoice.bru rename to tests/bruno/e2e/cross-chain-hub-separate/12-pay-btc-invoice.bru index a1c0e77e6..bcd038ca3 100644 --- a/tests/bruno/e2e/cross-chain-hub/10-pay-btc-invoice.bru +++ b/tests/bruno/e2e/cross-chain-hub-separate/12-pay-btc-invoice.bru @@ -1,7 +1,7 @@ meta { - name: 10-pay-btc-invoice + name: 12-pay-btc-invoice type: http - seq: 10 + seq: 12 } post { diff --git a/tests/bruno/e2e/cross-chain-hub-separate/13-get-invoice-status.bru b/tests/bruno/e2e/cross-chain-hub-separate/13-get-invoice-status.bru new file mode 100644 index 000000000..76325f069 --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub-separate/13-get-invoice-status.bru @@ -0,0 +1,66 @@ +meta { + name: 13-get-invoice-status + type: http + seq: 13 +} + +post { + url: {{NODE1_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "get_invoice", + "params": [ + { + "payment_hash": "{{PAYMENT_HASH}}" + } + ] + } +} + +assert { + res.body.error: isUndefined + res.body.result.status: isDefined +} + + +script:pre-request { + if(bru.getVar("iteration") === undefined){ + bru.setVar("iteration", 0); + } +} + +vars:post-response { + max_iterations: 20 +} + +script:post-response { + const i = bru.getVar("iteration"); + const n = bru.getVar("max_iterations"); + if (i < n) { + console.log(`Try ${i+1}/${n}`); + console.log(res.body) + } + + if (res.body.result.status == "Paid") { + console.log("Invoicee is paid"); + bru.setVar("iteration", 0); + } else if (i+1 < n) { + await new Promise(r => setTimeout(r, 100)); + bru.setVar("iteration", i + 1); + bru.setNextRequest("13-get-invoice-status"); + } else { + bru.setVar("iteration", 0); + throw new Error("Alice has not received the payment"); + } +} diff --git a/tests/bruno/e2e/cross-chain-hub/13-node1-send-shutdown-channel.bru b/tests/bruno/e2e/cross-chain-hub-separate/14-node1-send-shutdown-channel.bru similarity index 89% rename from tests/bruno/e2e/cross-chain-hub/13-node1-send-shutdown-channel.bru rename to tests/bruno/e2e/cross-chain-hub-separate/14-node1-send-shutdown-channel.bru index a706c2efa..78a17950a 100644 --- a/tests/bruno/e2e/cross-chain-hub/13-node1-send-shutdown-channel.bru +++ b/tests/bruno/e2e/cross-chain-hub-separate/14-node1-send-shutdown-channel.bru @@ -1,7 +1,7 @@ meta { - name: 13-node1-send-shutdown-channel + name: 14-node1-send-shutdown-channel type: http - seq: 13 + seq: 14 } post { @@ -35,7 +35,7 @@ body:json { } script:pre-request { - await new Promise(r => setTimeout(r, 1000)); + await new Promise(r => setTimeout(r, 5000)); } assert { diff --git a/tests/bruno/e2e/cross-chain-hub/14-node3-list-channel.bru b/tests/bruno/e2e/cross-chain-hub-separate/15-node3-list-channel.bru similarity index 92% rename from tests/bruno/e2e/cross-chain-hub/14-node3-list-channel.bru rename to tests/bruno/e2e/cross-chain-hub-separate/15-node3-list-channel.bru index aadd17ac0..5e4d40efd 100644 --- a/tests/bruno/e2e/cross-chain-hub/14-node3-list-channel.bru +++ b/tests/bruno/e2e/cross-chain-hub-separate/15-node3-list-channel.bru @@ -1,7 +1,7 @@ meta { - name: 14-node3-list-channel + name: 15-node3-list-channel type: http - seq: 14 + seq: 15 } post { @@ -32,4 +32,4 @@ assert { res.body.error: isUndefined res.body.result.channels: isDefined res.body.result.channels.map(channel => channel.channel_id): notContains {{N1N3_CHANNEL_ID}} -} \ No newline at end of file +} diff --git a/tests/bruno/e2e/cross-chain-hub-separate/README.md b/tests/bruno/e2e/cross-chain-hub-separate/README.md new file mode 100644 index 000000000..e587b6bec --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub-separate/README.md @@ -0,0 +1,14 @@ +# Cross-Chain Hub + +## Roles + +- Bitcoin user: lnd node lnd-bob +- CKB user: FNN 1 +- Cross-Chain Hub service provider: lnd node lnd-ingrid and FNN 3 + +## Run Locally + +1. Install [bitcoind](https://bitcoin.org/en/download), [lnd](https://github.com/lightningnetwork/lnd), and [jq](https://jqlang.github.io/jq/download/). Ensure that the executables are in your PATH. +2. Start Bitcoin and LND nodes using `tests/deploy/lnd-init/setup-lnd.sh`. +3. Start CKB and FNN using `tests/nodes/start.sh`. +4. Go to `tests/bruno` and run the command `npm exec -- @usebruno/cli run e2e/cross-chain-hub -r --env test`. diff --git a/tests/bruno/e2e/cross-chain-hub/01-add-btc-invoice.bru b/tests/bruno/e2e/cross-chain-hub/01-add-btc-invoice.bru index 381292e23..4427847be 100644 --- a/tests/bruno/e2e/cross-chain-hub/01-add-btc-invoice.bru +++ b/tests/bruno/e2e/cross-chain-hub/01-add-btc-invoice.bru @@ -11,7 +11,7 @@ post { } body:json { - {"value":20000} + {"value":100000} } assert { diff --git a/tests/bruno/e2e/cross-chain-hub/02-create-send-btc-order.bru b/tests/bruno/e2e/cross-chain-hub/02-create-send-btc-order.bru index 3ba61a553..3509e3276 100644 --- a/tests/bruno/e2e/cross-chain-hub/02-create-send-btc-order.bru +++ b/tests/bruno/e2e/cross-chain-hub/02-create-send-btc-order.bru @@ -23,7 +23,7 @@ body:json { "params": [ { "btc_pay_req": "{{BTC_PAY_REQ}}", - "currency": "Fibt" + "currency": "Fibd" } ] } @@ -32,11 +32,12 @@ body:json { assert { res.status: eq 200 res.body.error: isUndefined + res.body.result.incoming_invoice.Fiber: isDefined } script:post-response { if (res.body.result) { - bru.setVar("CKB_PAY_REQ", res.body.result.ckb_pay_req); + bru.setVar("FIBER_PAY_INVOICE", res.body.result.incoming_invoice.Fiber); console.log(res.body.result.payment_hash); } } diff --git a/tests/bruno/e2e/cross-chain-hub/04-node1-open-channel-to-node3.bru b/tests/bruno/e2e/cross-chain-hub/04-node1-open-channel-to-node3.bru index 8301ef0e7..cd33372a0 100644 --- a/tests/bruno/e2e/cross-chain-hub/04-node1-open-channel-to-node3.bru +++ b/tests/bruno/e2e/cross-chain-hub/04-node1-open-channel-to-node3.bru @@ -23,11 +23,11 @@ body:json { "params": [ { "peer_id": "{{NODE3_PEERID}}", - "funding_amount": "0xc350", + "funding_amount": "0x30d40", "funding_udt_type_script": { "code_hash": "{{UDT_CODE_HASH}}", "hash_type": "data1", - "args": "0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947" + "args": "{{UDT_SCRIPT_ARGS}}" } } ] diff --git a/tests/bruno/e2e/cross-chain-hub/07-node1-send-payment.bru b/tests/bruno/e2e/cross-chain-hub/07-node1-send-payment.bru new file mode 100644 index 000000000..88631a27b --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub/07-node1-send-payment.bru @@ -0,0 +1,44 @@ +meta { + name: 07-node1-send-payment + type: http + seq: 7 +} + +post { + url: {{NODE1_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "send_payment", + "params": [ + { + "invoice": "{{FIBER_PAY_INVOICE}}" + } + ] + } +} + +assert { + res.body.error: isUndefined +} + +script:pre-request { + let expiry = "0x" + (Date.now() + 1000 * 60 * 60 * 24).toString(16); + bru.setVar("expiry", expiry); +} + +script:post-response { + // Sleep for sometime to make sure current operation finishes before next request starts. + await new Promise(r => setTimeout(r, 100)); + console.log("response from node1 AddTlc:", res.body); +} diff --git a/tests/bruno/e2e/cross-chain-hub/09-create-fiber-invoice.bru b/tests/bruno/e2e/cross-chain-hub/09-create-fiber-invoice.bru new file mode 100644 index 000000000..4473af461 --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub/09-create-fiber-invoice.bru @@ -0,0 +1,66 @@ +meta { + name: 09-create-fiber-invoice + type: http + seq: 9 +} + +post { + url: {{NODE1_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "new_invoice", + "params": [ + { + "amount": "0xc350", + "currency": "Fibd", + "description": "test invoice", + "udt_type_script": { + "args": "{{UDT_SCRIPT_ARGS}}", + "code_hash": "0xe1e354d6d643ad42724d40967e334984534e0367405c5ae42a9d7d63d77df419", + "hash_type": "data1" + }, + "payment_preimage": "{{payment_preimage}}" + } + ] + } +} + +script:pre-request { + // generate random preimage + function generateRandomPreimage() { + let hash = '0x'; + for (let i = 0; i < 64; i++) { + hash += Math.floor(Math.random() * 16).toString(16); + } + return hash; + } + const payment_preimage = generateRandomPreimage(); + bru.setVar("payment_preimage", payment_preimage); +} + +assert { + res.status: eq 200 + res.body.error: isUndefined + res.body.result.invoice_address: isDefined + res.body.result.invoice.data.payment_hash: isDefined +} + +script:post-response { + bru.setVar("FIBER_PAY_REQ", res.body.result.invoice_address); + bru.setVar("PAYMENT_HASH", res.body.result.invoice.data.payment_hash); +} + +docs { + Create a fiber invoice to receive money +} diff --git a/tests/bruno/e2e/cross-chain-hub/09-create-receive-btc-order.bru b/tests/bruno/e2e/cross-chain-hub/09-create-receive-btc-order.bru deleted file mode 100644 index 1644615fc..000000000 --- a/tests/bruno/e2e/cross-chain-hub/09-create-receive-btc-order.bru +++ /dev/null @@ -1,61 +0,0 @@ -meta { - name: 09-create-receive-btc-order - type: http - seq: 9 -} - -post { - url: {{NODE3_RPC_URL}} - body: json - auth: none -} - -headers { - Content-Type: application/json - Accept: application/json -} - -body:json { - { - "id": "42", - "jsonrpc": "2.0", - "method": "receive_btc", - "params": [ - { - "payment_hash": "{{PAYMENT_HASH}}", - "channel_id": "{{N1N3_CHANNEL_ID}}", - "amount_sats": "0x1", - "final_tlc_expiry": "0x3c" - } - ] - } -} - -assert { - res.status: eq 200 - res.body.error: isUndefined -} - -script:pre-request { - const uuid = require('uuid'); - const CryptoJS = require("crypto-js"); - - const preimage = CryptoJS.SHA256(uuid.v4()); - const hash = CryptoJS.SHA256(preimage); - console.log(preimage.toString(CryptoJS.enc.Hex)); - console.log(hash.toString(CryptoJS.enc.Hex)); - - bru.setVar("PAYMENT_HASH", `0x${hash.toString(CryptoJS.enc.Hex)}`); - bru.setVar("PAYMENT_PREIMAGE", `0x${preimage.toString(CryptoJS.enc.Hex)}`); -} - -script:post-response { - if (res.body.result) { - bru.setVar("BTC_PAY_REQ", res.body.result.btc_pay_req); - console.log(res.body.result.payment_hash); - } -} - -docs { - CKB user requests a BTC invoice to receive BTC from Bitcoin user. -} diff --git a/tests/bruno/e2e/cross-chain-hub/10-create-receive-btc-order.bru b/tests/bruno/e2e/cross-chain-hub/10-create-receive-btc-order.bru new file mode 100644 index 000000000..70b4c459d --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub/10-create-receive-btc-order.bru @@ -0,0 +1,44 @@ +meta { + name: 10-create-receive-btc-order + type: http + seq: 10 +} + +post { + url: {{NODE3_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "receive_btc", + "params": [ + { + "fiber_pay_req": "{{FIBER_PAY_REQ}}" + } + ] + } +} + +assert { + res.status: eq 200 + res.body.error: isUndefined + res.body.result.incoming_invoice.Lightning: isDefined +} + +script:post-response { + bru.setVar("BTC_PAY_REQ", res.body.result.incoming_invoice.Lightning); + console.log(res.body.result.incoming_invoice.Lightning); +} + +docs { + CKB user requests a BTC invoice to receive BTC from Bitcoin user. +} diff --git a/tests/bruno/e2e/cross-chain-hub/11-get-receive-btc-order-tlc-id.bru b/tests/bruno/e2e/cross-chain-hub/11-get-receive-btc-order-tlc-id.bru index 83ec1d65b..4464f1837 100644 --- a/tests/bruno/e2e/cross-chain-hub/11-get-receive-btc-order-tlc-id.bru +++ b/tests/bruno/e2e/cross-chain-hub/11-get-receive-btc-order-tlc-id.bru @@ -1,5 +1,5 @@ meta { - name: 11-get-receive-btc-order-tlc-id + name: 11-check-receive-btc-order type: http seq: 11 } @@ -19,7 +19,7 @@ body:json { { "id": "42", "jsonrpc": "2.0", - "method": "get_receive_btc_order", + "method": "get_cch_order", "params": [ { "payment_hash": "{{PAYMENT_HASH}}" @@ -32,32 +32,3 @@ assert { res.body.error: isUndefined res.status: eq 200 } - -script:pre-request { - if(bru.getVar("iteration") === undefined){ - bru.setVar("iteration", 0); - } -} - -script:post-response { - const i = bru.getVar("iteration"); - const n = bru.getVar("max_iterations"); - if (i < n) { - console.log(`Try ${i+1}/${n}`); - } - - if (res.body.result.tlc_id !== null) { - bru.setVar("N3N1_TLC_ID1", res.body.result.tlc_id); - console.log(`Node 3 has sent a pending tlc: ${res.body.result.tlc_id}`); - bru.setVar("iteration", 0); - // wait for confirmation - await new Promise(r => setTimeout(r, 500)); - } else if (i+1 < n) { - await new Promise(r => setTimeout(r, 10)); - bru.setVar("iteration", i + 1); - bru.setNextRequest("11-get-receive-btc-order-tlc-id"); - } else { - bru.setVar("iteration", 0); - throw new Error("Node 3 has not sent a pending tlc"); - } -} diff --git a/tests/bruno/e2e/cross-chain-hub/12-pay-btc-invoice.bru b/tests/bruno/e2e/cross-chain-hub/12-pay-btc-invoice.bru new file mode 100644 index 000000000..bcd038ca3 --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub/12-pay-btc-invoice.bru @@ -0,0 +1,48 @@ +meta { + name: 12-pay-btc-invoice + type: http + seq: 12 +} + +post { + url: {{LND_BOB_RPC_URL}}/v2/router/send + body: json + auth: none +} + +body:json { + { + "payment_request": "{{BTC_PAY_REQ}}", + "timeout_seconds": 1 + } +} + +assert { + res.status: eq 409 +} + +script:pre-request { + const axios = require('axios'); + + const url = bru.getEnvVar("LND_BOB_RPC_URL") + "/v2/router/send"; + const body = { + payment_request: bru.getVar("BTC_PAY_REQ"), + timeout_seconds: 1 + }; + console.log(url); + console.log(body); + + const resp = await axios({ + method: 'POST', + url: url, + data: body, + responseType: 'stream' + }); + resp.data.destroy(); +} + +docs { + Send payment via lnd RPC https://lightning.engineering/api-docs/api/lnd/router/send-payment-v2. + + This is a server-streaming RPC which will block Bruno. The workaround is sending the request in the pre-script so the Bruno request will return 409 because the payment is already sent. +} diff --git a/tests/bruno/e2e/cross-chain-hub/13-get-invoice-status.bru b/tests/bruno/e2e/cross-chain-hub/13-get-invoice-status.bru new file mode 100644 index 000000000..76325f069 --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub/13-get-invoice-status.bru @@ -0,0 +1,66 @@ +meta { + name: 13-get-invoice-status + type: http + seq: 13 +} + +post { + url: {{NODE1_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "get_invoice", + "params": [ + { + "payment_hash": "{{PAYMENT_HASH}}" + } + ] + } +} + +assert { + res.body.error: isUndefined + res.body.result.status: isDefined +} + + +script:pre-request { + if(bru.getVar("iteration") === undefined){ + bru.setVar("iteration", 0); + } +} + +vars:post-response { + max_iterations: 20 +} + +script:post-response { + const i = bru.getVar("iteration"); + const n = bru.getVar("max_iterations"); + if (i < n) { + console.log(`Try ${i+1}/${n}`); + console.log(res.body) + } + + if (res.body.result.status == "Paid") { + console.log("Invoicee is paid"); + bru.setVar("iteration", 0); + } else if (i+1 < n) { + await new Promise(r => setTimeout(r, 100)); + bru.setVar("iteration", i + 1); + bru.setNextRequest("13-get-invoice-status"); + } else { + bru.setVar("iteration", 0); + throw new Error("Alice has not received the payment"); + } +} diff --git a/tests/bruno/e2e/cross-chain-hub/14-node1-send-shutdown-channel.bru b/tests/bruno/e2e/cross-chain-hub/14-node1-send-shutdown-channel.bru new file mode 100644 index 000000000..78a17950a --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub/14-node1-send-shutdown-channel.bru @@ -0,0 +1,49 @@ +meta { + name: 14-node1-send-shutdown-channel + type: http + seq: 14 +} + +post { + url: {{NODE1_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "shutdown_channel", + "params": [ + { + "channel_id": "{{N1N3_CHANNEL_ID}}", + "close_script": { + "code_hash": "0x2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a", + "hash_type": "data", + "args": "0x0101010101010101010101010101010101010101" + }, + "fee_rate": "0x3FC" + } + ] + } +} + +script:pre-request { + await new Promise(r => setTimeout(r, 5000)); +} + +assert { + res.body.error: isUndefined + res.body.result: isNull +} + +script:post-response { + // Sleep for sometime to make sure current operation finishes before next request starts. + await new Promise(r => setTimeout(r, 1000)); +} diff --git a/tests/bruno/e2e/cross-chain-hub/15-node3-list-channel.bru b/tests/bruno/e2e/cross-chain-hub/15-node3-list-channel.bru new file mode 100644 index 000000000..5e4d40efd --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub/15-node3-list-channel.bru @@ -0,0 +1,35 @@ +meta { + name: 15-node3-list-channel + type: http + seq: 15 +} + +post { + url: {{NODE3_RPC_URL}} + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "id": "42", + "jsonrpc": "2.0", + "method": "list_channels", + "params": [ + { + "peer_id": "{{NODE1_PEERID}}" + } + ] + } +} + +assert { + res.body.error: isUndefined + res.body.result.channels: isDefined + res.body.result.channels.map(channel => channel.channel_id): notContains {{N1N3_CHANNEL_ID}} +} diff --git a/tests/bruno/e2e/cross-chain-hub/README.md b/tests/bruno/e2e/cross-chain-hub/README.md index ccb3e0137..e587b6bec 100644 --- a/tests/bruno/e2e/cross-chain-hub/README.md +++ b/tests/bruno/e2e/cross-chain-hub/README.md @@ -8,7 +8,7 @@ ## Run Locally -1. Install [bitcoind](https://github.com/bitcoin/bitcoin/releases), [lnd](https://github.com/lightningnetwork/lnd), and [jq](https://jqlang.github.io/jq/download/). Ensure that the executables are in your PATH. +1. Install [bitcoind](https://bitcoin.org/en/download), [lnd](https://github.com/lightningnetwork/lnd), and [jq](https://jqlang.github.io/jq/download/). Ensure that the executables are in your PATH. 2. Start Bitcoin and LND nodes using `tests/deploy/lnd-init/setup-lnd.sh`. 3. Start CKB and FNN using `tests/nodes/start.sh`. 4. Go to `tests/bruno` and run the command `npm exec -- @usebruno/cli run e2e/cross-chain-hub -r --env test`. diff --git a/tests/bruno/e2e/udt-router-pay/03-node1-node2-open-channel.bru b/tests/bruno/e2e/udt-router-pay/03-node1-node2-open-channel.bru index 98b49a215..7459eb322 100644 --- a/tests/bruno/e2e/udt-router-pay/03-node1-node2-open-channel.bru +++ b/tests/bruno/e2e/udt-router-pay/03-node1-node2-open-channel.bru @@ -28,7 +28,7 @@ body:json { "funding_udt_type_script": { "code_hash": "{{UDT_CODE_HASH}}", "hash_type": "data1", - "args": "0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947" + "args": "{{UDT_SCRIPT_ARGS}}" } } ] diff --git a/tests/bruno/e2e/udt-router-pay/07-node2-node3-open-channel.bru b/tests/bruno/e2e/udt-router-pay/07-node2-node3-open-channel.bru index 709b5e867..fea7ca7a1 100644 --- a/tests/bruno/e2e/udt-router-pay/07-node2-node3-open-channel.bru +++ b/tests/bruno/e2e/udt-router-pay/07-node2-node3-open-channel.bru @@ -27,7 +27,7 @@ body:json { "funding_udt_type_script": { "code_hash": "{{UDT_CODE_HASH}}", "hash_type": "data1", - "args": "0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947" + "args": "{{UDT_SCRIPT_ARGS}}" } } ] diff --git a/tests/bruno/e2e/udt-router-pay/11-node3-gen-invoice.bru b/tests/bruno/e2e/udt-router-pay/11-node3-gen-invoice.bru index 0b01bbe57..b47b35987 100644 --- a/tests/bruno/e2e/udt-router-pay/11-node3-gen-invoice.bru +++ b/tests/bruno/e2e/udt-router-pay/11-node3-gen-invoice.bru @@ -31,7 +31,7 @@ body:json { "udt_type_script": { "code_hash": "{{UDT_CODE_HASH}}", "hash_type": "data1", - "args": "0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947" + "args": "{{UDT_SCRIPT_ARGS}}" } } ] diff --git a/tests/bruno/e2e/udt-router-pay/12-node1-send-payment.bru b/tests/bruno/e2e/udt-router-pay/12-node1-send-payment.bru index ac59dc366..22eff42c2 100644 --- a/tests/bruno/e2e/udt-router-pay/12-node1-send-payment.bru +++ b/tests/bruno/e2e/udt-router-pay/12-node1-send-payment.bru @@ -28,7 +28,7 @@ body:json { "udt_type_script": { "code_hash": "{{UDT_CODE_HASH}}", "hash_type": "data1", - "args": "0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947" + "args": "{{UDT_SCRIPT_ARGS}}" } } ] diff --git a/tests/bruno/e2e/udt-router-pay/13-node3-gen-invoice-later.bru b/tests/bruno/e2e/udt-router-pay/13-node3-gen-invoice-later.bru index d63ea12a1..fd6d07b90 100644 --- a/tests/bruno/e2e/udt-router-pay/13-node3-gen-invoice-later.bru +++ b/tests/bruno/e2e/udt-router-pay/13-node3-gen-invoice-later.bru @@ -31,7 +31,7 @@ body:json { "udt_type_script": { "code_hash": "{{UDT_CODE_HASH}}", "hash_type": "data1", - "args": "0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947" + "args": "{{UDT_SCRIPT_ARGS}}" } } ] diff --git a/tests/bruno/e2e/udt-router-pay/15-node1-send-payment-keysend.bru b/tests/bruno/e2e/udt-router-pay/15-node1-send-payment-keysend.bru index 22a50a786..8a9484fca 100644 --- a/tests/bruno/e2e/udt-router-pay/15-node1-send-payment-keysend.bru +++ b/tests/bruno/e2e/udt-router-pay/15-node1-send-payment-keysend.bru @@ -28,7 +28,7 @@ body:json { "udt_type_script": { "code_hash": "{{UDT_CODE_HASH}}", "hash_type": "data1", - "args": "0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947" + "args": "{{UDT_SCRIPT_ARGS}}" } } ] diff --git a/tests/bruno/e2e/udt-router-pay/16-node1-send-payment-keysend-large-amount.bru b/tests/bruno/e2e/udt-router-pay/16-node1-send-payment-keysend-large-amount.bru index cc1ea8b5e..9b3c8bafe 100644 --- a/tests/bruno/e2e/udt-router-pay/16-node1-send-payment-keysend-large-amount.bru +++ b/tests/bruno/e2e/udt-router-pay/16-node1-send-payment-keysend-large-amount.bru @@ -28,7 +28,7 @@ body:json { "udt_type_script": { "code_hash": "{{UDT_CODE_HASH}}", "hash_type": "data1", - "args": "0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947" + "args": "{{UDT_SCRIPT_ARGS}}" } } ] diff --git a/tests/bruno/e2e/udt/02-node1-node2-open-channel-amount-err.bru b/tests/bruno/e2e/udt/02-node1-node2-open-channel-amount-err.bru index 1f63aeefe..fd995dfd5 100644 --- a/tests/bruno/e2e/udt/02-node1-node2-open-channel-amount-err.bru +++ b/tests/bruno/e2e/udt/02-node1-node2-open-channel-amount-err.bru @@ -46,3 +46,7 @@ script:post-response { console.log("N1N2 response: ", res.body.result.temporary_channel_id); bru.setVar("N1N2_TEMP_CHANNEL_ID", res.body.result.temporary_channel_id); } + +docs { + Use a type script args that the node has no balance. +} diff --git a/tests/bruno/e2e/udt/04-node1-node2-open-channel.bru b/tests/bruno/e2e/udt/04-node1-node2-open-channel.bru index 163e7f352..7c3e77dfc 100644 --- a/tests/bruno/e2e/udt/04-node1-node2-open-channel.bru +++ b/tests/bruno/e2e/udt/04-node1-node2-open-channel.bru @@ -28,7 +28,7 @@ body:json { "funding_udt_type_script": { "code_hash": "{{UDT_CODE_HASH}}", "hash_type": "data1", - "args": "0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947" + "args": "{{UDT_SCRIPT_ARGS}}" } } ] diff --git a/tests/bruno/e2e/udt/07-node2-gen-invoice.bru b/tests/bruno/e2e/udt/07-node2-gen-invoice.bru index 3fa453365..599a91366 100644 --- a/tests/bruno/e2e/udt/07-node2-gen-invoice.bru +++ b/tests/bruno/e2e/udt/07-node2-gen-invoice.bru @@ -44,7 +44,7 @@ body:json { "udt_type_script": { "code_hash": "{{UDT_CODE_HASH}}", "hash_type": "data1", - "args": "0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947" + "args": "{{UDT_SCRIPT_ARGS}}" } } ] diff --git a/tests/bruno/e2e/udt/10-node1-node2-open-channel-invalid.bru b/tests/bruno/e2e/udt/10-node1-node2-open-channel-invalid.bru index 96065faf5..6d448ae73 100644 --- a/tests/bruno/e2e/udt/10-node1-node2-open-channel-invalid.bru +++ b/tests/bruno/e2e/udt/10-node1-node2-open-channel-invalid.bru @@ -28,7 +28,7 @@ body:json { "funding_udt_type_script": { "code_hash": "0xe1e354d6d643ad42724d40967e334984534e0367405c5ae42a9d7d63d77df410", "hash_type": "data1", - "args": "0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947" + "args": "{{UDT_SCRIPT_ARGS}}" } } ] @@ -47,4 +47,4 @@ script:post-response { if (!(res.body.error.message === "Invalid parameter: Invalid UDT type script")) { throw new Error("Assertion failed: error message is not right"); } -} \ No newline at end of file +} diff --git a/tests/bruno/e2e/udt/11-node1-node2-open-channel-no-auto-accept.bru b/tests/bruno/e2e/udt/11-node1-node2-open-channel-no-auto-accept.bru index b41cea4fa..7b86af608 100644 --- a/tests/bruno/e2e/udt/11-node1-node2-open-channel-no-auto-accept.bru +++ b/tests/bruno/e2e/udt/11-node1-node2-open-channel-no-auto-accept.bru @@ -28,7 +28,7 @@ body:json { "funding_udt_type_script": { "code_hash": "{{UDT_CODE_HASH}}", "hash_type": "data1", - "args": "0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947" + "args": "{{UDT_SCRIPT_ARGS}}" } } ] diff --git a/tests/bruno/e2e/watchtower/force-close-with-pending-tlcs-and-udt/04-get-node1-balance.bru b/tests/bruno/e2e/watchtower/force-close-with-pending-tlcs-and-udt/04-get-node1-balance.bru index ebf75701c..237b553bb 100644 --- a/tests/bruno/e2e/watchtower/force-close-with-pending-tlcs-and-udt/04-get-node1-balance.bru +++ b/tests/bruno/e2e/watchtower/force-close-with-pending-tlcs-and-udt/04-get-node1-balance.bru @@ -27,7 +27,7 @@ body:json { "script": { "code_hash": "{{UDT_CODE_HASH}}", "hash_type": "data1", - "args": "0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947" + "args": "{{UDT_SCRIPT_ARGS}}" } } }, diff --git a/tests/bruno/e2e/watchtower/force-close-with-pending-tlcs-and-udt/05-get-node2-balance.bru b/tests/bruno/e2e/watchtower/force-close-with-pending-tlcs-and-udt/05-get-node2-balance.bru index 8820e0d26..b103d892c 100644 --- a/tests/bruno/e2e/watchtower/force-close-with-pending-tlcs-and-udt/05-get-node2-balance.bru +++ b/tests/bruno/e2e/watchtower/force-close-with-pending-tlcs-and-udt/05-get-node2-balance.bru @@ -27,7 +27,7 @@ body:json { "script": { "code_hash": "{{UDT_CODE_HASH}}", "hash_type": "data1", - "args": "0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947" + "args": "{{UDT_SCRIPT_ARGS}}" } } }, diff --git a/tests/bruno/e2e/watchtower/force-close-with-pending-tlcs-and-udt/06-open-channel.bru b/tests/bruno/e2e/watchtower/force-close-with-pending-tlcs-and-udt/06-open-channel.bru index 57830438d..003eb827a 100644 --- a/tests/bruno/e2e/watchtower/force-close-with-pending-tlcs-and-udt/06-open-channel.bru +++ b/tests/bruno/e2e/watchtower/force-close-with-pending-tlcs-and-udt/06-open-channel.bru @@ -29,7 +29,7 @@ body:json { "funding_udt_type_script": { "code_hash": "{{UDT_CODE_HASH}}", "hash_type": "data1", - "args": "0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947" + "args": "{{UDT_SCRIPT_ARGS}}" } } ] diff --git a/tests/bruno/e2e/watchtower/force-close-with-pending-tlcs-and-udt/21-check-balance-node1.bru b/tests/bruno/e2e/watchtower/force-close-with-pending-tlcs-and-udt/21-check-balance-node1.bru index 1cf5cfaed..d98eeec05 100644 --- a/tests/bruno/e2e/watchtower/force-close-with-pending-tlcs-and-udt/21-check-balance-node1.bru +++ b/tests/bruno/e2e/watchtower/force-close-with-pending-tlcs-and-udt/21-check-balance-node1.bru @@ -27,7 +27,7 @@ body:json { "script": { "code_hash": "{{UDT_CODE_HASH}}", "hash_type": "data1", - "args": "0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947" + "args": "{{UDT_SCRIPT_ARGS}}" } } }, diff --git a/tests/bruno/e2e/watchtower/force-close-with-pending-tlcs-and-udt/22-check-balance-node2.bru b/tests/bruno/e2e/watchtower/force-close-with-pending-tlcs-and-udt/22-check-balance-node2.bru index ecfb967ae..3cc10944f 100644 --- a/tests/bruno/e2e/watchtower/force-close-with-pending-tlcs-and-udt/22-check-balance-node2.bru +++ b/tests/bruno/e2e/watchtower/force-close-with-pending-tlcs-and-udt/22-check-balance-node2.bru @@ -27,7 +27,7 @@ body:json { "script": { "code_hash": "{{UDT_CODE_HASH}}", "hash_type": "data1", - "args": "0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947" + "args": "{{UDT_SCRIPT_ARGS}}" } } }, diff --git a/tests/bruno/environments/test.bru b/tests/bruno/environments/test.bru index 8d2121e0e..38c5775da 100644 --- a/tests/bruno/environments/test.bru +++ b/tests/bruno/environments/test.bru @@ -3,6 +3,7 @@ vars { NODE1_RPC_URL: http://127.0.0.1:21714 NODE2_RPC_URL: http://127.0.0.1:21715 NODE3_RPC_URL: http://127.0.0.1:21716 + NODE_CCH_RPC_URL: http://127.0.0.1:21717 NODE1_ADDR: /ip4/127.0.0.1/tcp/8344/p2p/QmbvRjJHAQDmj3cgnUBGQ5zVnGxUKwb2qJygwNs2wk41h8 NODE2_ADDR: /ip4/127.0.0.1/tcp/8345/p2p/QmSRcPqUn4aQrKHXyCDjGn2qBVf43tWBDS2Wj9QDUZXtZp NODE3_ADDR: /ip4/127.0.0.1/tcp/8346/p2p/QmaFDJb9CkMrXy7nhTWBY5y9mvuykre3EzzRsCJUAVXprZ @@ -10,6 +11,7 @@ vars { NODE2_PEERID: QmSRcPqUn4aQrKHXyCDjGn2qBVf43tWBDS2Wj9QDUZXtZp NODE3_PEERID: QmaFDJb9CkMrXy7nhTWBY5y9mvuykre3EzzRsCJUAVXprZ UDT_CODE_HASH: 0xe1e354d6d643ad42724d40967e334984534e0367405c5ae42a9d7d63d77df419 + UDT_SCRIPT_ARGS: 0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947 LND_BOB_RPC_URL: http://127.0.0.1:8180 LND_INGRID_RPC_URL: http://127.0.0.1:8080 } diff --git a/tests/bruno/environments/xudt-test.bru b/tests/bruno/environments/xudt-test.bru index f5cc31133..2ead57350 100644 --- a/tests/bruno/environments/xudt-test.bru +++ b/tests/bruno/environments/xudt-test.bru @@ -3,6 +3,7 @@ vars { NODE1_RPC_URL: http://127.0.0.1:21714 NODE2_RPC_URL: http://127.0.0.1:21715 NODE3_RPC_URL: http://127.0.0.1:21716 + NODE_CCH_RPC_URL: http://127.0.0.1:21717 NODE1_ADDR: /ip4/127.0.0.1/tcp/8344/p2p/QmbvRjJHAQDmj3cgnUBGQ5zVnGxUKwb2qJygwNs2wk41h8 NODE2_ADDR: /ip4/127.0.0.1/tcp/8345/p2p/QmSRcPqUn4aQrKHXyCDjGn2qBVf43tWBDS2Wj9QDUZXtZp NODE3_ADDR: /ip4/127.0.0.1/tcp/8346/p2p/QmaFDJb9CkMrXy7nhTWBY5y9mvuykre3EzzRsCJUAVXprZ @@ -10,6 +11,7 @@ vars { NODE2_PEERID: QmSRcPqUn4aQrKHXyCDjGn2qBVf43tWBDS2Wj9QDUZXtZp NODE3_PEERID: QmaFDJb9CkMrXy7nhTWBY5y9mvuykre3EzzRsCJUAVXprZ UDT_CODE_HASH: 0x50bd8d6680b8b9cf98b73f3c08faf8b2a21914311954118ad6609be6e78a1b95 + UDT_SCRIPT_ARGS: 0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947 LND_BOB_RPC_URL: http://127.0.0.1:8180 LND_INGRID_RPC_URL: http://127.0.0.1:8080 } diff --git a/tests/deploy/udt-init/README.md b/tests/deploy/udt-init/README.md index e932da2ef..cf3fd6669 100644 --- a/tests/deploy/udt-init/README.md +++ b/tests/deploy/udt-init/README.md @@ -1 +1,3 @@ # udt-dev-init + +Run `./init.sh` to generate valid config files for udt dev environment. \ No newline at end of file diff --git a/tests/deploy/udt-init/init.sh b/tests/deploy/udt-init/init.sh new file mode 100755 index 000000000..5a2e6e869 --- /dev/null +++ b/tests/deploy/udt-init/init.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +cargo_toml="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/Cargo.toml" +cd -- "$(dirname -- "$cargo_toml")/../../nodes" || exit 1 +NODES_DIR="$PWD" cargo run --manifest-path="$cargo_toml" diff --git a/tests/deploy/udt-init/src/main.rs b/tests/deploy/udt-init/src/main.rs index 09b63ff1a..5b5a033eb 100644 --- a/tests/deploy/udt-init/src/main.rs +++ b/tests/deploy/udt-init/src/main.rs @@ -25,9 +25,8 @@ use ckb_types::{packed::CellDep, prelude::Builder}; use rand::Rng; use serde::{Deserialize, Serialize}; use std::{collections::HashSet, path::Path}; -use std::{fs, net::TcpListener}; - use std::{error::Error as StdErr, str::FromStr}; +use std::{fs, net::TcpListener}; const UDT_KINDS: [&str; 2] = ["SIMPLE_UDT", "XUDT"]; @@ -234,11 +233,13 @@ fn generate_ports(num_ports: usize) -> Vec { } fn generate_nodes_config() { + use serde_yaml::Value; + let node_dir_env = std::env::var("NODES_DIR").expect("env var"); let nodes_dir = Path::new(&node_dir_env); let yaml_file_path = nodes_dir.join("deployer/config.yml"); let content = std::fs::read_to_string(yaml_file_path).expect("read failed"); - let data: serde_yaml::Value = serde_yaml::from_str(&content).expect("Unable to parse YAML"); + let data: Value = serde_yaml::from_str(&content).expect("Unable to parse YAML"); let mut udt_infos = vec![]; for udt in UDT_KINDS { let (code_hash, genesis_tx, index) = get_udt_info(udt); @@ -246,7 +247,7 @@ fn generate_nodes_config() { name: udt.to_string(), auto_accept_amount: Some(1000), script: UdtScript { - code_hash: code_hash, + code_hash, hash_type: "Data1".to_string(), args: "0x.*".to_string(), }, @@ -268,12 +269,13 @@ fn generate_nodes_config() { "# this is generated from nodes/deployer/config.yml, any changes will not be checked in", "# you can edit nodes/deployer/config.yml and run `REMOVE_OLD_STATE=y ./tests/nodes/start.sh TESTCASE` to regenerate" ); - let config_dirs = vec!["bootnode", "1", "2", "3"]; + let config_dirs = vec!["bootnode", "1", "2", "3", "cch"]; let mut ports_map = vec![]; let on_github_action = std::env::var("ON_GITHUB_ACTION").is_ok(); - let gen_ports = generate_ports(6); + let gen_ports = generate_ports(8); let mut ports_iter = gen_ports.iter(); let dev_config = nodes_dir.join("deployer/dev.toml"); + let mut node_3_rpc_port: u16 = 0; for (i, config_dir) in config_dirs.iter().enumerate() { let use_gen_port = on_github_action && i != 0; let default_fiber_port = (8343 + i) as u16; @@ -283,27 +285,66 @@ fn generate_nodes_config() { } else { (default_fiber_port, default_rpc_port) }; - ports_map.push((default_fiber_port, fiber_port)); - ports_map.push((default_rpc_port, rpc_port)); + if i == 3 { + node_3_rpc_port = rpc_port; + } + if i != 4 { + // Separate CCH node does not start p2p + ports_map.push(( + format!("{}.{}", config_dir, "p2p"), + default_fiber_port, + fiber_port, + )); + } + ports_map.push(( + format!("{}.{}", config_dir, "rpc"), + default_rpc_port, + rpc_port, + )); let mut data = data.clone(); - data["fiber"]["listening_addr"] = - serde_yaml::Value::String(format!("/ip4/0.0.0.0/tcp/{}", fiber_port)); - data["fiber"]["announced_addrs"] = - serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(format!( - "/ip4/127.0.0.1/tcp/{}", - fiber_port - ))]); - data["fiber"]["announced_node_name"] = serde_yaml::Value::String(format!("fiber-{}", i)); - data["rpc"]["listening_addr"] = - serde_yaml::Value::String(format!("127.0.0.1:{}", rpc_port)); + data["fiber"]["listening_addr"] = Value::String(format!("/ip4/0.0.0.0/tcp/{}", fiber_port)); + data["fiber"]["announced_addrs"] = Value::Sequence(vec![serde_yaml::Value::String( + format!("/ip4/127.0.0.1/tcp/{}", fiber_port), + )]); + data["fiber"]["announced_node_name"] = Value::String(format!("fiber-{}", i)); + data["rpc"]["listening_addr"] = Value::String(format!("127.0.0.1:{}", rpc_port)); data["ckb"]["udt_whitelist"] = serde_yaml::to_value(&udt_infos).unwrap(); + let script: serde_yaml::Mapping = serde_yaml::from_str( + serde_yaml::to_string(&ckb_jsonrpc_types::Script::from(get_sudt_script())) + .expect("save script") + .as_str(), + ) + .expect("parse script"); + data["cch"]["wrapped_btc_type_script"] = Value::Mapping(script); // Node 3 acts as a CCH node. if i == 3 { data["services"] .as_sequence_mut() .unwrap() - .push(serde_yaml::Value::String("cch".to_string())); + .push(Value::String("cch".to_string())); + } + // Node cch is a separate CCH node + if i == 4 { + let top_mapping = data.as_mapping_mut().unwrap(); + top_mapping.remove("ckb"); + top_mapping.remove("fiber"); + data["cch"] + .as_mapping_mut() + .unwrap() + .remove("ignore_startup_failure"); + let modules = data["rpc"]["enabled_modules"].as_sequence_mut().unwrap(); + modules.clear(); + modules.push(Value::String("cch".to_string())); + let services = data["services"].as_sequence_mut().unwrap(); + services.clear(); + services.push(Value::String("rpc".to_string())); + services.push(Value::String("cch".to_string())); + let cch = data["cch"].as_mapping_mut().unwrap(); + cch.insert( + Value::String("fiber_rpc_url".into()), + Value::String(format!("http://127.0.0.1:{}", node_3_rpc_port)), + ); } let new_yaml = header.to_string() + &serde_yaml::to_string(&data).unwrap(); @@ -317,7 +358,7 @@ fn generate_nodes_config() { let bruno_dir = nodes_dir.join("../bruno/environments/"); for config in std::fs::read_dir(bruno_dir).expect("read dir") { let config = config.expect("read config"); - for (default_port, port) in ports_map.iter() { + for (_, default_port, port) in ports_map.iter() { let content = std::fs::read_to_string(config.path()).expect("read config"); let new_content = content.replace(&default_port.to_string(), &port.to_string()); std::fs::write(config.path(), new_content).expect("write config"); @@ -328,16 +369,20 @@ fn generate_nodes_config() { // write the real ports into a file so that later script can use it to double check the ports let content = ports_map .iter() - .skip(2) // bootnode node was not always started - .map(|(_, port)| port.to_string()) + .map(|(config_dir, _, port)| format!("{}: {}", config_dir, port)) .collect::>() .join("\n") + "\n"; - let port_file_path = nodes_dir.join(".ports"); + let port_file_path = nodes_dir.join(".ports_map"); std::fs::write(port_file_path, content).expect("write ports list"); } +fn get_sudt_script() -> Script { + let udt_owner = get_nodes_info("deployer"); + generate_udt_type_script("SIMPLE_UDT", &udt_owner.0) +} + fn init_udt_accounts() -> Result<(), Box> { let udt_owner = get_nodes_info("deployer"); for udt in UDT_KINDS { diff --git a/tests/nodes/cch/cch/.gitkeep b/tests/nodes/cch/cch/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/tests/nodes/cch/fiber/sk b/tests/nodes/cch/fiber/sk new file mode 100644 index 000000000..81ce9f919 --- /dev/null +++ b/tests/nodes/cch/fiber/sk @@ -0,0 +1 @@ +uoeTڌ5V8 \ No newline at end of file diff --git a/tests/nodes/deployer/config.yml b/tests/nodes/deployer/config.yml index 305f6805e..826b41f04 100644 --- a/tests/nodes/deployer/config.yml +++ b/tests/nodes/deployer/config.yml @@ -20,11 +20,13 @@ rpc: - invoice - peer - watchtower + - pubsub - dev cch: ignore_startup_failure: true - wrapped_btc_type_script_args: "0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947" + # will be generated by udt-init + wrapped_btc_type_script: 0x55000000100000003000000031000000e1e354d6d643ad42724d40967e334984534e0367405c5ae42a9d7d63d77df419022000000032e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947 lnd_cert_path: ../../../deploy/lnd-init/lnd-ingrid/tls.cert ckb: diff --git a/tests/nodes/start.sh b/tests/nodes/start.sh index 59dcf9db5..ec50aaa46 100755 --- a/tests/nodes/start.sh +++ b/tests/nodes/start.sh @@ -18,7 +18,7 @@ if ! [ -d "$testcase_dir" ]; then fi case "$testcase_name" in - "e2e/cross-chain-hub") + "e2e/cross-chain-hub"*) ./tests/deploy/lnd-init/setup-lnd.sh ;; "e2e/router-pay") @@ -67,9 +67,13 @@ cd "$nodes_dir" || exit 1 start() { log_file="${2}.log" echo "logging to ${log_file}" - ../../target/debug/fnn "$@" 2>&1 | tee "$log_file" + # Append used ports to the file for ports detection + grep "^${2}\\." "$nodes_dir/.ports_map" | sed 's/.*: //' >> "$nodes_dir/.ports_tmp" + ../../target/debug/fnn "$@" 2>&1 | tee "$log_file" & } +rm -f "$nodes_dir/.ports" +rm -f "$nodes_dir/.ports_tmp" if [ "${#start_node_ids[@]}" = 0 ]; then if [[ -n "$should_start_bootnode" ]]; then FIBER_SECRET_KEY_PASSWORD='password0' LOG_PREFIX=$'[boot node]' start -d bootnode & @@ -79,14 +83,18 @@ if [ "${#start_node_ids[@]}" = 0 ]; then # export the environment variable so that other nodes can connect to the bootnode. export FIBER_BOOTNODE_ADDRS=/ip4/127.0.0.1/tcp/8343/p2p/Qmbyc4rhwEwxxSQXd5B4Ej4XkKZL6XLipa3iJrnPL9cjGR fi - FIBER_SECRET_KEY_PASSWORD='password1' LOG_PREFIX=$'[node 1]' start -d 1 & - FIBER_SECRET_KEY_PASSWORD='password2' LOG_PREFIX=$'[node 2]' start -d 2 & - FIBER_SECRET_KEY_PASSWORD='password3' LOG_PREFIX=$'[node 3]' start -d 3 & + FIBER_SECRET_KEY_PASSWORD='password1' LOG_PREFIX=$'[node 1]' start -d 1 + FIBER_SECRET_KEY_PASSWORD='password2' LOG_PREFIX=$'[node 2]' start -d 2 + FIBER_SECRET_KEY_PASSWORD='password3' LOG_PREFIX=$'[node 3]' start -d 3 + if [ "$testcase_name" = "e2e/cross-chain-hub-separate" ]; then + FIBER_SECRET_KEY_PASSWORD='password4' LOG_PREFIX=$'[node cch]' start -d cch + fi else for id in "${start_node_ids[@]}"; do - FIBER_SECRET_KEY_PASSWORD="password$id" LOG_PREFIX="[$id]"$'' start -d "$id" & + FIBER_SECRET_KEY_PASSWORD="password$id" LOG_PREFIX="[$id]"$'' start -d "$id" done fi +mv -f "$nodes_dir/.ports_tmp" "$nodes_dir/.ports" # we will exit when any of the background processes exits. # we don't use `wait -n` because of compatibility issues between bash and zsh