diff --git a/Cargo.lock b/Cargo.lock index b359e26ca..855db6c70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1876,6 +1876,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-stream", + "async-trait", "atty", "axum", "bdk", @@ -3404,6 +3405,7 @@ dependencies = [ "flutter_rust_bridge", "ln-dlc-node", "local-ip-address", + "maker", "native", "orderbook-commons", "quote", diff --git a/coordinator/src/admin.rs b/coordinator/src/admin.rs index 18d88b11d..891a17fa8 100644 --- a/coordinator/src/admin.rs +++ b/coordinator/src/admin.rs @@ -24,8 +24,8 @@ use tracing::instrument; #[derive(Serialize, Deserialize)] pub struct Balance { - offchain: u64, - onchain: u64, + pub offchain: u64, + pub onchain: u64, } #[autometrics] diff --git a/crates/ln-dlc-node/src/lib.rs b/crates/ln-dlc-node/src/lib.rs index 4b739fc1a..35ee55de0 100644 --- a/crates/ln-dlc-node/src/lib.rs +++ b/crates/ln-dlc-node/src/lib.rs @@ -28,13 +28,13 @@ mod disk; mod dlc_custom_signer; mod fee_rate_estimator; mod ldk_node_wallet; -mod ln; mod ln_dlc_wallet; mod on_chain_wallet; mod shadow; pub mod channel; pub mod config; +pub mod ln; pub mod node; pub mod scorer; pub mod seed; @@ -45,6 +45,8 @@ pub use config::CONFIRMATION_TARGET; pub use config::CONTRACT_TX_FEE_RATE; pub use config::LIQUIDITY_MULTIPLIER; pub use ldk_node_wallet::WalletSettings; +pub use lightning; +pub use lightning_invoice; pub use ln::AppEventHandler; pub use ln::ChannelDetails; pub use ln::CoordinatorEventHandler; diff --git a/crates/ln-dlc-node/src/ln/common_handlers.rs b/crates/ln-dlc-node/src/ln/common_handlers.rs index f71ba6921..9d46f21fd 100644 --- a/crates/ln-dlc-node/src/ln/common_handlers.rs +++ b/crates/ln-dlc-node/src/ln/common_handlers.rs @@ -34,7 +34,7 @@ use time::OffsetDateTime; use tokio::task::block_in_place; use uuid::Uuid; -pub(crate) fn handle_payment_claimable( +pub fn handle_payment_claimable( channel_manager: &Arc, payment_hash: PaymentHash, purpose: PaymentPurpose, @@ -57,7 +57,7 @@ pub(crate) fn handle_payment_claimable( Ok(()) } -pub(crate) fn handle_htlc_handling_failed( +pub fn handle_htlc_handling_failed( prev_channel_id: [u8; 32], failed_next_destination: lightning::util::events::HTLCDestination, ) { @@ -68,7 +68,7 @@ pub(crate) fn handle_htlc_handling_failed( ); } -pub(crate) fn handle_discard_funding(transaction: bitcoin::Transaction, channel_id: [u8; 32]) { +pub fn handle_discard_funding(transaction: bitcoin::Transaction, channel_id: [u8; 32]) { let tx_hex = serialize_hex(&transaction); tracing::info!( channel_id = %channel_id.to_hex(), @@ -82,7 +82,7 @@ pub(crate) fn handle_discard_funding(transaction: bitcoin::Transaction, channel_ // generated. } -pub(crate) fn handle_payment_forwarded( +pub fn handle_payment_forwarded( node: &Arc>, prev_channel_id: Option<[u8; 32]>, next_channel_id: Option<[u8; 32]>, @@ -217,7 +217,7 @@ where Ok(()) } -pub(crate) fn handle_channel_closed( +pub fn handle_channel_closed( node: &Arc>, pending_intercepted_htlcs: &PendingInterceptedHtlcs, user_channel_id: u128, @@ -265,7 +265,7 @@ where Ok(()) } -pub(crate) fn handle_spendable_outputs( +pub fn handle_spendable_outputs( node: &Arc>, outputs: Vec, ) -> Result<()> @@ -305,7 +305,7 @@ where Ok(()) } -pub(crate) fn handle_payment_claimed( +pub fn handle_payment_claimed( node: &Arc>, amount_msat: u64, payment_hash: PaymentHash, @@ -344,7 +344,7 @@ pub(crate) fn handle_payment_claimed( } } -pub(crate) fn handle_payment_failed(node: &Arc>, payment_hash: PaymentHash) +pub fn handle_payment_failed(node: &Arc>, payment_hash: PaymentHash) where S: Storage, { @@ -369,7 +369,7 @@ where } } -pub(crate) async fn handle_funding_generation_ready( +pub async fn handle_funding_generation_ready( node: &Arc>, user_channel_id: u128, counterparty_node_id: PublicKey, @@ -449,7 +449,7 @@ where }) } -pub(crate) fn handle_channel_ready_internal( +fn handle_channel_ready_internal( node: &Arc>, pending_intercepted_htlcs: &PendingInterceptedHtlcs, user_channel_id: u128, @@ -517,7 +517,7 @@ pub(crate) fn fail_intercepted_htlc( let _ = channel_manager.fail_intercepted_htlc(*intercept_id); } -pub(crate) fn handle_pending_htlcs_forwardable( +pub fn handle_pending_htlcs_forwardable( forwarding_channel_manager: Arc, time_forwardable: Duration, ) { diff --git a/crates/ln-dlc-node/src/ln/mod.rs b/crates/ln-dlc-node/src/ln/mod.rs index e8bb98f2c..bb63a546e 100644 --- a/crates/ln-dlc-node/src/ln/mod.rs +++ b/crates/ln-dlc-node/src/ln/mod.rs @@ -1,17 +1,18 @@ mod app_event_handler; mod channel_details; -/// A collection of handlers for events emitted by the lightning node. -/// -/// When constructing a new [`Node`], you can pass in a custom [`EventHandler`] -/// to handle events; these handlers are useful to reduce boilerplate if you -/// don't require custom behaviour -pub mod common_handlers; mod coordinator_event_handler; mod dlc_channel_details; mod event_handler; mod logger; mod manage_spendable_outputs; +/// A collection of handlers for events emitted by the Lightning node. +/// +/// When constructing a new [`Node`], you can pass in a custom [`EventHandler`] +/// to handle events; these handlers are useful to reduce boilerplate if you +/// don't require custom behaviour. +pub mod common_handlers; + pub use app_event_handler::AppEventHandler; pub use channel_details::ChannelDetails; pub use coordinator_event_handler::CoordinatorEventHandler; diff --git a/crates/ln-dlc-node/src/node/mod.rs b/crates/ln-dlc-node/src/node/mod.rs index 284416d5f..bdd00bcc6 100644 --- a/crates/ln-dlc-node/src/node/mod.rs +++ b/crates/ln-dlc-node/src/node/mod.rs @@ -9,6 +9,7 @@ use crate::node::peer_manager::alias_as_bytes; use crate::node::peer_manager::broadcast_node_announcement; use crate::on_chain_wallet::OnChainWallet; use crate::seed::Bip39Seed; +use crate::shadow::Shadow; use crate::ChainMonitor; use crate::EventHandlerTrait; use crate::NetworkGraph; @@ -30,6 +31,7 @@ use lightning::ln::msgs::NetAddress; use lightning::ln::peer_handler::MessageHandler; use lightning::routing::gossip::P2PGossipSync; use lightning::routing::router::DefaultRouter; +use lightning::routing::scoring::ProbabilisticScorer; use lightning::routing::utxo::UtxoLookup; use lightning::util::config::UserConfig; use lightning_background_processor::process_events_async; @@ -57,25 +59,25 @@ use tokio::task::spawn_blocking; mod channel_manager; mod connection; -pub(crate) mod dlc_channel; mod dlc_manager; -pub(crate) mod invoice; mod ln_channel; mod oracle; -pub mod peer_manager; mod storage; mod sub_channel_manager; mod wallet; +pub(crate) mod dlc_channel; +pub(crate) mod invoice; + +pub mod peer_manager; + pub use self::dlc_manager::DlcManager; pub use crate::node::oracle::OracleInfo; -use crate::shadow::Shadow; pub use ::dlc_manager as rust_dlc_manager; pub use channel_manager::ChannelManager; pub use dlc_channel::dlc_message_name; pub use dlc_channel::sub_channel_message_name; pub use invoice::HTLCStatus; -use lightning::routing::scoring::ProbabilisticScorer; pub use storage::InMemoryStore; pub use storage::Storage; pub use sub_channel_manager::SubChannelManager; @@ -122,7 +124,7 @@ pub struct Node { pub sub_channel_manager: Arc, oracle: Arc, pub dlc_message_handler: Arc, - pub(crate) storage: Arc, + pub storage: Arc, pub ldk_config: Arc>, // fields below are needed only to start the node diff --git a/crates/tests-e2e/Cargo.toml b/crates/tests-e2e/Cargo.toml index 78969c391..07f26562b 100644 --- a/crates/tests-e2e/Cargo.toml +++ b/crates/tests-e2e/Cargo.toml @@ -12,6 +12,7 @@ coordinator = { path = "../../coordinator" } coordinator-commons = { path = "../coordinator-commons" } flutter_rust_bridge = "1.78.0" ln-dlc-node = { path = "../ln-dlc-node" } +maker = { path = "../../maker" } native = { path = "../../mobile/native" } orderbook-commons = { path = "../orderbook-commons" } quote = "1.0.28" diff --git a/crates/tests-e2e/examples/fund.rs b/crates/tests-e2e/examples/fund.rs index f2b1ef697..cf7703083 100644 --- a/crates/tests-e2e/examples/fund.rs +++ b/crates/tests-e2e/examples/fund.rs @@ -46,7 +46,11 @@ async fn fund_everything(faucet: &str, coordinator: &str) -> Result<()> { mine(10, faucet).await?; let coordinator_balance = coordinator.get_balance().await?; - tracing::info!("coordinator BTC balance: {}", coordinator_balance); + tracing::info!( + onchain = %coordinator_balance.onchain, + offchain = %coordinator_balance.offchain, + "Coordinator balance", + ); let node: NodeInfo = coordinator.get_node_info().await?; tracing::info!("lightning node: {}", node); diff --git a/crates/tests-e2e/src/coordinator.rs b/crates/tests-e2e/src/coordinator.rs index e8cd0dce5..a70d0bea0 100644 --- a/crates/tests-e2e/src/coordinator.rs +++ b/crates/tests-e2e/src/coordinator.rs @@ -1,13 +1,15 @@ use anyhow::Context; use anyhow::Result; +use coordinator::admin::Balance; use coordinator::routes::InvoiceParams; +use ln_dlc_node::lightning_invoice; use ln_dlc_node::node::NodeInfo; use reqwest::Client; use serde::Deserialize; -/// A wrapper over the coordinator HTTP API +/// A wrapper over the coordinator HTTP API. /// -/// It does not aim to be complete, functionality will be added as needed +/// It does not aim to be complete, functionality will be added as needed. pub struct Coordinator { client: Client, host: String, @@ -48,7 +50,7 @@ impl Coordinator { Self::new(client, "http://localhost:8000") } - /// Check whether the coordinator is running + /// Check whether the coordinator is running. pub async fn is_running(&self) -> bool { self.get("/health").await.is_ok() } @@ -73,7 +75,7 @@ impl Coordinator { Ok(()) } - pub async fn create_invoice(&self, amount: Option) -> Result { + pub async fn create_invoice(&self, amount: Option) -> Result { let invoice_params = InvoiceParams { amount, description: Some("Fee for tests".to_string()), @@ -86,7 +88,9 @@ impl Coordinator { .get(&format!("/api/invoice?{encoded_params}")) .await? .text() - .await?; + .await? + .parse()?; + Ok(invoice) } @@ -105,9 +109,8 @@ impl Coordinator { .to_owned()) } - // TODO: Introduce strong type - pub async fn get_balance(&self) -> Result { - Ok(self.get("/api/admin/balance").await?.text().await?) + pub async fn get_balance(&self) -> Result { + Ok(self.get("/api/admin/balance").await?.json().await?) } pub async fn get_node_info(&self) -> Result { diff --git a/crates/tests-e2e/src/fund.rs b/crates/tests-e2e/src/fund.rs index d2ab53c8b..52e453f5e 100644 --- a/crates/tests-e2e/src/fund.rs +++ b/crates/tests-e2e/src/fund.rs @@ -1,21 +1,25 @@ +use crate::app::AppHandle; +use crate::wait_until; use anyhow::bail; use anyhow::Result; use native::api; +use native::api::PaymentFlow; +use native::api::Status; +use native::api::WalletHistoryItem; use reqwest::Client; use serde::Deserialize; use tokio::task::spawn_blocking; -// TODO: Fetch these from the app -pub const FUNDING_TRANSACTION_FEES: u64 = 153; - -/// Pay a lightning invoice using an LND faucet -/// -/// Returns the funded amount (in satoshis) -pub async fn fund_app_with_faucet(client: &Client, funding_amount: u64) -> Result { - let invoice = spawn_blocking(move || { - api::create_invoice_with_amount(funding_amount).expect("to succeed") - }) - .await?; +/// Instruct the LND faucet to pay an invoice generated with the purpose of opening a JIT channel +/// between the coordinator and an app. +pub async fn fund_app_with_faucet( + app: &AppHandle, + client: &Client, + fund_amount: u64, +) -> Result<()> { + let invoice = + spawn_blocking(move || api::create_invoice_with_amount(fund_amount).expect("to succeed")) + .await?; api::decode_invoice(invoice.clone()).expect("to decode invoice we created"); pay_with_faucet(client, invoice).await?; @@ -23,7 +27,51 @@ pub async fn fund_app_with_faucet(client: &Client, funding_amount: u64) -> Resul // Ensure we sync the wallet info after funding spawn_blocking(move || api::refresh_wallet_info().expect("to succeed")).await?; - Ok(funding_amount - FUNDING_TRANSACTION_FEES) + // Wait until the app has an outbound payment which should correspond to the channel-opening fee + wait_until!(app + .rx + .wallet_info() + .expect("to have wallet info") + .history + .iter() + .any(|item| matches!( + item, + WalletHistoryItem { + flow: PaymentFlow::Outbound, + status: Status::Confirmed, + .. + } + ))); + + let order_matching_fee = app + .rx + .wallet_info() + .expect("to have wallet info") + .history + .iter() + .find_map(|item| match item { + WalletHistoryItem { + flow: PaymentFlow::Outbound, + status: Status::Confirmed, + amount_sats, + .. + } => Some(amount_sats), + _ => None, + }) + .copied() + .expect("to have an order-matching fee"); + + tracing::info!(%fund_amount, %order_matching_fee, "Successfully funded app with faucet"); + assert_eq!( + app.rx + .wallet_info() + .expect("to have wallet info") + .balances + .lightning, + fund_amount - order_matching_fee + ); + + Ok(()) } async fn pay_with_faucet(client: &Client, invoice: String) -> Result<()> { diff --git a/crates/tests-e2e/src/lib.rs b/crates/tests-e2e/src/lib.rs index c1b584ecf..753296380 100644 --- a/crates/tests-e2e/src/lib.rs +++ b/crates/tests-e2e/src/lib.rs @@ -4,6 +4,7 @@ pub mod coordinator; pub mod fund; pub mod http; pub mod logger; +pub mod maker; pub mod setup; pub mod test_flow; pub mod test_subscriber; diff --git a/crates/tests-e2e/src/maker.rs b/crates/tests-e2e/src/maker.rs new file mode 100644 index 000000000..026dcf5ca --- /dev/null +++ b/crates/tests-e2e/src/maker.rs @@ -0,0 +1,116 @@ +use anyhow::Context; +use anyhow::Result; +use bitcoin::Address; +use ln_dlc_node::lightning_invoice::Invoice; +use ln_dlc_node::node::NodeInfo; +use maker::routes::Balance; +use maker::routes::ChannelParams; +use maker::routes::TargetInfo; +use reqwest::Client; +use serde::Serialize; + +/// A wrapper over the maker HTTP API. +/// +/// It does not aim to be complete, functionality will be added as needed. +pub struct Maker { + client: Client, + host: String, +} + +impl Maker { + pub fn new(client: Client, host: &str) -> Self { + Self { + client, + host: host.to_string(), + } + } + + pub fn new_local(client: Client) -> Self { + Self::new(client, "http://localhost:18000") + } + + pub async fn is_running(&self) -> bool { + self.get("/").await.is_ok() + } + + pub async fn sync_on_chain(&self) -> Result<()> { + let no_json: Option<()> = None; + self.post("/api/sync-on-chain", no_json).await?; + Ok(()) + } + + pub async fn pay_invoice(&self, invoice: Invoice) -> Result<()> { + let no_json: Option<()> = None; + self.post(&format!("/api/pay-invoice/{invoice}"), no_json) + .await?; + Ok(()) + } + + pub async fn get_new_address(&self) -> Result
{ + Ok(self.get("/api/newaddress").await?.text().await?.parse()?) + } + + pub async fn get_balance(&self) -> Result { + Ok(self.get("/api/balance").await?.json().await?) + } + + pub async fn get_node_info(&self) -> Result { + self.get("/api/node") + .await? + .json() + .await + .context("could not parse json") + } + + pub async fn open_channel( + &self, + target: NodeInfo, + local_balance: u64, + remote_balance: Option, + ) -> Result<()> { + self.post( + "/api/channels", + Some(ChannelParams { + target: TargetInfo { + pubkey: target.pubkey.to_string(), + address: target.address.to_string(), + }, + local_balance, + remote_balance, + }), + ) + .await?; + + Ok(()) + } + + async fn get(&self, path: &str) -> Result { + self.client + .get(format!("{0}{path}", self.host)) + .send() + .await + .context("Could not send GET request to coordinator")? + .error_for_status() + .context("Maker did not return 200 OK") + } + + async fn post(&self, path: &str, json: Option) -> Result + where + J: Serialize, + { + let builder = self.client.post(format!("{0}{path}", self.host)); + + let builder = match json { + Some(ref json) => builder.json(json), + None => builder, + }; + + builder + .json(&json) + .send() + .await + .context("Could not send POST request to coordinator")? + .error_for_status() + .context("Maker did not return 200 OK") + } +} diff --git a/crates/tests-e2e/src/setup.rs b/crates/tests-e2e/src/setup.rs index 0c8033ce9..4cea7d3c3 100644 --- a/crates/tests-e2e/src/setup.rs +++ b/crates/tests-e2e/src/setup.rs @@ -61,7 +61,8 @@ impl TestSetup { "App should start with empty wallet" ); - let funded_amount = fund_app_with_faucet(&client, 50_000) + let fund_amount = 50_000; + fund_app_with_faucet(&app, &client, fund_amount) .await .expect("to be able to fund"); @@ -71,11 +72,7 @@ impl TestSetup { .expect("to have wallet info") .balances .lightning; - tracing::info!(%funded_amount, %ln_balance, "Successfully funded app with faucet"); - - if funded_amount != ln_balance { - tracing::warn!("Expected funded amount does not match ln balance!"); - } + tracing::info!(%fund_amount, %ln_balance, "Successfully funded app with faucet"); assert!(ln_balance > 0, "App wallet should be funded"); diff --git a/crates/tests-e2e/src/tracing.rs b/crates/tests-e2e/src/tracing.rs deleted file mode 100644 index 201e5d62c..000000000 --- a/crates/tests-e2e/src/tracing.rs +++ /dev/null @@ -1,22 +0,0 @@ -use std::sync::Once; - -pub fn init_tracing() { - static TRACING_TEST_SUBSCRIBER: Once = Once::new(); - - TRACING_TEST_SUBSCRIBER.call_once(|| { - tracing_subscriber::fmt() - .with_env_filter( - "debug,\ - hyper=warn,\ - reqwest=warn,\ - rustls=warn,\ - bdk=info,\ - lightning::ln::peer_handler=debug,\ - lightning=trace,\ - sled=info,\ - ureq=info", - ) - .with_test_writer() - .init() - }) -} diff --git a/crates/tests-e2e/tests/basic.rs b/crates/tests-e2e/tests/basic.rs index 3e3e55005..e5191cc37 100644 --- a/crates/tests-e2e/tests/basic.rs +++ b/crates/tests-e2e/tests/basic.rs @@ -34,17 +34,10 @@ async fn app_can_be_funded_with_lnd_faucet() -> Result<()> { let app = run_app().await; // Unfunded wallet should be empty - assert_eq!(app.rx.wallet_info().unwrap().balances.on_chain, 0); assert_eq!(app.rx.wallet_info().unwrap().balances.lightning, 0); - let funded_amount = fund_app_with_faucet(&client, 50_000).await?; + let fund_amount = 50_000; + fund_app_with_faucet(&app, &client, fund_amount).await?; - assert_eq!(app.rx.wallet_info().unwrap().balances.on_chain, 0); - - tracing::info!(%funded_amount, "Successfully funded app with faucet"); - assert_eq!( - app.rx.wallet_info().unwrap().balances.lightning, - funded_amount - ); Ok(()) } diff --git a/crates/tests-e2e/tests/maker.rs b/crates/tests-e2e/tests/maker.rs new file mode 100644 index 000000000..a335c0e20 --- /dev/null +++ b/crates/tests-e2e/tests/maker.rs @@ -0,0 +1,83 @@ +use anyhow::Result; +use bitcoin::Amount; +use std::time::Duration; +use tests_e2e::bitcoind::Bitcoind; +use tests_e2e::coordinator::Coordinator; +use tests_e2e::http::init_reqwest; +use tests_e2e::logger::init_tracing; +use tests_e2e::maker::Maker; +use tests_e2e::wait_until; + +#[tokio::test] +#[ignore = "need to be run with 'just e2e' command"] +async fn maker_can_open_channel_to_coordinator_and_send_payment() -> Result<()> { + init_tracing(); + + let client = init_reqwest(); + + let maker = Maker::new_local(client.clone()); + assert!(maker.is_running().await); + + let coordinator = Coordinator::new_local(client.clone()); + assert!(coordinator.is_running().await); + + let node_info_coordinator = coordinator.get_node_info().await?; + + // Ensure the maker has a free UTXO available. + let address = maker.get_new_address().await.unwrap(); + let bitcoind = Bitcoind::new(client.clone()); + bitcoind + .send_to_address(address, Amount::ONE_BTC) + .await + .unwrap(); + bitcoind.mine(1).await.unwrap(); + maker.sync_on_chain().await.unwrap(); + + let balance_maker_before_channel = maker.get_balance().await?.offchain; + + let outbound_liquidity_maker = 500_000; + maker + .open_channel(node_info_coordinator, outbound_liquidity_maker, None) + .await?; + + // Wait for the channel between maker and coordinator to be open. + tokio::time::sleep(Duration::from_secs(5)).await; + + // Mine one block to render the public channel is usable. + bitcoind.mine(1).await.unwrap(); + coordinator.sync_wallet().await.unwrap(); + maker.sync_on_chain().await.unwrap(); + + let balance_maker_after_channel = maker.get_balance().await?.offchain; + + assert_eq!( + balance_maker_before_channel + outbound_liquidity_maker, + balance_maker_after_channel + ); + + let balance_coordinator_after_channel = coordinator.get_balance().await?.offchain; + + let payment_amount = 100_000; + let invoice = coordinator.create_invoice(Some(payment_amount)).await?; + + maker.pay_invoice(invoice).await?; + + wait_until!( + coordinator.get_balance().await.unwrap().offchain > balance_coordinator_after_channel + ); + + let balance_maker_after_payment = maker.get_balance().await?.offchain; + let balance_coordinator_after_payment = coordinator.get_balance().await?.offchain; + + assert_eq!( + balance_maker_after_channel - payment_amount, + balance_maker_after_payment + ); + + assert_eq!( + balance_coordinator_after_channel + payment_amount, + balance_coordinator_after_payment + ); + + Ok(()) +} diff --git a/crates/tests-e2e/tests/send_payment_when_open_position.rs b/crates/tests-e2e/tests/send_payment_when_open_position.rs index e1bd08e70..00c3c4292 100644 --- a/crates/tests-e2e/tests/send_payment_when_open_position.rs +++ b/crates/tests-e2e/tests/send_payment_when_open_position.rs @@ -18,10 +18,9 @@ async fn can_send_payment_with_open_position() { .create_invoice(Some(invoice_amount)) .await .unwrap(); - api::decode_invoice(invoice.clone()).expect("to decode coordinator's invoice"); tracing::info!("Sending payment to coordinator from the app"); - spawn_blocking(move || api::send_payment(invoice).unwrap()) + spawn_blocking(move || api::send_payment(invoice.to_string()).unwrap()) .await .unwrap(); diff --git a/justfile b/justfile index 5d4ba4d89..eff72059b 100644 --- a/justfile +++ b/justfile @@ -204,7 +204,7 @@ native-test: test: flutter-test native-test -# Run expensive tests from the `ln-dlc-node` crate. +# Run expensive tests from the `ln-dlc-node` crate. ln-dlc-node-test args="": docker # wait a few seconds to ensure that Docker containers started sleep 2 @@ -240,6 +240,7 @@ run-maker-detached: echo "Starting (and building) maker" cargo run --bin maker &> {{maker_log_file}} & + just wait-for-maker-to-be-ready echo "Maker successfully started. You can inspect the logs at {{maker_log_file}}" # Attach to the current coordinator logs @@ -255,7 +256,7 @@ maker-logs: less +F {{maker_log_file}} # Run services in the background -services: docker run-coordinator-detached run-maker-detached wait-for-coordinator-to-be-ready fund +services: docker run-coordinator-detached run-maker-detached fund # Run everything at once (docker, coordinator, native build) # Note: if you have mobile simulator running, it will start that one instead of native, but will *not* rebuild the mobile rust library. @@ -325,6 +326,39 @@ wait-for-coordinator-to-be-ready: echo "Max attempts reached. Coordinator is still not ready." exit 1 +[private] +wait-for-maker-to-be-ready: + #!/usr/bin/env bash + set +e + + endpoint="http://localhost:18000/" + max_attempts=600 + sleep_duration=1 + + check_endpoint() { + response=$(curl -s -o /dev/null -w "%{http_code}" "$endpoint") + if [ "$response" -eq 200 ]; then + echo "Maker is ready!" + exit 0 + else + echo "Maker not ready yet. Retrying..." + return 1 + fi + } + + attempt=1 + while [ "$attempt" -le "$max_attempts" ]; do + if check_endpoint; then + exit 0 + fi + + sleep "$sleep_duration" + attempt=$((attempt + 1)) + done + + echo "Max attempts reached. Maker is still not ready." + exit 1 + build-ipa args="": #!/usr/bin/env bash BUILD_NUMBER=$(git rev-list HEAD --count) diff --git a/maker/Cargo.toml b/maker/Cargo.toml index 4d2f2dbed..dbb962714 100644 --- a/maker/Cargo.toml +++ b/maker/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] anyhow = { version = "1", features = ["backtrace"] } async-stream = "0.3" +async-trait = "0.1" atty = "0.2.14" axum = { version = "0.6.7", features = ["ws"] } bdk = { version = "0.27.0", default-features = false, features = ["key-value-db", "use-esplora-blocking"] } diff --git a/maker/src/bin/maker.rs b/maker/src/bin/maker.rs index a8e64194c..e37c977cf 100644 --- a/maker/src/bin/maker.rs +++ b/maker/src/bin/maker.rs @@ -1,12 +1,26 @@ +use anyhow::Context; use anyhow::Result; -use bitcoin::secp256k1::PublicKey; +use diesel::r2d2; +use diesel::r2d2::ConnectionManager; +use diesel::PgConnection; +use ln_dlc_node::node::InMemoryStore; +use ln_dlc_node::node::LnDlcNodeSettings; +use ln_dlc_node::seed::Bip39Seed; use maker::cli::Opts; +use maker::ln::ldk_config; +use maker::ln::EventHandler; use maker::logger; +use maker::routes::router; +use maker::run_migration; use maker::trading; +use rand::thread_rng; +use rand::RngCore; use std::backtrace::Backtrace; -use std::str::FromStr; -use time::Duration; -use tracing::level_filters::LevelFilter; +use std::net::IpAddr; +use std::net::Ipv4Addr; +use std::net::SocketAddr; +use std::sync::Arc; +use tracing::metadata::LevelFilter; #[tokio::main] async fn main() -> Result<()> { @@ -23,29 +37,92 @@ async fn main() -> Result<()> { ); let opts = Opts::read(); - - let node_pubkey = - PublicKey::from_str("03f75f318471d32d39be3c86c622e2c51bd5731bf95f98aaa3ed5d6e1c0025927f") - .expect("is a valid public key"); + let data_dir = opts.data_dir()?; + let address = opts.p2p_address; + let http_address = opts.http_address; + let network = opts.network(); logger::init_tracing(LevelFilter::DEBUG, opts.json)?; - match trading::run( - &opts.orderbook, - node_pubkey, - opts.network(), - opts.concurrent_orders, - Duration::seconds(opts.order_expiry_after_seconds as i64), - ) - .await + let mut ephemeral_randomness = [0; 32]; + thread_rng().fill_bytes(&mut ephemeral_randomness); + + let data_dir = data_dir.join(network.to_string()); + if !data_dir.exists() { + std::fs::create_dir_all(&data_dir) + .context(format!("Could not create data dir for {network}"))?; + } + + let data_dir_string = data_dir.clone().into_os_string(); + tracing::info!("Data-dir: {data_dir_string:?}"); + + let seed_path = data_dir.join("seed"); + let seed = Bip39Seed::initialize(&seed_path)?; + + let node = Arc::new(ln_dlc_node::node::Node::new( + ldk_config(), + ln_dlc_node::scorer::persistent_scorer, + "maker", + network, + data_dir.as_path(), + Arc::new(InMemoryStore::default()), + address, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), address.port()), + ln_dlc_node::util::into_net_addresses(address), + opts.esplora.clone(), + seed, + ephemeral_randomness, + LnDlcNodeSettings::default(), + opts.get_oracle_info().into(), + )?); + + let event_handler = EventHandler::new(node.clone()); + let _running_node = node.start(event_handler)?; + + let node_pubkey = node.info.pubkey; + tokio::spawn(async move { + match trading::run( + &opts.orderbook, + node_pubkey, + network, + opts.concurrent_orders, + time::Duration::seconds(opts.order_expiry_after_seconds as i64), + ) + .await + { + Ok(()) => { + tracing::error!("Maker stopped trading"); + } + Err(error) => { + tracing::error!("Maker stopped trading: {error:#}"); + } + } + }); + + let manager = ConnectionManager::::new(opts.database); + let pool = r2d2::Pool::builder() + .build(manager) + .expect("Failed to create pool."); + + let mut conn = pool.get().expect("to get connection from pool"); + run_migration(&mut conn); + + let app = router(node, pool); + + let addr = SocketAddr::from((http_address.ip(), http_address.port())); + tracing::debug!("Listening on http://{}", addr); + + match axum::Server::bind(&addr) + .serve(app.into_make_service()) + .await { Ok(_) => { - tracing::error!("Maker stopped trading") + tracing::info!("HTTP server stopped running"); } Err(e) => { - tracing::error!("Maker stopped trading: {e:#}"); + tracing::error!("HTTP server stopped running: {e:#}"); } - }; + } Ok(()) } diff --git a/maker/src/cli.rs b/maker/src/cli.rs index 664e5256e..17207d123 100644 --- a/maker/src/cli.rs +++ b/maker/src/cli.rs @@ -1,5 +1,6 @@ use anyhow::Result; use clap::Parser; +use ln_dlc_node::node::OracleInfo; use reqwest::Url; use std::env::current_dir; use std::net::SocketAddr; @@ -7,47 +8,58 @@ use std::path::PathBuf; #[derive(Parser)] pub struct Opts { - /// The address to listen on for the lightning and dlc peer2peer API. + /// The address to listen on for the Lightning and `rust-dlc` p2p API. #[clap(long, default_value = "0.0.0.0:19045")] pub p2p_address: SocketAddr, - /// The IP address to listen on for the HTTP API. + /// Our own HTTP endpoint. #[clap(long, default_value = "0.0.0.0:18000")] pub http_address: SocketAddr, - /// Where to permanently store data, defaults to the current working directory. + /// Where to permanently store data. Defaults to the current working directory. #[clap(long)] data_dir: Option, #[clap(value_enum, default_value = "regtest")] pub network: Network, - /// The HTTP address for the orderbook. + /// The orderbook HTTP endpoint. #[clap(long, default_value = "http://localhost:8000")] pub orderbook: Url, - /// The address where to find the database inclding username and password + /// The address where to find the database including username and password. #[clap( long, default_value = "postgres://postgres:mysecretpassword@localhost:5432/orderbook" )] pub database: String, - /// The address to connect esplora API to + /// The Esplora server endpoint. #[clap(long, default_value = "http://localhost:3000")] pub esplora: String, - /// If enabled logs will be in json format + /// If enabled logs will be in JSON format. #[clap(short, long)] pub json: bool, - /// Amount of concurrent orders (buy,sell) that maker will create at a time + /// Amount of concurrent orders (buy,sell) that the maker will create at a time. #[clap(long, default_value = "5")] pub concurrent_orders: usize, - /// Orders created by maker will be valid for this amount of seconds + /// Orders created by maker will be valid for this number of seconds. #[clap(long, default_value = "60")] pub order_expiry_after_seconds: u64, + + /// The oracle endpoint. + #[clap(long, default_value = "http://localhost:8081")] + oracle_endpoint: String, + + /// The public key of the oracle. + #[clap( + long, + default_value = "16f88cf7d21e6c0f46bcbc983a4e3b19726c6c98858cc31c83551a88fde171c0" + )] + oracle_pubkey: String, } #[derive(Debug, Clone, Copy, clap::ValueEnum)] @@ -88,4 +100,15 @@ impl Opts { Ok(data_dir) } + + pub fn get_oracle_info(&self) -> OracleInfo { + OracleInfo { + endpoint: self.oracle_endpoint.clone(), + public_key: self + .oracle_pubkey + .as_str() + .parse() + .expect("Valid oracle public key"), + } + } } diff --git a/maker/src/lib.rs b/maker/src/lib.rs index 812a0de38..054a988ca 100644 --- a/maker/src/lib.rs +++ b/maker/src/lib.rs @@ -1,17 +1,18 @@ +use diesel::PgConnection; +use diesel_migrations::embed_migrations; +use diesel_migrations::EmbeddedMigrations; +use diesel_migrations::MigrationHarness; + +#[cfg(test)] +mod tests; + pub mod cli; +pub mod ln; pub mod logger; pub mod routes; pub mod schema; pub mod trading; -#[cfg(test)] -mod tests; - -use diesel::PgConnection; -use diesel_migrations::embed_migrations; -use diesel_migrations::EmbeddedMigrations; -use diesel_migrations::MigrationHarness; - pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); pub fn run_migration(conn: &mut PgConnection) { diff --git a/maker/src/ln.rs b/maker/src/ln.rs new file mode 100644 index 000000000..d1d68239f --- /dev/null +++ b/maker/src/ln.rs @@ -0,0 +1,38 @@ +use ln_dlc_node::lightning::ln::channelmanager::MIN_CLTV_EXPIRY_DELTA; +use ln_dlc_node::lightning::util::config::ChannelConfig; +use ln_dlc_node::lightning::util::config::ChannelHandshakeConfig; +use ln_dlc_node::lightning::util::config::ChannelHandshakeLimits; +use ln_dlc_node::lightning::util::config::UserConfig; + +mod event_handler; + +pub use event_handler::EventHandler; + +pub fn ldk_config() -> UserConfig { + UserConfig { + channel_handshake_config: ChannelHandshakeConfig { + // The coordinator mandates this. + announced_channel: true, + minimum_depth: 1, + // There is no risk in leaf channels receiving 100% of the channel capacity. + max_inbound_htlc_value_in_flight_percent_of_channel: 100, + // We want the coordinator to recover force-close funds as soon as possible. We choose + // 144 because we can't go any lower according to LDK. + our_to_self_delay: 144, + ..Default::default() + }, + channel_handshake_limits: ChannelHandshakeLimits { + max_minimum_depth: 1, + // We want makers to only have to wait ~24 hours in case of a force-close. We choose 144 + // because we can't go any lower according to LDK. + their_to_self_delay: 144, + max_funding_satoshis: 100_000_000, + ..Default::default() + }, + channel_config: ChannelConfig { + cltv_expiry_delta: MIN_CLTV_EXPIRY_DELTA, + ..Default::default() + }, + ..Default::default() + } +} diff --git a/maker/src/ln/event_handler.rs b/maker/src/ln/event_handler.rs new file mode 100644 index 000000000..6404fb2fd --- /dev/null +++ b/maker/src/ln/event_handler.rs @@ -0,0 +1,294 @@ +use anyhow::anyhow; +use anyhow::Context; +use anyhow::Result; +use async_trait::async_trait; +use bitcoin::hashes::hex::ToHex; +use bitcoin::secp256k1::PublicKey; +use ln_dlc_node::channel::Channel; +use ln_dlc_node::channel::UserChannelId; +use ln_dlc_node::lightning; +use ln_dlc_node::lightning::util::events::Event; +use ln_dlc_node::ln::common_handlers; +use ln_dlc_node::node::rust_dlc_manager::subchannel::LNChannelManager; +use ln_dlc_node::node::ChannelManager; +use ln_dlc_node::node::Node; +use ln_dlc_node::node::Storage; +use ln_dlc_node::EventHandlerTrait; +use ln_dlc_node::EventSender; +use std::sync::Arc; +use tokio::task::block_in_place; +use uuid::Uuid; + +pub struct EventHandler { + pub(crate) node: Arc>, +} + +impl EventHandler +where + S: Storage + Send + Sync + 'static, +{ + pub fn new(node: Arc>) -> Self { + Self { node } + } +} + +#[async_trait] +impl EventHandlerTrait for EventHandler +where + S: Storage + Send + Sync + 'static, +{ + fn event_sender(&self) -> &Option { + &None + } + + async fn match_event(&self, event: Event) -> Result<()> { + match event { + Event::FundingGenerationReady { + temporary_channel_id, + counterparty_node_id, + channel_value_satoshis, + output_script, + user_channel_id, + } => { + common_handlers::handle_funding_generation_ready( + &self.node, + user_channel_id, + counterparty_node_id, + output_script, + channel_value_satoshis, + temporary_channel_id, + ) + .await?; + } + Event::PaymentClaimed { + payment_hash, + purpose, + amount_msat, + receiver_node_id: _, + } => { + common_handlers::handle_payment_claimed( + &self.node, + amount_msat, + payment_hash, + purpose, + ); + } + Event::PaymentSent { + payment_preimage, + payment_hash, + fee_paid_msat, + .. + } => { + common_handlers::handle_payment_sent( + &self.node, + payment_hash, + payment_preimage, + fee_paid_msat, + )?; + } + Event::OpenChannelRequest { + counterparty_node_id, + funding_satoshis, + push_msat, + temporary_channel_id, + .. + } => { + handle_open_channel_request_0_conf( + &self.node.channel_manager, + counterparty_node_id, + funding_satoshis, + push_msat, + temporary_channel_id, + )?; + } + Event::PaymentPathSuccessful { + payment_id, + payment_hash, + path, + } => { + tracing::info!(?payment_id, ?payment_hash, ?path, "Payment path successful"); + } + Event::PaymentPathFailed { payment_hash, .. } => { + tracing::warn!( + payment_hash = %payment_hash.0.to_hex(), + "Payment path failed"); + } + Event::PaymentFailed { payment_hash, .. } => { + common_handlers::handle_payment_failed(&self.node, payment_hash); + } + Event::PaymentForwarded { + prev_channel_id, + next_channel_id, + fee_earned_msat, + claim_from_onchain_tx, + } => { + common_handlers::handle_payment_forwarded( + &self.node, + prev_channel_id, + next_channel_id, + claim_from_onchain_tx, + fee_earned_msat, + ); + } + Event::PendingHTLCsForwardable { time_forwardable } => { + common_handlers::handle_pending_htlcs_forwardable( + self.node.channel_manager.clone(), + time_forwardable, + ); + } + Event::SpendableOutputs { outputs } => { + common_handlers::handle_spendable_outputs(&self.node, outputs)?; + } + Event::ChannelClosed { + channel_id, + reason, + user_channel_id, + } => { + self.handle_channel_closed(user_channel_id, reason, channel_id)?; + } + Event::DiscardFunding { + channel_id, + transaction, + } => { + common_handlers::handle_discard_funding(transaction, channel_id); + } + Event::ProbeSuccessful { .. } => {} + Event::ProbeFailed { .. } => {} + Event::ChannelReady { + channel_id, + counterparty_node_id, + user_channel_id, + .. + } => { + self.handle_channel_ready(user_channel_id, channel_id, counterparty_node_id)?; + } + Event::HTLCHandlingFailed { + prev_channel_id, + failed_next_destination, + } => { + common_handlers::handle_htlc_handling_failed( + prev_channel_id, + failed_next_destination, + ); + } + Event::PaymentClaimable { + receiver_node_id: _, + payment_hash, + amount_msat, + purpose, + via_channel_id: _, + via_user_channel_id: _, + } => { + common_handlers::handle_payment_claimable( + &self.node.channel_manager, + payment_hash, + purpose, + amount_msat, + )?; + } + Event::HTLCIntercepted { .. } => { + tracing::error!( + ?event, + "The maker should not support interceptable invoices!" + ); + } + }; + + Ok(()) + } +} + +impl EventHandler +where + S: Storage, +{ + pub fn handle_channel_ready( + &self, + user_channel_id: u128, + channel_id: [u8; 32], + counterparty_node_id: PublicKey, + ) -> Result<()> + where + S: Storage, + { + block_in_place(|| { + let user_channel_id = UserChannelId::from(user_channel_id).to_string(); + + tracing::info!( + user_channel_id, + channel_id = %channel_id.to_hex(), + counterparty = %counterparty_node_id.to_string(), + "Channel ready" + ); + + let channel_details = self + .node + .channel_manager + .get_channel_details(&channel_id) + .ok_or(anyhow!( + "Failed to get channel details by channel_id {}", + channel_id.to_hex() + ))?; + + let channel = self.node.storage.get_channel(&user_channel_id)?; + let channel = Channel::open_channel(channel, channel_details)?; + self.node.storage.upsert_channel(channel)?; + + Ok(()) + }) + } + + pub fn handle_channel_closed( + &self, + user_channel_id: u128, + reason: lightning::util::events::ClosureReason, + channel_id: [u8; 32], + ) -> Result<(), anyhow::Error> { + block_in_place(|| { + let user_channel_id = Uuid::from_u128(user_channel_id).to_string(); + tracing::info!( + %user_channel_id, + channel_id = %channel_id.to_hex(), + ?reason, + "Channel closed", + ); + + if let Some(channel) = self.node.storage.get_channel(&user_channel_id)? { + let channel = Channel::close_channel(channel, reason); + self.node.storage.upsert_channel(channel)?; + } + + self.node + .sub_channel_manager + .notify_ln_channel_closed(channel_id)?; + + anyhow::Ok(()) + })?; + Ok(()) + } +} + +fn handle_open_channel_request_0_conf( + channel_manager: &Arc, + counterparty_node_id: PublicKey, + funding_satoshis: u64, + push_msat: u64, + temporary_channel_id: [u8; 32], +) -> Result<()> { + let counterparty = counterparty_node_id.to_string(); + tracing::info!( + counterparty, + funding_satoshis, + push_msat, + "Accepting open channel request" + ); + channel_manager + .accept_inbound_channel_from_trusted_peer_0conf( + &temporary_channel_id, + &counterparty_node_id, + 0, + ) + .map_err(|e| anyhow!("{e:?}")) + .context("To be able to accept a 0-conf channel")?; + Ok(()) +} diff --git a/maker/src/routes.rs b/maker/src/routes.rs index 37c2cd311..0b981c798 100644 --- a/maker/src/routes.rs +++ b/maker/src/routes.rs @@ -20,6 +20,7 @@ use serde::Serialize; use serde_json::json; use std::str::FromStr; use std::sync::Arc; +use tokio::task::spawn_blocking; pub struct AppState { pub node: Arc>, @@ -40,6 +41,7 @@ pub fn router( .route("/api/channels", get(list_channels).post(create_channel)) .route("/api/connect", post(connect_to_peer)) .route("/api/pay-invoice/:invoice", post(pay_invoice)) + .route("/api/sync-on-chain", post(sync_on_chain)) .with_state(app_state) } @@ -95,14 +97,14 @@ pub async fn index(State(app_state): State>) -> Result })) } -pub async fn get_unused_address(State(app_state): State>) -> Json { - Json(app_state.node.get_unused_address().to_string()) +pub async fn get_unused_address(State(app_state): State>) -> impl IntoResponse { + app_state.node.get_unused_address().to_string() } #[derive(Serialize, Deserialize)] pub struct Balance { - offchain: u64, - onchain: u64, + pub offchain: u64, + pub onchain: u64, } pub async fn get_balance(State(state): State>) -> Result, AppError> { @@ -147,17 +149,17 @@ impl IntoResponse for AppError { } } -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] pub struct ChannelParams { - target: TargetInfo, - local_balance: u64, - remote_balance: Option, + pub target: TargetInfo, + pub local_balance: u64, + pub remote_balance: Option, } -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] pub struct TargetInfo { - pubkey: String, - address: String, + pub pubkey: String, + pub address: String, } pub async fn create_channel( @@ -184,7 +186,7 @@ pub async fn create_channel( let channel_id = state .node - .initiate_open_channel(peer.pubkey, channel_amount, initial_send_amount, false) + .initiate_open_channel(peer.pubkey, channel_amount, initial_send_amount, true) .map_err(|e| AppError::InternalServerError(format!("Failed to open channel: {e:#}")))?; Ok(Json(hex::encode(channel_id))) @@ -204,7 +206,7 @@ pub async fn list_channels(State(state): State>) -> Json>, Path(invoice): Path, -) -> Result, AppError> { +) -> Result<(), AppError> { let invoice = invoice .parse() .map_err(|e| AppError::BadRequest(format!("Invalid invoice provided {e:#}")))?; @@ -212,5 +214,14 @@ pub async fn pay_invoice( .node .send_payment(&invoice) .map_err(|e| AppError::InternalServerError(format!("Could not pay invoice {e:#}")))?; - Ok(Json("bl".to_string())) + Ok(()) +} + +pub async fn sync_on_chain(State(state): State>) -> Result<(), AppError> { + spawn_blocking(move || state.node.wallet().sync()) + .await + .expect("task to complete") + .map_err(|e| AppError::InternalServerError(format!("Could not sync wallet: {e:#}")))?; + + Ok(()) }