diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 026ec6a8..850f3857 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -13,6 +13,7 @@ jobs: - open-use-close-a-channel - udt - reestablish + - cross-chain-hub release: - "0.116.1" test_env: @@ -41,9 +42,21 @@ 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 + 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 + fi - name: Run e2e workflow run: | + if [ ${{ matrix.workflow }} = "cross-chain-hub" ]; then + ./tests/deploy/lnd-init/setup-lnd.sh + fi + # Prebuild the program so that we can run the following script faster cargo build cd tests/deploy/udt-init && cargo build && cd - diff --git a/Cargo.lock b/Cargo.lock index 364902e8..e956e144 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -144,6 +144,28 @@ dependencies = [ "fenwick", ] +[[package]] +name = "async-stream" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + [[package]] name = "async-trait" version = "0.1.77" @@ -161,6 +183,63 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "axum" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acee9fd5073ab6b045a275b3e709c163dd36c90685219cb21804a147b58dba43" +dependencies = [ + "async-trait", + "axum-core 0.2.9", + "bitflags 1.3.2", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.28", + "itoa", + "matchit 0.5.0", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde", + "sync_wrapper 0.1.2", + "tokio", + "tower", + "tower-http", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +dependencies = [ + "async-trait", + "axum-core 0.3.4", + "bitflags 1.3.2", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.28", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper 0.1.2", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "axum" version = "0.7.5" @@ -168,7 +247,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf" dependencies = [ "async-trait", - "axum-core", + "axum-core 0.4.3", "bytes", "futures-util", "http 1.1.0", @@ -177,7 +256,7 @@ dependencies = [ "hyper 1.2.0", "hyper-util", "itoa", - "matchit", + "matchit 0.7.3", "memchr", "mime", "percent-encoding", @@ -195,6 +274,39 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-core" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37e5939e02c56fecd5c017c37df4238c0a839fa76b7f97acdd7efb804fd181cc" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "mime", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + [[package]] name = "axum-core" version = "0.4.3" @@ -487,7 +599,7 @@ version = "0.1.0" dependencies = [ "anyhow", "arcode", - "axum", + "axum 0.7.5", "base64 0.13.1", "bech32 0.8.1", "bitcoin", @@ -514,6 +626,7 @@ dependencies = [ "lightning-net-tokio", "lightning-persister", "lightning-rapid-gossip-sync", + "lnd-grpc-tonic-client", "molecule 0.7.5", "musig2", "nom", @@ -1474,6 +1587,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.0.28" @@ -1837,6 +1956,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f" + [[package]] name = "httparse" version = "1.8.0" @@ -1892,6 +2017,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "hyper-openssl" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ee5d7a8f718585d1c3c61dfde28ef5b0bb14734b4db13f5ada856cdc6c612b" +dependencies = [ + "http 0.2.12", + "hyper 0.14.28", + "linked_hash_set", + "once_cell", + "openssl", + "openssl-sys", + "parking_lot", + "tokio", + "tokio-openssl", + "tower-layer", +] + +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper 0.14.28", + "pin-project-lite", + "tokio", + "tokio-io-timeout", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -2018,6 +2173,24 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.10" @@ -2260,12 +2433,45 @@ dependencies = [ "lightning", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linked_hash_set" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47186c6da4d81ca383c7c47c1bfc80f4b95f4720514d860a5407aaf4233f9588" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "linux-raw-sys" version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" +[[package]] +name = "lnd-grpc-tonic-client" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d09ab65602a2f5b9582e81aa850e7a341ca4e5d26aa144d12c5384ba5fe9d112" +dependencies = [ + "hex", + "hyper 0.14.28", + "hyper-openssl", + "openssl", + "prost 0.12.6", + "thiserror", + "tonic 0.11.0", + "tonic-build", + "tonic-openssl", + "tower-service", +] + [[package]] name = "lock_api" version = "0.4.11" @@ -2300,6 +2506,12 @@ dependencies = [ "regex-automata 0.1.10", ] +[[package]] +name = "matchit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cbba799671b762df5a175adf59ce145165747bb891505c43d09aefbbf38beb" + [[package]] name = "matchit" version = "0.7.3" @@ -2407,6 +2619,12 @@ dependencies = [ "faster-hex", ] +[[package]] +name = "multimap" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" + [[package]] name = "musig2" version = "0.0.11" @@ -2661,6 +2879,16 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.2.5", +] + [[package]] name = "phf" version = "0.8.0" @@ -2818,6 +3046,82 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71adf41db68aa0daaefc69bb30bcd68ded9b9abaad5d1fbb6304c4fb390e083e" +dependencies = [ + "bytes", + "prost-derive 0.10.1", +] + +[[package]] +name = "prost" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +dependencies = [ + "bytes", + "prost-derive 0.12.6", +] + +[[package]] +name = "prost-build" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" +dependencies = [ + "bytes", + "heck", + "itertools 0.12.1", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost 0.12.6", + "prost-types", + "regex", + "syn 2.0.52", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b670f45da57fb8542ebdbb6105a925fe571b67f9e7ed9f47a06a84e72b4e7cc" +dependencies = [ + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "prost-derive" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +dependencies = [ + "anyhow", + "itertools 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "prost-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" +dependencies = [ + "prost 0.12.6", +] + [[package]] name = "quote" version = "1.0.35" @@ -3800,6 +4104,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "tokio-io-timeout" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b74022ada614a1b4834de765f9bb43877f910cc8ce4be40e89042c9223a8bf" +dependencies = [ + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-macros" version = "2.2.0" @@ -3821,6 +4135,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-openssl" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ffab79df67727f6acf57f1ff743091873c24c579b1e2ce4d8f53e47ded4d63d" +dependencies = [ + "futures-util", + "openssl", + "openssl-sys", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.15" @@ -3890,6 +4216,92 @@ dependencies = [ "winnow", ] +[[package]] +name = "tonic" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be9d60db39854b30b835107500cf0aca0b0d14d6e1c3de124217c23a29c2ddb" +dependencies = [ + "async-stream", + "async-trait", + "axum 0.5.17", + "base64 0.13.1", + "bytes", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.28", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost 0.10.4", + "prost-derive 0.10.1", + "tokio", + "tokio-stream", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "tracing-futures", +] + +[[package]] +name = "tonic" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76c4eb7a4e9ef9d4763600161f12f5070b92a578e1b634db88a6887844c91a13" +dependencies = [ + "async-stream", + "async-trait", + "axum 0.6.20", + "base64 0.21.7", + "bytes", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.28", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost 0.12.6", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4ef6dd70a610078cb4e338a0f79d06bc759ff1b22d2120c2ff02ae264ba9c2" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "tonic-openssl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc64bfb2812f4311055de425e65745229551438db6add3815b489770da3b906d" +dependencies = [ + "async-stream", + "futures", + "openssl", + "tokio", + "tokio-openssl", + "tonic 0.7.2", +] + [[package]] name = "tower" version = "0.4.13" @@ -3898,14 +4310,37 @@ checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" dependencies = [ "futures-core", "futures-util", + "indexmap 1.9.3", "pin-project", "pin-project-lite", + "rand 0.8.5", + "slab", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", ] +[[package]] +name = "tower-http" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f873044bf02dd1e8239e9c1293ea39dad76dc594ec16185d0a1bf31d8dc8d858" +dependencies = [ + "bitflags 1.3.2", + "bytes", + "futures-core", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "http-range-header", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.2" @@ -3951,6 +4386,16 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "pin-project", + "tracing", +] + [[package]] name = "tracing-log" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 601ae133..4b16e386 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ regex = "1.10.5" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } socket2 = "0.5.7" +lnd-grpc-tonic-client = "0.3.0" [profile.release] panic = "abort" diff --git a/src/cch/actor.rs b/src/cch/actor.rs new file mode 100644 index 00000000..72a23669 --- /dev/null +++ b/src/cch/actor.rs @@ -0,0 +1,823 @@ +use anyhow::{anyhow, Context, Result}; +use futures::StreamExt as _; +use hex::ToHex; +use lightning_invoice::Bolt11Invoice; +use lnd_grpc_tonic_client::{ + create_invoices_client, create_router_client, invoicesrpc, lnrpc, routerrpc, InvoicesClient, + RouterClient, Uri, +}; +use ractor::{call, RpcReplyPort}; +use ractor::{Actor, ActorCell, ActorProcessingErr, ActorRef}; +use serde::Deserialize; +use std::str::FromStr; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::{select, time::sleep}; +use tokio_util::{sync::CancellationToken, task::TaskTracker}; + +use crate::ckb::channel::{ + AddTlcCommand, ChannelCommand, ChannelCommandWithId, RemoveTlcCommand, TlcNotification, +}; +use crate::ckb::hash_algorithm::HashAlgorithm; +use crate::ckb::types::{Hash256, LockTime, RemoveTlcFulfill, RemoveTlcReason}; +use crate::ckb::{NetworkActorCommand, NetworkActorMessage}; +use crate::ckb_chain::contracts::{get_script_by_contract, Contract}; +use crate::invoice::Currency; + +use super::error::CchDbError; +use super::{CchConfig, CchError, CchOrderStatus, CchOrdersDb, ReceiveBTCOrder, SendBTCOrder}; + +pub const BTC_PAYMENT_TIMEOUT_SECONDS: i32 = 60; +pub const DEFAULT_ORDER_EXPIRY_SECONDS: u64 = 86400; // 24 hours + +pub async fn start_cch( + config: CchConfig, + tracker: TaskTracker, + token: CancellationToken, + root_actor: ActorCell, + network_actor: Option>, +) -> Result> { + let (actor, _handle) = Actor::spawn_linked( + Some("cch actor".to_string()), + CchActor::new(config, tracker, token, network_actor), + (), + root_actor, + ) + .await?; + Ok(actor) +} + +#[derive(Debug)] +pub struct SettleSendBTCOrderEvent { + payment_hash: String, + preimage: Option, + status: CchOrderStatus, +} + +#[derive(Debug)] +pub struct SettleReceiveBTCOrderEvent { + payment_hash: String, + preimage: Option, + status: CchOrderStatus, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SendBTC { + pub btc_pay_req: String, + pub currency: Currency, +} + +#[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 enum CchMessage { + SendBTC(SendBTC, RpcReplyPort>), + ReceiveBTC(ReceiveBTC, RpcReplyPort>), + + GetReceiveBTCOrder(String, RpcReplyPort>), + + SettleSendBTCOrder(SettleSendBTCOrderEvent), + SettleReceiveBTCOrder(SettleReceiveBTCOrderEvent), + + PendingReceivedTlcNotification(TlcNotification), + SettledTlcNotification(TlcNotification), +} + +#[derive(Clone)] +struct LndConnectionInfo { + uri: Uri, + cert: Option>, + macaroon: Option>, +} + +impl LndConnectionInfo { + async fn create_router_client( + &self, + ) -> Result { + create_router_client( + self.uri.clone(), + self.cert.as_deref(), + self.macaroon.as_deref(), + ) + .await + } + + async fn create_invoices_client( + &self, + ) -> Result { + create_invoices_client( + self.uri.clone(), + self.cert.as_deref(), + self.macaroon.as_deref(), + ) + .await + } +} + +pub struct CchActor { + config: CchConfig, + tracker: TaskTracker, + token: CancellationToken, + network_actor: Option>, +} + +pub struct CchState { + lnd_connection: LndConnectionInfo, + orders_db: CchOrdersDb, +} + +#[ractor::async_trait] +impl Actor for CchActor { + type Msg = CchMessage; + type State = CchState; + type Arguments = (); + + async fn pre_start( + &self, + myself: ActorRef, + _config: 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 = LndConnectionInfo { + uri: lnd_rpc_url, + cert, + macaroon, + }; + + let payments_tracker = + LndPaymentsTracker::new(myself.clone(), lnd_connection.clone(), self.token.clone()); + self.tracker + .spawn(async move { payments_tracker.run().await }); + + Ok(CchState { + lnd_connection, + orders_db: Default::default(), + }) + } + + async fn handle( + &self, + myself: ActorRef, + message: Self::Msg, + state: &mut Self::State, + ) -> Result<(), ActorProcessingErr> { + match message { + CchMessage::SendBTC(send_btc, port) => { + let result = self.send_btc(state, send_btc).await; + if !port.is_closed() { + // ignore error + let _ = port.send(result); + } + Ok(()) + } + CchMessage::ReceiveBTC(receive_btc, port) => { + let result = self.receive_btc(myself, state, 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); + if !port.is_closed() { + // ignore error + let _ = port.send(result); + } + Ok(()) + } + CchMessage::SettleSendBTCOrder(event) => { + tracing::debug!("settle_send_btc_order {:?}", event); + if let Err(err) = self.settle_send_btc_order(state, 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 { + 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); + } + 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); + } + Ok(()) + } + } + } +} + +impl CchActor { + pub fn new( + config: CchConfig, + tracker: TaskTracker, + token: CancellationToken, + network_actor: Option>, + ) -> Self { + Self { + config, + tracker, + token, + network_actor, + } + } + + async fn send_btc( + &self, + state: &mut CchState, + send_btc: SendBTC, + ) -> 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 expiry = invoice + .expires_at() + .and_then(|expired_at| expired_at.checked_sub(duration_since_epoch)) + .map(|duration| duration.as_secs()) + .ok_or(CchError::BTCInvoiceExpired)?; + + let amount_msat = invoice + .amount_milli_satoshis() + .ok_or(CchError::BTCInvoiceMissingAmount)? as u128; + + let fee_sats = amount_msat * (self.config.fee_rate_per_million_sats as u128) + / 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)? + .as_ref(), + ) + .into(); + let mut order = SendBTCOrder { + expires_after: expiry, + wrapped_btc_type_script, + fee_sats, + currency: send_btc.currency, + created_at: duration_since_epoch.as_secs(), + ckb_final_tlc_expiry: self.config.ckb_final_tlc_expiry_blocks, + btc_pay_req: send_btc.btc_pay_req, + ckb_pay_req: Default::default(), + payment_hash: format!("0x{}", invoice.payment_hash().encode_hex::()), + payment_preimage: None, + channel_id: None, + tlc_id: None, + amount_sats: amount_msat.div_ceil(1_000u128) + fee_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()) + + Ok(order) + } + + // On receiving new TLC, check whether it matches the SendBTC order + async fn handle_pending_received_tlc_notification( + &self, + state: &mut CchState, + tlc_notification: TlcNotification, + ) -> Result<()> { + let payment_hash = format!("{:#x}", tlc_notification.tlc.payment_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(()), + Err(err) => return Err(err.into()), + Ok(order) => order, + }; + + 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.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(()); + } + } + + Ok(()) + } + + async fn handle_settled_tlc_notification( + &self, + state: &mut CchState, + tlc_notification: TlcNotification, + ) -> Result<()> { + let payment_hash = format!("{:#x}", tlc_notification.tlc.payment_hash); + tracing::debug!("[settled tlc] payment hash: {}", payment_hash); + + match state.orders_db.get_receive_btc_order(&payment_hash).await { + Err(CchDbError::NotFound(_)) => return Ok(()), + Err(err) => return Err(err.into()), + _ => { + // ignore + } + }; + + 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); + + Ok(()) + } + + async fn settle_send_btc_order( + &self, + state: &mut CchState, + event: SettleSendBTCOrderEvent, + ) -> Result<()> { + let mut order = match state + .orders_db + .get_send_btc_order(&event.payment_hash) + .await + { + Err(CchDbError::NotFound(_)) => return Ok(()), + Err(err) => return Err(err.into()), + 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::ControlCfnChannel( + 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))?; + } + + state.orders_db.update_send_btc_order(order).await?; + + Ok(()) + } + + async fn receive_btc( + &self, + myself: ActorRef, + state: &mut CchState, + receive_btc: ReceiveBTC, + ) -> Result { + 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)?; + + let amount_sats = receive_btc.amount_sats as u128; + let fee_sats = amount_sats * (self.config.fee_rate_per_million_sats as u128) + / 1_000_000u128 + + (self.config.base_fee_sats as u128); + if amount_sats <= fee_sats { + return Err(CchError::ReceiveBTCOrderAmountTooSmall); + } + if amount_sats > (i64::MAX / 1_000i64) as u128 { + return Err(CchError::ReceiveBTCOrderAmountTooLarge); + } + + let mut client = state.lnd_connection.create_invoices_client().await?; + let req = invoicesrpc::AddHoldInvoiceRequest { + hash: hash_bin, + 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, + ..Default::default() + }; + let invoice = 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)? + .as_ref(), + ) + .into(); + let order = ReceiveBTCOrder { + created_at: duration_since_epoch.as_secs(), + expires_after: DEFAULT_ORDER_EXPIRY_SECONDS, + ckb_final_tlc_expiry: receive_btc.final_tlc_expiry, + btc_pay_req, + payment_hash: receive_btc.payment_hash.clone(), + 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 }); + + Ok(order) + } + + async fn settle_receive_btc_order( + &self, + state: &mut CchState, + event: SettleReceiveBTCOrderEvent, + ) -> Result<()> { + let mut order = match state + .orders_db + .get_receive_btc_order(&event.payment_hash) + .await + { + Err(CchDbError::NotFound(_)) => return Ok(()), + Err(err) => return Err(err.into()), + 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::ControlCfnChannel( + ChannelCommandWithId { + channel_id: order.channel_id, + command: ChannelCommand::AddTlc( + AddTlcCommand { + amount: order.amount_sats - order.fee_sats, + preimage: None, + payment_hash: Some( + Hash256::from_str(&order.payment_hash).expect("parse Hash256"), + ), + expiry: LockTime::new(self.config.ckb_final_tlc_expiry_blocks), + hash_algorithm: HashAlgorithm::Sha256, + }, + rpc_reply, + ), + }, + )) + }; + let tlc_response = call!(self.network_actor.as_ref().unwrap(), 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(); + + state + .orders_db + .update_receive_btc_order(order.clone()) + .await?; + Ok(()) + } +} + +struct LndPaymentsTracker { + cch_actor: ActorRef, + lnd_connection: LndConnectionInfo, + token: CancellationToken, +} + +impl LndPaymentsTracker { + fn new( + cch_actor: ActorRef, + lnd_connection: LndConnectionInfo, + token: CancellationToken, + ) -> Self { + Self { + cch_actor, + lnd_connection, + token, + } + } + + async fn run(self) { + // TODO: clean up expired orders + loop { + select! { + result = self.run_inner() => { + match result { + Ok(_) => { + break; + } + Err(err) => { + tracing::error!( + "Error tracking LND payments, retry 15 seconds later: {:?}", + err + ); + select! { + _ = sleep(Duration::from_secs(15)) => { + // continue + } + _ = self.token.cancelled() => { + tracing::debug!("Cancellation received, shutting down cch service"); + return; + } + } + } + } + } + _ = self.token.cancelled() => { + tracing::debug!("Cancellation received, shutting down cch service"); + return; + } + } + } + } + + async fn run_inner(&self) -> Result<()> { + tracing::debug!( + "[LndPaymentsTracker] will connect {}", + self.lnd_connection.uri + ); + let mut client = self.lnd_connection.create_router_client().await?; + let mut stream = client + .track_payments(routerrpc::TrackPaymentsRequest { + no_inflight_updates: true, + }) + .await? + .into_inner(); + + loop { + select! { + payment_opt = stream.next() => { + match payment_opt { + Some(Ok(payment)) => self.on_payment(payment).await?, + Some(Err(err)) => return Err(err.into()), + None => return Err(anyhow!("unexpected closed stream")), + } + } + _ = self.token.cancelled() => { + tracing::debug!("Cancellation received, shutting down cch service"); + return Ok(()); + } + } + } + } + + async fn on_payment(&self, payment: lnrpc::Payment) -> Result<()> { + tracing::debug!("[LndPaymentsTracker] payment: {:?}", payment); + let event = CchMessage::SettleSendBTCOrder(SettleSendBTCOrderEvent { + payment_hash: format!("0x{}", payment.payment_hash), + preimage: (!payment.payment_preimage.is_empty()) + .then(|| format!("0x{}", payment.payment_preimage)), + status: lnrpc::payment::PaymentStatus::try_from(payment.status) + .map(Into::into) + .unwrap_or(CchOrderStatus::InFlight), + }); + self.cch_actor.cast(event).map_err(Into::into) + } +} + +/// Subscribe single invoice. +/// +/// Lnd does not notify Accepted event in SubscribeInvoices rpc. +/// +/// +struct LndInvoiceTracker { + cch_actor: ActorRef, + payment_hash: String, + lnd_connection: LndConnectionInfo, + token: CancellationToken, +} + +impl LndInvoiceTracker { + fn new( + cch_actor: ActorRef, + payment_hash: String, + lnd_connection: LndConnectionInfo, + token: CancellationToken, + ) -> Self { + Self { + cch_actor, + payment_hash, + lnd_connection, + token, + } + } + + async fn run(self) { + loop { + select! { + result = self.run_inner() => { + match result { + Ok(_) => { + break; + } + Err(err) => { + tracing::error!( + "Error tracking LND invoices, retry 15 seconds later: {:?}", + err + ); + select! { + _ = sleep(Duration::from_secs(15)) => { + // continue + } + _ = self.token.cancelled() => { + tracing::debug!("Cancellation received, shutting down cch service"); + return; + } + } + } + } + } + _ = self.token.cancelled() => { + tracing::debug!("Cancellation received, shutting down cch service"); + return; + } + } + } + } + + async fn run_inner(&self) -> Result<()> { + tracing::debug!( + "[LndInvoiceTracker] will connect {}", + self.lnd_connection.uri + ); + let mut client = self.lnd_connection.create_invoices_client().await?; + // 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"))?, + }) + .await? + .into_inner(); + + loop { + select! { + invoice_opt = stream.next() => { + match invoice_opt { + Some(Ok(invoice)) => if self.on_invoice(invoice).await? { + return Ok(()); + }, + Some(Err(err)) => return Err(err.into()), + None => return Err(anyhow!("unexpected closed stream")), + } + } + _ = self.token.cancelled() => { + tracing::debug!("Cancellation received, shutting down cch service"); + return Ok(()); + } + } + } + } + + // Return true to quit the tracker + async fn on_invoice(&self, invoice: lnrpc::Invoice) -> Result { + tracing::debug!("[LndInvoiceTracker] invoice: {:?}", invoice); + let status = lnrpc::invoice::InvoiceState::try_from(invoice.state) + .map(Into::into) + .unwrap_or(CchOrderStatus::Pending); + 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))), + status, + }); + self.cch_actor.cast(event)?; + // Quit tracker when the status is final + Ok(status == CchOrderStatus::Succeeded || status == CchOrderStatus::Failed) + } +} diff --git a/src/cch/command.rs b/src/cch/command.rs deleted file mode 100644 index 822747b1..00000000 --- a/src/cch/command.rs +++ /dev/null @@ -1,22 +0,0 @@ -use serde::Deserialize; -use serde_with::serde_as; - -#[serde_as] -#[derive(Clone, Debug, Deserialize)] -pub enum CchCommand { - SendBTC(SendBTC), -} - -impl CchCommand { - pub fn name(&self) -> &'static str { - match self { - CchCommand::SendBTC(_) => "SendBTC", - } - } -} - -#[serde_as] -#[derive(Clone, Debug, Deserialize)] -pub struct SendBTC { - pub btc_pay_req: String, -} diff --git a/src/cch/config.rs b/src/cch/config.rs index 5cfe424a..29a74f03 100644 --- a/src/cch/config.rs +++ b/src/cch/config.rs @@ -1,38 +1,59 @@ +use std::path::PathBuf; + use clap_serde_derive::ClapSerde; /// Default cross-chain order expiry time in seconds. pub const DEFAULT_ORDER_EXPIRY_TIME: u64 = 3600; /// Default BTC final-hop HTLC expiry time in seconds. -/// CCH will only use one-hop payment in CKB network. pub const DEFAULT_BTC_FINAL_TLC_EXPIRY_TIME: u64 = 36; -/// Default CKB final-hop HTLC expiry time in seconds. -/// Leave enough time for routing the BTC payment -pub const DEFAULT_CKB_FINAL_TLC_EXPIRY_TIME: u64 = 108; +/// Default CKB final-hop HTLC expiry time in blocks. +pub const DEFAULT_CKB_FINAL_TLC_EXPIRY_BLOCKS: u64 = 10; // Use prefix `cch-`/`CCH_` #[derive(ClapSerde, Debug, Clone)] pub struct CchConfig { + /// cch base directory #[arg( - name = "CCH_RATIO_BTC_MSAT", - long = "cch-ratio-btc-msat", + name = "CCH_BASE_DIR", + long = "cch-base-dir", env, - help = "exchange ratio between BTC and CKB, in milisatoshi per `CCH_RATIO_CKB_SHANNONS` shannon" + help = "base directory for cch [default: $BASE_DIR/cch]" )] - pub ratio_btc_msat: Option, + pub base_dir: Option, + + #[default("https://127.0.0.1:10009".to_string())] #[arg( - name = "CCH_RATIO_CKB_SHANNONS", - long = "cch-ratio-ckb-shannons", + name = "CCH_LND_RPC_URL", + long = "cch-lnd-rpc-url", env, - help = "exchange ratio between BTC and CKB, in shannons per `CCH_RATIO_BTC_MSAT` shannon" + help = "lnd grpc endpoint, default is http://127.0.0.1:10009" )] - pub ratio_ckb_shannons: Option, + pub lnd_rpc_url: String, - /// Whether reject expired BTC invoice when creating the order to send BTC. - /// - /// Default is `false`. Only set to `true` in test. - #[default(false)] - #[arg(skip)] - pub allow_expired_btc_invoice: bool, + #[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." + )] + pub lnd_cert_path: Option, + + #[arg( + name = "CCH_LND_MACAROON_PATH", + long = "cch-lnd-macaroon-path", + env, + help = "Path to the Macaroon file for the grpc connection" + )] + pub lnd_macaroon_path: Option, + + // TODO: use hex type + #[arg( + name = "CCH_WRAPPED_BTC_TYPE_SCRIPT_ARGS", + long = "cch-wrapped-btc-type-script-args", + env, + help = "Wrapped BTC type script args. It must be a UDT with 8 decimal places." + )] + pub wrapped_btc_type_script_args: String, /// Cross-chain order expiry time in seconds. #[default(DEFAULT_ORDER_EXPIRY_TIME)] @@ -46,21 +67,21 @@ pub struct CchConfig { #[default(0)] #[arg( - name = "CCH_BASE_FEE_SHANNONS", - long = "cch-base-fee-shannons", + name = "CCH_BASE_FEE_SATS", + long = "cch-base-fee-sats", env, help = "The base fee charged for each cross-chain order, default is 0" )] - pub base_fee_shannons: u64, + pub base_fee_sats: u64, #[default(1)] #[arg( - name = "CCH_FEE_RATE_PER_MILLION_SHANNONS", - long = "cch-fee-rate-per-million-shannons", + name = "CCH_FEE_RATE_PER_MILLION_SATS", + long = "cch-fee-rate-per-million-sats", env, - help = "The proportional fee charged per million shannons based on the cross-chain order value, default is 1" + help = "The proportional fee charged per million satoshis based on the cross-chain order value, default is 1" )] - pub fee_rate_per_million_shannons: u64, + pub fee_rate_per_million_sats: u64, /// Final tlc expiry time for BTC network. #[default(DEFAULT_BTC_FINAL_TLC_EXPIRY_TIME)] @@ -72,13 +93,39 @@ pub struct CchConfig { )] pub btc_final_tlc_expiry: u64, - /// Final tlc expiry time for CKB network. - #[default(DEFAULT_CKB_FINAL_TLC_EXPIRY_TIME)] + /// Final tlc expiry time for CKB network in blocks. + #[default(DEFAULT_CKB_FINAL_TLC_EXPIRY_BLOCKS)] #[arg( - name = "CCH_CKB_FINAL_TLC_EXPIRY", - long = "cch-ckb-final-tlc-expiry", + name = "CCH_CKB_FINAL_TLC_EXPIRY_BLOCKS", + long = "cch-ckb-final-tlc-expiry-blocks", env, - help = format!("final tlc expiry time in seconds for CKB network, default is {}", DEFAULT_CKB_FINAL_TLC_EXPIRY_TIME), + help = format!("final tlc expiry time in blocks for CKB network, default is {}", DEFAULT_CKB_FINAL_TLC_EXPIRY_BLOCKS), )] - pub ckb_final_tlc_expiry: u64, + pub ckb_final_tlc_expiry_blocks: u64, + + /// Ignore the failure when starting the cch service. + #[default(false)] + pub ignore_startup_failure: bool, +} + +impl CchConfig { + pub fn resolve_lnd_cert_path(&self) -> Option { + self.lnd_cert_path.as_ref().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), + _ => path, + } + }) + } + + pub fn resolve_lnd_macaroon_path(&self) -> Option { + self.lnd_macaroon_path.as_ref().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), + _ => path, + } + }) + } } diff --git a/src/cch/error.rs b/src/cch/error.rs index 1eb88924..201dbfaa 100644 --- a/src/cch/error.rs +++ b/src/cch/error.rs @@ -1,21 +1,64 @@ +use std::time::SystemTimeError; + +use jsonrpsee::types::{error::CALL_EXECUTION_FAILED_CODE, ErrorObjectOwned}; use thiserror::Error; #[derive(Error, Debug)] pub enum CchDbError { - #[error("Duplicated SendBTCOrder with the same payment hash: {0}")] - DuplicatedSendBTCOrder(String), + #[error("Inserting duplicated key: {0}")] + Duplicated(String), + + #[error("Key not found: {0}")] + NotFound(String), } #[derive(Error, Debug)] pub enum CchError { #[error("Database error: {0}")] DbError(#[from] CchDbError), + #[error("BTC invoice parse error: {0}")] + BTCInvoiceParseError(#[from] lightning_invoice::ParseOrSemanticError), #[error("BTC invoice expired")] BTCInvoiceExpired, #[error("BTC invoice missing amount")] BTCInvoiceMissingAmount, - #[error("CKB asset not allowed to exchange BTC")] - CKBAssetNotAllowed, + #[error("CKB invoice error: {0}")] + CKBInvoiceError(#[from] crate::invoice::InvoiceError), + #[error("SendBTC order already paid")] + SendBTCOrderAlreadyPaid, + #[error("SendBTC received payment amount is too small")] + SendBTCReceivedAmountTooSmall, + #[error("ReceiveBTC order payment amount is too small")] + ReceiveBTCOrderAmountTooSmall, + #[error("ReceiveBTC order payment amount is too large")] + ReceiveBTCOrderAmountTooLarge, + #[error("ReceiveBTC order already paid")] + ReceiveBTCOrderAlreadyPaid, + #[error("ReceiveBTC received payment amount is too small")] + ReceiveBTCReceivedAmountTooSmall, + #[error("ReceiveBTC expected preimage but missing")] + ReceiveBTCMissingPreimage, + #[error("System time error: {0}")] + SystemTimeError(#[from] SystemTimeError), + #[error("JSON serialization error: {0}")] + JSONSerializationError(#[from] serde_json::Error), + #[error("Hex decoding error")] + HexDecodingError, + #[error("Lnd channel error: {0}")] + LndChannelError(#[from] lnd_grpc_tonic_client::channel::Error), + #[error("Lnd RPC error: {0}")] + LndRpcError(String), } pub type CchResult = std::result::Result; + +impl Into for CchError { + fn into(self) -> ErrorObjectOwned { + // TODO: categorize error codes + ErrorObjectOwned::owned( + CALL_EXECUTION_FAILED_CODE, + self.to_string(), + Option::<()>::None, + ) + } +} diff --git a/src/cch/mod.rs b/src/cch/mod.rs index e3427756..2466611e 100644 --- a/src/cch/mod.rs +++ b/src/cch/mod.rs @@ -1,20 +1,17 @@ -mod service; -pub use service::start_cch; +mod actor; +pub use actor::{start_cch, CchActor, CchMessage, ReceiveBTC, SendBTC}; mod error; pub use error::{CchError, CchResult}; mod config; pub use config::{ - CchConfig, DEFAULT_BTC_FINAL_TLC_EXPIRY_TIME, DEFAULT_CKB_FINAL_TLC_EXPIRY_TIME, + CchConfig, DEFAULT_BTC_FINAL_TLC_EXPIRY_TIME, DEFAULT_CKB_FINAL_TLC_EXPIRY_BLOCKS, DEFAULT_ORDER_EXPIRY_TIME, }; -mod command; -pub use command::{CchCommand, SendBTC}; - mod order; -pub use order::{CchOrderStatus, SendBTCOrder}; +pub use order::{CchOrderStatus, ReceiveBTCOrder, SendBTCOrder}; mod orders_db; pub use orders_db::CchOrdersDb; diff --git a/src/cch/order.rs b/src/cch/order.rs index bf66d93a..017b4679 100644 --- a/src/cch/order.rs +++ b/src/cch/order.rs @@ -1,28 +1,136 @@ +use super::CchError; +use lnd_grpc_tonic_client::lnrpc; use serde::{Deserialize, Serialize}; +use serde_with::serde_as; +use std::{str::FromStr as _, time::Duration}; -#[derive(Debug, Serialize, Deserialize)] +use crate::{ + ckb::{ + serde_utils::{U128Hex, U64Hex}, + types::Hash256, + }, + invoice::{Currency, InvoiceBuilder}, +}; + +#[derive(Debug, Copy, Clone, Serialize, Deserialize, Eq, PartialEq)] #[serde(rename_all = "snake_case")] pub enum CchOrderStatus { + /// Order is created and has not send out payments yet. Pending = 0, - Completed = 1, - Expired = 2, + /// 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, + /// Order is failed. + Failed = 4, +} + +/// lnd payment is the second half of SendBTCOrder +impl From for CchOrderStatus { + fn from(status: lnrpc::payment::PaymentStatus) -> Self { + use lnrpc::payment::PaymentStatus; + match status { + PaymentStatus::Succeeded => CchOrderStatus::Succeeded, + PaymentStatus::Failed => CchOrderStatus::Failed, + _ => CchOrderStatus::InFlight, + } + } } -#[derive(Debug, Serialize, Deserialize)] +/// lnd invoice is the first half 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::Canceled => CchOrderStatus::Failed, + InvoiceState::Settled => CchOrderStatus::Succeeded, + _ => CchOrderStatus::Pending, + } + } +} + +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct SendBTCOrder { // Seconds since epoch when the order is created - pub timestamp: u64, + #[serde_as(as = "U64Hex")] + pub created_at: u64, // Seconds after timestamp that the order expires - pub expiry: u64, + #[serde_as(as = "U64Hex")] + pub expires_after: u64, // The minimal expiry in seconds of the final TLC in the CKB network + #[serde_as(as = "U64Hex")] pub ckb_final_tlc_expiry: 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)?, + ) + .expiry_time(Duration::from_secs(self.expires_after)) + .final_cltv(self.ckb_final_tlc_expiry) + .udt_type_script(self.wrapped_btc_type_script.clone().into()); + + let invoice = invoice_builder.build()?; + self.ckb_pay_req = invoice.to_string(); + + Ok(()) + } +} + +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReceiveBTCOrder { + // 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 in seconds of the final TLC in the CKB network + #[serde_as(as = "U64Hex")] + pub ckb_final_tlc_expiry: u64, + + 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, - // Amount required to pay in Shannons - pub amount_shannons: u64, - pub fulfilled_amount_shannons: u64, + /// Amount required to pay in Satoshis via BTC, including the fee for the cross-chain hub + #[serde_as(as = "U128Hex")] + pub amount_sats: u128, + #[serde_as(as = "U128Hex")] + pub fee_sats: u128, pub status: CchOrderStatus, } diff --git a/src/cch/orders_db.rs b/src/cch/orders_db.rs index 4f64ad5f..c6573f04 100644 --- a/src/cch/orders_db.rs +++ b/src/cch/orders_db.rs @@ -1,18 +1,71 @@ use std::collections::HashMap; -use super::{error::CchDbError, SendBTCOrder}; +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 - .insert(order.payment_hash.clone(), order); - Ok(()) + .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/src/cch/service.rs b/src/cch/service.rs deleted file mode 100644 index a83db27c..00000000 --- a/src/cch/service.rs +++ /dev/null @@ -1,124 +0,0 @@ -use std::str::FromStr; -use std::time::{SystemTime, UNIX_EPOCH}; - -use anyhow::Result; -use hex::ToHex; -use lightning_invoice::Bolt11Invoice; -use tokio::{select, sync::mpsc}; -use tokio_util::{sync::CancellationToken, task::TaskTracker}; - -use super::{CchCommand, CchConfig, CchError, CchOrderStatus, CchOrdersDb, SendBTC, SendBTCOrder}; - -pub async fn start_cch( - config: CchConfig, - command_receiver: mpsc::Receiver, - token: CancellationToken, - tracker: TaskTracker, -) { - let service = CchService { - config, - command_receiver, - token, - orders_db: Default::default(), - }; - tracker.spawn(async move { - service.run().await; - }); -} -struct CchService { - config: CchConfig, - token: CancellationToken, - command_receiver: mpsc::Receiver, - orders_db: CchOrdersDb, -} - -impl CchService { - pub async fn run(mut self) { - loop { - select! { - _ = self.token.cancelled() => { - tracing::debug!("Cancellation received, shutting down cch service"); - break; - } - command = self.command_receiver.recv() => { - match command { - None => { - tracing::debug!("Command receiver completed, shutting down tentacle service"); - break; - } - Some(command) => { - let command_name = command.name(); - tracing::info!("Process cch command {}", command_name); - - match self.process_command(command).await { - Ok(_) => {} - Err(err) => { - tracing::error!("Error processing command {}: {:?}", command_name, err); - } - } - } - } - } - } - } - } - - async fn process_command(&mut self, command: CchCommand) -> Result<()> { - tracing::debug!("CchCommand received: {:?}", command); - match command { - CchCommand::SendBTC(send_btc) => self.send_btc(send_btc).await, - } - } - - async fn send_btc(&mut self, send_btc: SendBTC) -> 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 expiry = invoice - .expires_at() - .and_then(|expired_at| expired_at.checked_sub(duration_since_epoch)) - .map(|duration| duration.as_secs()) - .or_else(|| { - self.config - .allow_expired_btc_invoice - .then_some(self.config.order_expiry) - }) - .ok_or(CchError::BTCInvoiceExpired)?; - - let amount_msat = invoice - .amount_milli_satoshis() - .ok_or(CchError::BTCInvoiceMissingAmount)?; - - tracing::debug!("SendBTC expiry: {:?}", expiry); - let (ratio_ckb_shannons, ratio_btc_msat) = - match (self.config.ratio_ckb_shannons, self.config.ratio_btc_msat) { - (Some(ratio_ckb_shannons), Some(ratio_btc_msat)) => { - (ratio_ckb_shannons, ratio_btc_msat) - } - _ => return Err(CchError::CKBAssetNotAllowed.into()), - }; - let order_value = ((ratio_ckb_shannons as u128) * (amount_msat as u128) - / (ratio_btc_msat as u128)) as u64; - let fee = order_value * self.config.fee_rate_per_million_shannons / 1_000_000 - + self.config.base_fee_shannons; - - let order = SendBTCOrder { - timestamp: duration_since_epoch.as_secs(), - expiry, - ckb_final_tlc_expiry: self.config.ckb_final_tlc_expiry, - btc_pay_req: send_btc.btc_pay_req, - payment_hash: invoice.payment_hash().encode_hex(), - amount_shannons: order_value + fee, - fulfilled_amount_shannons: 0u64, - status: CchOrderStatus::Pending, - }; - - // TODO: Return it as the RPC response - tracing::info!("SendBTCOrder: {}", serde_json::to_string(&order)?); - self.orders_db.insert_send_btc_order(order).await?; - - Ok(()) - } -} diff --git a/src/ckb/channel.rs b/src/ckb/channel.rs index 7b8a0864..1141fd51 100644 --- a/src/ckb/channel.rs +++ b/src/ckb/channel.rs @@ -1,3 +1,4 @@ +use bitcoin::hashes::{sha256::Hash as Sha256, Hash as _}; use bitflags::bitflags; use ckb_hash::{blake2b_256, new_blake2b}; @@ -16,7 +17,8 @@ use musig2::{ PubNonce, SecNonce, }; use ractor::{ - async_trait as rasync_trait, Actor, ActorProcessingErr, ActorRef, RpcReplyPort, SpawnErr, + async_trait as rasync_trait, Actor, ActorProcessingErr, ActorRef, OutputPort, RpcReplyPort, + SpawnErr, }; use tracing::{debug, error, info, warn}; @@ -29,6 +31,8 @@ use tokio::sync::oneshot; use std::{ borrow::Borrow, collections::BTreeMap, + fmt::Debug, + sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; @@ -52,8 +56,8 @@ use super::{ serde_utils::EntityHex, types::{ AcceptChannel, AddTlc, CFNMessage, ChannelReady, ClosingSigned, CommitmentSigned, Hash256, - LockTime, OpenChannel, Privkey, Pubkey, ReestablishChannel, RemoveTlc, RemoveTlcReason, - RevokeAndAck, TxCollaborationMsg, TxComplete, TxUpdate, + LockTime, OpenChannel, Privkey, Pubkey, ReestablishChannel, RemoveTlc, RemoveTlcFulfill, + RemoveTlcReason, RevokeAndAck, TxCollaborationMsg, TxComplete, TxUpdate, }, NetworkActorCommand, NetworkActorEvent, NetworkActorMessage, }; @@ -86,6 +90,13 @@ pub struct AddTlcResponse { pub tlc_id: u64, } +#[derive(Clone)] +pub struct TlcNotification { + pub channel_id: Hash256, + pub tlc: TLC, + pub script: Script, +} + #[derive(Debug)] pub enum ChannelCommand { TxCollaborationCommand(TxCollaborationCommand), @@ -180,19 +191,40 @@ pub enum ChannelInitializationParameter { ReestablishChannel(Hash256), } -#[derive(Debug)] +#[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 { peer_id: PeerId, network: ActorRef, store: S, + subscribers: ChannelSubscribers, } impl ChannelActor { - pub fn new(peer_id: PeerId, network: ActorRef, store: S) -> Self { + pub fn new( + peer_id: PeerId, + network: ActorRef, + store: S, + subscribers: ChannelSubscribers, + ) -> Self { Self { peer_id, network, store, + subscribers, } } @@ -347,7 +379,15 @@ impl ChannelActor { let tlc = state.create_inbounding_tlc(add_tlc)?; state.insert_tlc(tlc)?; - + if let Some(ref udt_type_script) = state.funding_udt_type_script { + self.subscribers + .pending_received_tlcs_subscribers + .send(TlcNotification { + tlc, + channel_id: state.get_id(), + script: udt_type_script.clone(), + }); + } // TODO: here we didn't send any ack message to the peer. // The peer may falsely believe that we have already processed this message, // while we have crashed. We need a way to make sure that the peer will resend @@ -356,9 +396,25 @@ impl ChannelActor { } CFNMessage::RemoveTlc(remove_tlc) => { state.check_state_for_tlc_update()?; + let channel_id = state.get_id(); - state + let tlc_details = state .remove_tlc_with_reason(TLCId::Offered(remove_tlc.tlc_id), remove_tlc.reason)?; + if let ( + Some(ref udt_type_script), + RemoveTlcReason::RemoveTlcFulfill(RemoveTlcFulfill { payment_preimage }), + ) = (state.funding_udt_type_script.clone(), remove_tlc.reason) + { + let mut tlc = tlc_details.tlc.clone(); + tlc.payment_preimage = Some(payment_preimage); + self.subscribers + .settled_tlcs_subscribers + .send(TlcNotification { + tlc, + channel_id, + script: udt_type_script.clone(), + }); + } Ok(()) } CFNMessage::Shutdown(shutdown) => { @@ -4790,3 +4846,7 @@ mod tests { .await; } } + +pub fn sha256>(s: T) -> [u8; 32] { + Sha256::hash(s.as_ref()).to_byte_array() +} diff --git a/src/ckb/network.rs b/src/ckb/network.rs index 1d5f956b..1a5188e7 100644 --- a/src/ckb/network.rs +++ b/src/ckb/network.rs @@ -34,8 +34,8 @@ use tokio_util::task::TaskTracker; use super::channel::{ AcceptChannelParameter, ChannelActorMessage, ChannelActorStateStore, ChannelCommandWithId, - ChannelEvent, OpenChannelParameter, ProcessingChannelError, ProcessingChannelResult, - DEFAULT_COMMITMENT_FEE_RATE, DEFAULT_FEE_RATE, + ChannelEvent, ChannelSubscribers, OpenChannelParameter, ProcessingChannelError, + ProcessingChannelResult, DEFAULT_COMMITMENT_FEE_RATE, DEFAULT_FEE_RATE, }; use super::key::blake2b_hash_with_salt; use super::types::{Hash256, OpenChannel}; @@ -783,6 +783,7 @@ pub struct NetworkActorState { open_channel_auto_accept_min_ckb_funding_amount: u64, // Tha default amount of CKB to be funded when auto accepting a channel. auto_accept_channel_ckb_funding_amount: u64, + channel_subscribers: ChannelSubscribers, } static CHANNEL_ACTOR_NAME_PREFIX: AtomicU64 = AtomicU64::new(0u64); @@ -837,7 +838,12 @@ impl NetworkActorState { let (tx, rx) = oneshot::channel::(); let channel = Actor::spawn_linked( Some(generate_channel_actor_name(&self.peer_id, &peer_id)), - ChannelActor::new(peer_id.clone(), network.clone(), store), + ChannelActor::new( + peer_id.clone(), + network.clone(), + store, + self.channel_subscribers.clone(), + ), ChannelInitializationParameter::OpenChannel(OpenChannelParameter { funding_amount, seed, @@ -888,7 +894,12 @@ impl NetworkActorState { let (tx, rx) = oneshot::channel::(); let channel = Actor::spawn_linked( Some(generate_channel_actor_name(&self.peer_id, &peer_id)), - ChannelActor::new(peer_id.clone(), network.clone(), store), + ChannelActor::new( + peer_id.clone(), + network.clone(), + store, + self.channel_subscribers.clone(), + ), ChannelInitializationParameter::AcceptChannel(AcceptChannelParameter { funding_amount, reserved_ckb_amount, @@ -1016,7 +1027,12 @@ impl NetworkActorState { debug!("Reestablishing channel {:x}", &channel_id); if let Ok((channel, _)) = Actor::spawn_linked( Some(generate_channel_actor_name(&self.peer_id, peer_id)), - ChannelActor::new(peer_id.clone(), self.network.clone(), store.clone()), + ChannelActor::new( + peer_id.clone(), + self.network.clone(), + store.clone(), + self.channel_subscribers.clone(), + ), ChannelInitializationParameter::ReestablishChannel(channel_id), self.network.get_cell(), ) @@ -1222,6 +1238,12 @@ impl NetworkActorState { } } +pub struct NetworkActorStartArguments { + pub config: CkbConfig, + pub tracker: TaskTracker, + pub channel_subscribers: ChannelSubscribers, +} + #[rasync_trait] impl Actor for NetworkActor where @@ -1229,13 +1251,18 @@ where { type Msg = NetworkActorMessage; type State = NetworkActorState; - type Arguments = (CkbConfig, TaskTracker); + type Arguments = NetworkActorStartArguments; async fn pre_start( &self, myself: ActorRef, - (config, tracker): Self::Arguments, + args: Self::Arguments, ) -> Result { + let NetworkActorStartArguments { + config, + tracker, + channel_subscribers, + } = args; let now = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .expect("SystemTime::now() should after UNIX_EPOCH"); @@ -1301,6 +1328,7 @@ where open_channel_auto_accept_min_ckb_funding_amount: config .open_channel_auto_accept_min_ckb_funding_amount(), auto_accept_channel_ckb_funding_amount: config.auto_accept_channel_ckb_funding_amount(), + channel_subscribers, }) } @@ -1465,6 +1493,7 @@ pub async fn start_ckb ActorRef { let secio_kp: SecioKeyPair = config .read_or_generate_secret_key() @@ -1475,7 +1504,11 @@ pub async fn start_ckb { if !reply_port.is_closed() { - reply_port.send(status).expect("reply ok"); + // ignore error + let _ = reply_port.send(status); } return Ok(()); } diff --git a/src/config.rs b/src/config.rs index 713fddc2..99e70fff 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,7 @@ use crate::{ckb_chain::CkbChainConfig, CchConfig, CkbConfig, LdkConfig, RpcConfi const DEFAULT_CONFIG_FILE_NAME: &str = "config.yml"; const DEFAULT_CKB_DIR_NAME: &str = "ckb"; const DEFAULT_LDK_DIR_NAME: &str = "ldk"; +const DEFAULT_CCH_DIR_NAME: &str = "cch"; fn get_default_base_dir() -> PathBuf { let mut path = home_dir().expect("get home directory"); @@ -174,6 +175,7 @@ impl Config { args.ckb_chain.base_dir = Some(Some( base_dir.join(crate::ckb_chain::DEFAULT_CKB_CHAIN_BASE_DIR_NAME), )); + args.cch.base_dir = Some(Some(base_dir.join(DEFAULT_CCH_DIR_NAME))); let (ckb, ldk, cch, rpc, ckb_chain) = config_from_file .map(|x| { diff --git a/src/lib.rs b/src/lib.rs index 18bd5a6d..1eba127f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,7 @@ pub use ldk::{start_ldk, LdkConfig}; pub mod ckb; pub use ckb::{start_ckb, CkbConfig, NetworkServiceEvent}; pub mod cch; -pub use cch::{start_cch, CchConfig}; +pub use cch::{start_cch, CchActor, CchConfig}; pub mod rpc; pub use rpc::{start_rpc, RpcConfig}; diff --git a/src/main.rs b/src/main.rs index fee58e73..9f4f64f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +use cfn_node::cch::CchMessage; +use cfn_node::ckb::channel::ChannelSubscribers; use cfn_node::ckb_chain::contracts::init_contracts_context; use cfn_node::store::Store; use ractor::Actor; @@ -10,7 +12,6 @@ use tracing_subscriber::{fmt, EnvFilter}; use std::str::FromStr; use cfn_node::actors::RootActor; -use cfn_node::cch::CchCommand; use cfn_node::ckb::{NetworkActorCommand, NetworkActorMessage}; use cfn_node::ckb_chain::CkbChainActor; use cfn_node::tasks::{ @@ -39,6 +40,7 @@ pub async fn main() { let root_actor = RootActor::start(tracker, token).await; let store = Store::new(config.ckb.as_ref().unwrap().store_path()); + let subscribers = ChannelSubscribers::default(); let ckb_command_sender = match config.ckb { Some(ckb_config) => { @@ -72,6 +74,7 @@ pub async fn main() { new_tokio_task_tracker(), root_actor.get_cell(), store.clone(), + subscribers.clone(), ) .await; @@ -112,19 +115,44 @@ pub async fn main() { None => None, }; - let cch_command_sender = match config.cch { + let cch_actor = match config.cch { Some(cch_config) => { - const CHANNEL_SIZE: usize = 4000; - let (command_sender, command_receiver) = mpsc::channel::(CHANNEL_SIZE); info!("Starting cch"); - start_cch( + let ignore_startup_failure = cch_config.ignore_startup_failure; + match start_cch( cch_config, - command_receiver, - new_tokio_cancellation_token(), new_tokio_task_tracker(), + new_tokio_cancellation_token(), + root_actor.get_cell(), + ckb_command_sender.clone(), ) - .await; - Some(command_sender) + .await + { + Err(err) => { + error!("Cross-chain service failed to start: {}", err); + if ignore_startup_failure { + None + } else { + return; + } + } + 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)) + }, + ); + + Some(actor) + } + } } None => None, }; @@ -132,13 +160,13 @@ pub async fn main() { // Start rpc service let rpc_server_handle = match config.rpc { Some(rpc_config) => { - if ckb_command_sender.is_none() && cch_command_sender.is_none() { + if ckb_command_sender.is_none() && cch_actor.is_none() { error!("Rpc service requires ckb and cch service to be started. Exiting."); return; } info!("Starting rpc"); - let handle = start_rpc(rpc_config, ckb_command_sender, cch_command_sender, store).await; + let handle = start_rpc(rpc_config, ckb_command_sender, cch_actor, store).await; Some(handle) } None => None, diff --git a/src/rpc/cch.rs b/src/rpc/cch.rs index ffa853f5..7074c647 100644 --- a/src/rpc/cch.rs +++ b/src/rpc/cch.rs @@ -1,40 +1,234 @@ -use crate::cch::CchCommand; -use jsonrpsee::{core::async_trait, proc_macros::rpc, types::ErrorObjectOwned}; +use crate::{ + cch::{CchMessage, CchOrderStatus, ReceiveBTCOrder}, + ckb::{ + serde_utils::{U128Hex, U64Hex}, + types::Hash256, + }, + invoice::Currency, +}; +use jsonrpsee::{ + core::async_trait, + proc_macros::rpc, + types::{error::CALL_EXECUTION_FAILED_CODE, ErrorObjectOwned}, +}; +use ractor::{call_t, ActorRef}; use serde::{Deserialize, Serialize}; -use tokio::sync::mpsc::Sender; +use serde_with::serde_as; #[derive(Serialize, Deserialize)] pub struct SendBtcParams { pub btc_pay_req: String, + pub currency: Currency, +} + +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SendBTCResponse { + // 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: 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, + + #[serde_as(as = "U128Hex")] + // Amount required to pay in Satoshis, including fee + pub amount_sats: u128, + #[serde_as(as = "U128Hex")] + pub fee_sats: u128, + + pub status: CchOrderStatus, +} + +#[serde_as] +#[derive(Serialize, Deserialize)] +pub struct ReceiveBtcParams { + /// Payment hash for the HTLC for both CKB and BTC. + pub payment_hash: String, + 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, +} + +#[derive(Serialize, Deserialize)] +pub struct GetReceiveBtcOrderParams { + /// 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: u64, + + pub wrapped_btc_type_script: ckb_jsonrpc_types::Script, + + pub btc_pay_req: String, + pub payment_hash: String, + pub channel_id: Hash256, + #[serde_as(as = "Option")] + pub tlc_id: Option, + + // Amount will be received by the payee + #[serde_as(as = "U128Hex")] + pub amount_sats: u128, + #[serde_as(as = "U128Hex")] + pub fee_sats: u128, + + pub status: CchOrderStatus, } #[rpc(server)] pub trait CchRpc { #[method(name = "send_btc")] - async fn send_btc(&self, params: SendBtcParams) -> Result<(), ErrorObjectOwned>; + async fn send_btc(&self, params: SendBtcParams) -> Result; + + #[method(name = "receive_btc")] + async fn receive_btc( + &self, + params: ReceiveBtcParams, + ) -> Result; + + #[method(name = "get_receive_btc_order")] + async fn get_receive_btc_order( + &self, + params: GetReceiveBtcOrderParams, + ) -> Result; } pub struct CchRpcServerImpl { - pub cch_command_sender: Sender, + pub cch_actor: ActorRef, } impl CchRpcServerImpl { - pub fn new(cch_command_sender: Sender) -> Self { - CchRpcServerImpl { cch_command_sender } + pub fn new(cch_actor: ActorRef) -> Self { + CchRpcServerImpl { cch_actor } } } +pub const TIMEOUT: u64 = 1000; + #[async_trait] impl CchRpcServer for CchRpcServerImpl { - async fn send_btc(&self, params: SendBtcParams) -> Result<(), ErrorObjectOwned> { - let command = CchCommand::SendBTC(crate::cch::SendBTC { - btc_pay_req: params.btc_pay_req, - }); - - self.cch_command_sender - .send(command) - .await - .expect("send command"); - Ok(()) + async fn send_btc(&self, params: SendBtcParams) -> Result { + let result = call_t!( + self.cch_actor, + CchMessage::SendBTC, + TIMEOUT, + crate::cch::SendBTC { + btc_pay_req: params.btc_pay_req, + currency: params.currency, + } + ) + .map_err(|ractor_error| { + ErrorObjectOwned::owned( + CALL_EXECUTION_FAILED_CODE, + ractor_error.to_string(), + Option::<()>::None, + ) + })?; + + result + .map(|order| SendBTCResponse { + timestamp: order.created_at, + expiry: order.expires_after, + ckb_final_tlc_expiry: order.ckb_final_tlc_expiry, + 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) + } + + async fn receive_btc( + &self, + 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, + } + ) + .map_err(|ractor_error| { + ErrorObjectOwned::owned( + CALL_EXECUTION_FAILED_CODE, + ractor_error.to_string(), + Option::<()>::None, + ) + })?; + + result.map(Into::into).map_err(Into::into) + } + + async fn get_receive_btc_order( + &self, + params: GetReceiveBtcOrderParams, + ) -> Result { + let result = call_t!( + self.cch_actor, + CchMessage::GetReceiveBTCOrder, + TIMEOUT, + params.payment_hash + ) + .map_err(|ractor_error| { + ErrorObjectOwned::owned( + CALL_EXECUTION_FAILED_CODE, + ractor_error.to_string(), + Option::<()>::None, + ) + })?; + + result.map(Into::into).map_err(Into::into) + } +} + +impl From for ReceiveBTCResponse { + fn from(value: ReceiveBTCOrder) -> Self { + Self { + timestamp: value.created_at, + expiry: value.expires_after, + ckb_final_tlc_expiry: value.ckb_final_tlc_expiry, + wrapped_btc_type_script: value.wrapped_btc_type_script, + btc_pay_req: value.btc_pay_req, + 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/src/rpc/mod.rs b/src/rpc/mod.rs index a2dcc33f..6c022a4a 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -6,7 +6,7 @@ mod peer; mod utils; use crate::{ - cch::CchCommand, + cch::CchMessage, ckb::{channel::ChannelActorStateStore, NetworkActorMessage}, invoice::{InvoiceCommand, InvoiceStore}, }; @@ -51,7 +51,7 @@ fn build_server(addr: &str) -> Server { pub async fn start_rpc( config: RpcConfig, ckb_network_actor: Option>, - cch_command_sender: Option>, + cch_actor: Option>, store: S, ) -> ServerHandle { let listening_addr = config.listening_addr.as_deref().unwrap_or("[::]:0"); @@ -63,8 +63,8 @@ pub async fn start_rpc setTimeout(r, 1000)); +} 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 new file mode 100644 index 00000000..74868737 --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub/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": "0xc350", + "funding_udt_type_script": { + "code_hash": "0xe1e354d6d643ad42724d40967e334984534e0367405c5ae42a9d7d63d77df419", + "hash_type": "data1", + "args": "0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947" + } + } + ] + } +} + +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/05-node3-accept-channel.bru b/tests/bruno/e2e/cross-chain-hub/05-node3-accept-channel.bru new file mode 100644 index 00000000..9c365961 --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub/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/06-ckb-generate-blocks.bru b/tests/bruno/e2e/cross-chain-hub/06-ckb-generate-blocks.bru new file mode 100644 index 00000000..f40aa52c --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub/06-ckb-generate-blocks.bru @@ -0,0 +1,60 @@ +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": {{iteration}}, + "jsonrpc": "2.0", + "method": "generate_block", + "params": [] + } +} + +vars:post-response { + max_iterations: 10 +} + +assert { + res.status: eq 200 +} + +script:pre-request { + // Script taken from https://github.com/usebruno/bruno/discussions/385#discussioncomment-8015350 + 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(`Generated ${i+1}/${n + 1} blocks`); + } + + if(i < n) { + bru.setVar("iteration", i + 1); + await new Promise(r => setTimeout(r, 10)); + bru.setNextRequest("06-ckb-generate-blocks"); + } else { + bru.setVar("iteration", 0); + // Don't know why it takes so long for funding transaction to be confirmed. + console.log("Wait for 5 seconds"); + 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/07-node1-add-tlc.bru new file mode 100644 index 00000000..fba4e585 --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub/07-node1-add-tlc.bru @@ -0,0 +1,45 @@ +meta { + name: 07-node1-add-tlc + 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": "add_tlc", + "params": [ + { + "channel_id": "{{N1N3_CHANNEL_ID}}", + "amount": "0x4e20", + "payment_hash": "{{PAYMENT_HASH}}", + "expiry": 40, + "hash_algorithm": "sha256" + } + ] + } +} + +assert { + res.body.error: isUndefined + res.body.result.tlc_id: isDefined +} + +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/08-check-btc-received.bru b/tests/bruno/e2e/cross-chain-hub/08-check-btc-received.bru new file mode 100644 index 00000000..b8b0bfe3 --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub/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/09-create-receive-btc-order.bru b/tests/bruno/e2e/cross-chain-hub/09-create-receive-btc-order.bru new file mode 100644 index 00000000..1644615f --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub/09-create-receive-btc-order.bru @@ -0,0 +1,61 @@ +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-pay-btc-invoice.bru b/tests/bruno/e2e/cross-chain-hub/10-pay-btc-invoice.bru new file mode 100644 index 00000000..a1c0e77e --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub/10-pay-btc-invoice.bru @@ -0,0 +1,48 @@ +meta { + name: 10-pay-btc-invoice + type: http + seq: 10 +} + +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/11-get-receive-btc-order-tlc-id.bru b/tests/bruno/e2e/cross-chain-hub/11-get-receive-btc-order-tlc-id.bru new file mode 100644 index 00000000..83ec1d65 --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub/11-get-receive-btc-order-tlc-id.bru @@ -0,0 +1,63 @@ +meta { + name: 11-get-receive-btc-order-tlc-id + type: http + seq: 11 +} + +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": "get_receive_btc_order", + "params": [ + { + "payment_hash": "{{PAYMENT_HASH}}" + } + ] + } +} + +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-remove-tlc-for-receive-btc-order.bru b/tests/bruno/e2e/cross-chain-hub/12-remove-tlc-for-receive-btc-order.bru new file mode 100644 index 00000000..a01fe13d --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub/12-remove-tlc-for-receive-btc-order.bru @@ -0,0 +1,38 @@ +meta { + name: 12-remove-tlc-for-receive-btc-order + type: http + seq: 12 +} + +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": "remove_tlc", + "params": [ + { + "channel_id": "{{N1N3_CHANNEL_ID}}", + "tlc_id": "{{N3N1_TLC_ID1}}", + "reason": { + "payment_preimage": "{{PAYMENT_PREIMAGE}}" + } + } + ] + } +} + +assert { + res.body.error: isUndefined + res.body.result: isNull +} diff --git a/tests/bruno/e2e/cross-chain-hub/README.md b/tests/bruno/e2e/cross-chain-hub/README.md new file mode 100644 index 00000000..549c67e6 --- /dev/null +++ b/tests/bruno/e2e/cross-chain-hub/README.md @@ -0,0 +1,14 @@ +# Cross-Chain Hub + +## Roles + +- Bitcoin user: lnd node lnd-bob +- CKB user: CFN node 1 +- Cross-Chain Hub service provider: lnd node lnd-ingrid and CFN node 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 CFN nodes 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/environments/test.bru b/tests/bruno/environments/test.bru index a58c1459..d230d78b 100644 --- a/tests/bruno/environments/test.bru +++ b/tests/bruno/environments/test.bru @@ -10,4 +10,6 @@ vars { NODE2_PEERID: QmSRcPqUn4aQrKHXyCDjGn2qBVf43tWBDS2Wj9QDUZXtZp NODE3_PEERID: QmaFDJb9CkMrXy7nhTWBY5y9mvuykre3EzzRsCJUAVXprZ UDT_CODE_HASH: 0xe1e354d6d643ad42724d40967e334984534e0367405c5ae42a9d7d63d77df419 + 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 4ac6a107..83d7bc28 100644 --- a/tests/bruno/environments/xudt-test.bru +++ b/tests/bruno/environments/xudt-test.bru @@ -10,4 +10,6 @@ vars { NODE2_PEERID: QmSRcPqUn4aQrKHXyCDjGn2qBVf43tWBDS2Wj9QDUZXtZp NODE3_PEERID: QmaFDJb9CkMrXy7nhTWBY5y9mvuykre3EzzRsCJUAVXprZ UDT_CODE_HASH: 0x50bd8d6680b8b9cf98b73f3c08faf8b2a21914311954118ad6609be6e78a1b95 + 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/send_btc.bru b/tests/bruno/send_btc.bru deleted file mode 100644 index 24abbf94..00000000 --- a/tests/bruno/send_btc.bru +++ /dev/null @@ -1,30 +0,0 @@ -meta { - name: send_btc - type: http - seq: 2 -} - -post { - url: {{NODE3_RPC_URL}}/cch - body: json - auth: none -} - -headers { - Content-Type: application/json - Accept: application/json -} - -body:json { - { - "request": { - "SendBTC": { - "btc_pay_req": "lnbcrt10u1pjl6r4npp5gdw9ha2cnwfj3pnnf5nj4dprm7srkprakgueaxkhrs0kyjp9st0qdqqcqzzsxqyz5vqsp567m6wxsnmgl5cgsxs0g8ffz0tg3tajkw4q6j0q7gj3wqnpxcqums9qyysgq2vht3tcxemehylp0arnvjnqnal9pnxtdfkxxxm2hxqxxljv5et98wse4clcv36r4u9fxnqr4vl0qztazuyq4s9ald85yj30l4vzlu4qq3nkstp" - } - } - } -} - -assert { - res.status: eq 200 -} diff --git a/tests/deploy/lnd-init/.gitignore b/tests/deploy/lnd-init/.gitignore new file mode 100644 index 00000000..27d95936 --- /dev/null +++ b/tests/deploy/lnd-init/.gitignore @@ -0,0 +1,12 @@ +/bitcoind/regtest +/lnd-bob/data +/lnd-bob/letsencrypt +/lnd-bob/logs +/lnd-bob/tls.cert +/lnd-bob/tls.key +/lnd-ingrid/data +/lnd-ingrid/letsencrypt +/lnd-ingrid/logs +/lnd-ingrid/tls.cert +/lnd-ingrid/tls.key +*.pid diff --git a/tests/deploy/lnd-init/README.md b/tests/deploy/lnd-init/README.md new file mode 100644 index 00000000..a7b190ab --- /dev/null +++ b/tests/deploy/lnd-init/README.md @@ -0,0 +1,11 @@ +# lnd init + +Setup 1 bitcoind and 2 lnd nodes. + +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. + +The nodes will have their own data directories: + +- `bitcoind`: bitcoind node. +- `lnd-bob`: lnd node for the BTC user. +- `lnd-ingrid`: lnd node for the cross-chain hub operator. diff --git a/tests/deploy/lnd-init/bitcoind/bitcoin.conf b/tests/deploy/lnd-init/bitcoind/bitcoin.conf new file mode 100644 index 00000000..4a40b25f --- /dev/null +++ b/tests/deploy/lnd-init/bitcoind/bitcoin.conf @@ -0,0 +1,9 @@ +server=1 +regtest=1 + +[regtest] +rpcport=18443 +rpcuser=btc +rpcpassword=btc +zmqpubrawblock=tcp://127.0.0.1:28332 +zmqpubrawtx=tcp://127.0.0.1:28333 diff --git a/tests/deploy/lnd-init/lnd-bob/lnd.conf b/tests/deploy/lnd-init/lnd-bob/lnd.conf new file mode 100644 index 00000000..32ef5a1d --- /dev/null +++ b/tests/deploy/lnd-init/lnd-bob/lnd.conf @@ -0,0 +1,19 @@ +[Application Options] +listen=0.0.0.0:9835 +rpclisten=localhost:11009 +restlisten=localhost:8180 +no-macaroons=true +no-rest-tls=true +noseedbackup=true + +[Bitcoin] +bitcoin.active=1 +bitcoin.regtest=1 +bitcoin.node=bitcoind + +[Bitcoind] +bitcoind.rpchost=localhost:18443 +bitcoind.rpcuser=btc +bitcoind.rpcpass=btc +bitcoind.zmqpubrawblock=tcp://127.0.0.1:28332 +bitcoind.zmqpubrawtx=tcp://127.0.0.1:28333 diff --git a/tests/deploy/lnd-init/lnd-ingrid/lnd.conf b/tests/deploy/lnd-init/lnd-ingrid/lnd.conf new file mode 100644 index 00000000..22220921 --- /dev/null +++ b/tests/deploy/lnd-init/lnd-ingrid/lnd.conf @@ -0,0 +1,19 @@ +[Application Options] +listen=0.0.0.0:9735 +rpclisten=localhost:10009 +restlisten=localhost:8080 +no-macaroons=true +no-rest-tls=true +noseedbackup=true + +[Bitcoin] +bitcoin.active=1 +bitcoin.regtest=1 +bitcoin.node=bitcoind + +[Bitcoind] +bitcoind.rpchost=localhost:18443 +bitcoind.rpcuser=btc +bitcoind.rpcpass=btc +bitcoind.zmqpubrawblock=tcp://127.0.0.1:28332 +bitcoind.zmqpubrawtx=tcp://127.0.0.1:28333 diff --git a/tests/deploy/lnd-init/setup-lnd.sh b/tests/deploy/lnd-init/setup-lnd.sh new file mode 100755 index 00000000..e2e125c8 --- /dev/null +++ b/tests/deploy/lnd-init/setup-lnd.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash + +set -e + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +bob_port=11009 +ingrid_port=10009 + +echo "=> bootstrap" +echo "script_dir=$script_dir" + +kill-via-pid-file () { + local pid="$(cat "$1" 2>/dev/null || true)" + if [ -n "$pid" ]; then + kill "$pid" || true + sleep 3 + if kill -0 "$pid" 2>/dev/null; then + echo "Failed to kill $pid, force killing it." + kill -9 "$pid" + fi + rm -f "$1" + fi +} + +cleanup() { + echo "=> cleanup" + kill-via-pid-file "$script_dir/bitcoind/bitcoind.pid" + kill-via-pid-file "$script_dir/lnd-bob/lnd.pid" + kill-via-pid-file "$script_dir/lnd-ingrid/lnd.pid" + rm -rf "$script_dir/bitcoind/regtest" + + local lnd_dir + + for lnd_dir in lnd-bob lnd-ingrid; do + rm -rf "$script_dir/$lnd_dir/data" + rm -rf "$script_dir/$lnd_dir/letsencrypt" + rm -rf "$script_dir/$lnd_dir/logs" + done +} + +setup-bitcoind() { + echo "=> setting up bitcoind" + local bitcoind_dir="$script_dir/bitcoind" + local bitcoind_conf="$bitcoind_dir/bitcoin.conf" + local bitcoind_pid="$bitcoind_dir/bitcoind.pid" + + bitcoind -conf="$bitcoind_conf" -datadir="$bitcoind_dir" -daemonwait -pid="$bitcoind_pid" + bitcoin-cli -conf="$bitcoind_conf" -datadir="$bitcoind_dir" -rpcwait createwallet dev >/dev/null + echo "bitcoind wallet created" + bitcoin-cli -conf="$bitcoind_conf" -generate 101 >/dev/null + echo "bitcoind blocks generated" +} + +setup-lnd() { + local lnd_name="$1" + local lnd_port="$2" + local lnd_dir="$script_dir/$lnd_name" + echo "=> setting up lnd $lnd_name" + nohup lnd --lnddir="$lnd_dir" &>/dev/null & + echo "$!" > "$lnd_dir/lnd.pid" + local retries=30 + echo "waiting for ready" + while [[ $retries -gt 0 ]] && ! lncli -n regtest --lnddir="$lnd_dir" --no-macaroons --rpcserver "localhost:$lnd_port" getinfo &>/dev/null; do + sleep 1 + retries=$((retries - 1)) + done + echo "remaining retries=$retries" +} + +setup-channels() { + echo "=> open channel from ingrid to bob" + local bob_dir="$script_dir/lnd-bob" + local ingrid_dir="$script_dir/lnd-ingrid" + local ingrid_p2tr_address="$(lncli -n regtest --lnddir="$ingrid_dir" --no-macaroons --rpcserver "localhost:$ingrid_port" newaddress p2tr | jq -r .address)" + local bob_node_key="$(lncli -n regtest --lnddir="$bob_dir" --no-macaroons --rpcserver "localhost:$bob_port" getinfo | jq -r .identity_pubkey)" + echo "ingrid_p2tr_address=$ingrid_p2tr_address" + echo "bob_node_key=$bob_node_key" + + echo "deposit btc" + local bitcoind_dir="$script_dir/bitcoind" + local bitcoind_conf="$bitcoind_dir/bitcoin.conf" + bitcoin-cli -conf="$bitcoind_conf" -rpcwait -named sendtoaddress address="$ingrid_p2tr_address" amount=5 fee_rate=25 + bitcoin-cli -conf="$bitcoind_conf" -generate 1 >/dev/null + + echo "openchannel" + local retries=5 + while [[ $retries -gt 0 ]] && ! lncli -n regtest --lnddir="$ingrid_dir" --no-macaroons --rpcserver "localhost:$ingrid_port" \ + openchannel \ + --node_key "$bob_node_key" \ + --connect localhost:9835 \ + --local_amt 1000000 \ + --sat_per_vbyte 1 \ + --min_confs 0; do + sleep 3 + retries=$((retries - 1)) + done + + echo "generate blocks" + bitcoin-cli -conf="$bitcoind_conf" -generate 3 >/dev/null +} + +cleanup +setup-bitcoind +setup-lnd lnd-bob $bob_port +setup-lnd lnd-ingrid $ingrid_port +setup-channels diff --git a/tests/deploy/udt-init/src/main.rs b/tests/deploy/udt-init/src/main.rs index 92841046..0c48920c 100644 --- a/tests/deploy/udt-init/src/main.rs +++ b/tests/deploy/udt-init/src/main.rs @@ -236,6 +236,15 @@ fn genrate_nodes_config() { data["rpc"]["listening_addr"] = serde_yaml::Value::String(format!("127.0.0.1:{}", 41714 + i - 1)); data["ckb_chain"]["udt_whitelist"] = serde_yaml::to_value(&udt_infos).unwrap(); + + // Node 3 acts as a CCH node. + if i == 3 { + data["services"] + .as_sequence_mut() + .unwrap() + .push(serde_yaml::Value::String("cch".to_string())); + } + let new_yaml = header.to_string() + &serde_yaml::to_string(&data).unwrap(); let config_path = format!("{}/{}/config.yml", nodes_dir, i); std::fs::write(config_path, new_yaml).expect("write failed"); diff --git a/tests/nodes/3/cch/.gitkeep b/tests/nodes/3/cch/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/nodes/deployer/config.yml b/tests/nodes/deployer/config.yml index 60cfa2c5..22feed8b 100644 --- a/tests/nodes/deployer/config.yml +++ b/tests/nodes/deployer/config.yml @@ -8,20 +8,11 @@ rpc: listening_addr: 127.0.0.1:41716 cch: - ratio_btc_msat: 1 - ratio_ckb_shannons: 5000 - allow_expired_btc_invoice: true + ignore_startup_failure: true + wrapped_btc_type_script_args: "0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947" + lnd_cert_path: ../../../deploy/lnd-init/lnd-ingrid/tls.cert -# Note that we are different in the sense we have 20 billions of CKB tokens in the genesis block. ckb_chain: - # sighash_all - funding_source_lock_script_code_hash: "0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8" - funding_source_lock_script_hash_type: "type" - - # funding lock - funding_cell_lock_script_code_hash: "0x8090ce20be9976e2407511502acebf74ac1cfed10d7b35b7f33f56c9bd0daec6" - funding_cell_lock_script_hash_type: "type" - # udt whitelist, will be generated by udt-init udt_whitelist: - name: simple_udt @@ -36,6 +27,5 @@ ckb_chain: services: - ckb - - cch - rpc - - ckb_chain \ No newline at end of file + - ckb_chain diff --git a/tests/nodes/start.ps1 b/tests/nodes/start.ps1 new file mode 100644 index 00000000..127e3660 --- /dev/null +++ b/tests/nodes/start.ps1 @@ -0,0 +1,16 @@ +Set-Location -Path $PSScriptRoot + +$jobs = @() +try { + $jobs += Start-Job -ScriptBlock { cargo run -- -d 1 } + $jobs += Start-Job -ScriptBlock { cargo run -- -d 2 } + $jobs += Start-Job -ScriptBlock { cargo run -- -d 3 } + + # Wait for jobs to complete + $jobs | Format-Table + $jobs | Receive-Job -Wait -Force -WriteEvents +} finally { + Write-Warning "Ctrl+C detected. Stopping jobs..." + $jobs | Stop-Job + $jobs | Remove-Job +}