From 0172a94b2b9cdf647cfccf8b22799afe07e3d300 Mon Sep 17 00:00:00 2001 From: Richard Holzeis Date: Wed, 22 May 2024 21:02:09 +0200 Subject: [PATCH] feat: Watch for lightning invoice payment This will submit the order once a hodl invoice is paid. In the next step we have to extend the post order api to revail the pre image so that the coordinator can claim the payment and open a single sided channel. --- Cargo.lock | 1 + coordinator/src/node/invoice.rs | 51 ++++---- coordinator/src/routes.rs | 9 +- crates/tests-e2e/src/test_subscriber.rs | 3 + crates/xxi-node/Cargo.toml | 1 + crates/xxi-node/src/commons/message.rs | 8 ++ crates/xxi-node/src/commons/pre_image.rs | 8 ++ mobile/lib/common/routes.dart | 29 ++--- .../trade/application/order_service.dart | 22 +++- .../channel_configuration_screen.dart | 5 +- .../channel_funding_screen.dart | 66 +++++----- .../trade/submit_order_change_notifier.dart | 10 +- mobile/native/src/api.rs | 100 ++++++++++++--- mobile/native/src/backup.rs | 3 - mobile/native/src/dlc/node.rs | 6 +- mobile/native/src/event/api.rs | 7 +- mobile/native/src/event/mod.rs | 7 +- mobile/native/src/hodl_invoice.rs | 20 ++- mobile/native/src/lib.rs | 2 +- mobile/native/src/orderbook.rs | 4 + mobile/native/src/unfunded_orders.rs | 117 ------------------ mobile/native/src/watcher.rs | 88 +++++++++++++ 22 files changed, 332 insertions(+), 235 deletions(-) delete mode 100644 mobile/native/src/unfunded_orders.rs create mode 100644 mobile/native/src/watcher.rs diff --git a/Cargo.lock b/Cargo.lock index 8cd058f92..eabff1f35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5413,6 +5413,7 @@ dependencies = [ "anyhow", "async-trait", "axum 0.6.20", + "base64 0.22.1", "bdk", "bdk_coin_select", "bdk_esplora", diff --git a/coordinator/src/node/invoice.rs b/coordinator/src/node/invoice.rs index 49f58f5dd..398afbabc 100644 --- a/coordinator/src/node/invoice.rs +++ b/coordinator/src/node/invoice.rs @@ -1,10 +1,17 @@ +use bitcoin::Amount; use futures_util::TryStreamExt; use lnd_bridge::InvoiceState; use lnd_bridge::LndBridge; +use tokio::sync::broadcast; use xxi_node::commons; +use xxi_node::commons::Message; /// Watches a hodl invoice with the given r_hash -pub fn spawn_invoice_watch(lnd_bridge: LndBridge, invoice_params: commons::HodlInvoiceParams) { +pub fn spawn_invoice_watch( + trader_sender: broadcast::Sender, + lnd_bridge: LndBridge, + invoice_params: commons::HodlInvoiceParams, +) { tokio::spawn(async move { let trader_pubkey = invoice_params.trader_pubkey; let r_hash = invoice_params.r_hash; @@ -12,28 +19,30 @@ pub fn spawn_invoice_watch(lnd_bridge: LndBridge, invoice_params: commons::HodlI 'watch_invoice: loop { match stream.try_next().await { - Ok(Some(invoice)) => { - match invoice.state { - InvoiceState::Open => { - tracing::debug!(%trader_pubkey, r_hash, "Watching hodl invoice."); - continue 'watch_invoice; - } - InvoiceState::Settled => { - tracing::info!(%trader_pubkey, r_hash, "Accepted hodl invoice has been settled."); - break 'watch_invoice; - } - InvoiceState::Canceled => { - tracing::warn!(%trader_pubkey, r_hash, "Pending hodl invoice has been canceled."); - break 'watch_invoice; - } - InvoiceState::Accepted => { - tracing::info!(%trader_pubkey, r_hash, "Pending hodl invoice has been accepted."); - // TODO(holzeis): Notify the client about the accepted invoice. - // wait for the invoice to get settled. - continue 'watch_invoice; + Ok(Some(invoice)) => match invoice.state { + InvoiceState::Open => { + tracing::debug!(%trader_pubkey, invoice.r_hash, "Watching hodl invoice."); + continue 'watch_invoice; + } + InvoiceState::Settled => { + tracing::info!(%trader_pubkey, invoice.r_hash, "Accepted hodl invoice has been settled."); + break 'watch_invoice; + } + InvoiceState::Canceled => { + tracing::warn!(%trader_pubkey, invoice.r_hash, "Pending hodl invoice has been canceled."); + break 'watch_invoice; + } + InvoiceState::Accepted => { + tracing::info!(%trader_pubkey, invoice.r_hash, "Pending hodl invoice has been accepted."); + if let Err(e) = trader_sender.send(Message::PaymentReceived { + r_hash: invoice.r_hash.clone(), + amount: Amount::from_sat(invoice.amt_paid_sat), + }) { + tracing::error!(%trader_pubkey, r_hash = invoice.r_hash, "Failed to send payment received event to app. Error: {e:#}") } + continue 'watch_invoice; } - } + }, Ok(None) => { tracing::error!(%trader_pubkey, r_hash, "Websocket sender died."); break 'watch_invoice; diff --git a/coordinator/src/routes.rs b/coordinator/src/routes.rs index c78cc0bed..ff6cc5d01 100644 --- a/coordinator/src/routes.rs +++ b/coordinator/src/routes.rs @@ -552,6 +552,7 @@ fn parse_offset_datetime(date_str: String) -> Result> { Ok(Some(date_time)) } +#[instrument(skip_all, err(Debug))] pub async fn get_leaderboard( State(state): State>, params: Query, @@ -593,6 +594,7 @@ pub async fn get_leaderboard( })) } +#[instrument(skip_all, err(Debug))] async fn post_error( State(state): State>, app_error: Json, @@ -608,6 +610,7 @@ async fn post_error( Ok(()) } +#[instrument(skip_all, err(Debug))] async fn create_invoice( State(state): State>, Json(invoice_params): Json>, @@ -630,7 +633,11 @@ async fn create_invoice( .map_err(|e| AppError::InternalServerError(format!("{e:#}")))?; // watch for the created hodl invoice - invoice::spawn_invoice_watch(state.lnd_bridge.clone(), invoice_params); + invoice::spawn_invoice_watch( + state.tx_orderbook_feed.clone(), + state.lnd_bridge.clone(), + invoice_params, + ); Ok(Json(response.payment_request)) } diff --git a/crates/tests-e2e/src/test_subscriber.rs b/crates/tests-e2e/src/test_subscriber.rs index e4bbba466..7bd5c9ef4 100644 --- a/crates/tests-e2e/src/test_subscriber.rs +++ b/crates/tests-e2e/src/test_subscriber.rs @@ -194,6 +194,9 @@ impl Senders { native::event::EventInternal::FundingChannelNotification(_) => { // ignored } + native::event::EventInternal::PaymentReceived => { + // ignored + } } Ok(()) } diff --git a/crates/xxi-node/Cargo.toml b/crates/xxi-node/Cargo.toml index 47b1b3e88..6eaf5d015 100644 --- a/crates/xxi-node/Cargo.toml +++ b/crates/xxi-node/Cargo.toml @@ -10,6 +10,7 @@ description = "A common library for the 10101 node" anyhow = { version = "1", features = ["backtrace"] } async-trait = "0.1.71" axum = { version = "0.6", features = ["ws"], optional = true } +base64 = "0.22.1" bdk = { version = "1.0.0-alpha.6", features = ["std"] } bdk_coin_select = "0.2.0" bdk_esplora = { version = "0.8.0" } diff --git a/crates/xxi-node/src/commons/message.rs b/crates/xxi-node/src/commons/message.rs index 05e285b1d..6f993aa80 100644 --- a/crates/xxi-node/src/commons/message.rs +++ b/crates/xxi-node/src/commons/message.rs @@ -41,6 +41,11 @@ pub enum Message { order_id: Uuid, error: TradingError, }, + PaymentReceived { + r_hash: String, + #[serde(with = "bitcoin::amount::serde::as_sat")] + amount: Amount, + }, RolloverError { error: TradingError, }, @@ -123,6 +128,9 @@ impl Display for Message { Message::RolloverError { .. } => { write!(f, "RolloverError") } + Message::PaymentReceived { .. } => { + write!(f, "PaymentReceived") + } } } } diff --git a/crates/xxi-node/src/commons/pre_image.rs b/crates/xxi-node/src/commons/pre_image.rs index 4ebc6e02f..4e7b94c61 100644 --- a/crates/xxi-node/src/commons/pre_image.rs +++ b/crates/xxi-node/src/commons/pre_image.rs @@ -1,3 +1,5 @@ +use base64::engine::general_purpose; +use base64::Engine; use rand::Rng; use sha256::digest; @@ -6,6 +8,12 @@ pub struct PreImage { pub hash: String, } +impl PreImage { + pub fn get_base64_encoded_pre_image(&self) -> String { + general_purpose::URL_SAFE.encode(self.pre_image) + } +} + pub fn create_pre_image() -> PreImage { let pre_image = inner_create_pre_image(); let hash = inner_hash_pre_image(&pre_image); diff --git a/mobile/lib/common/routes.dart b/mobile/lib/common/routes.dart index ba873a019..6868b998e 100644 --- a/mobile/lib/common/routes.dart +++ b/mobile/lib/common/routes.dart @@ -1,31 +1,32 @@ import 'package:flutter/material.dart'; +import 'package:get_10101/common/background_task_dialog_screen.dart'; import 'package:get_10101/common/global_keys.dart'; +import 'package:get_10101/common/scaffold_with_nav_bar.dart'; +import 'package:get_10101/common/settings/app_info_screen.dart'; import 'package:get_10101/common/settings/channel_screen.dart'; +import 'package:get_10101/common/settings/collab_close_screen.dart'; import 'package:get_10101/common/settings/emergency_kit_screen.dart'; +import 'package:get_10101/common/settings/force_close_screen.dart'; +import 'package:get_10101/common/settings/seed_screen.dart'; +import 'package:get_10101/common/settings/settings_screen.dart'; +import 'package:get_10101/common/settings/share_logs_screen.dart'; import 'package:get_10101/common/settings/user_screen.dart'; import 'package:get_10101/common/settings/wallet_settings.dart'; import 'package:get_10101/common/status_screen.dart'; -import 'package:get_10101/common/background_task_dialog_screen.dart'; +import 'package:get_10101/features/trade/application/order_service.dart'; import 'package:get_10101/features/trade/channel_creation_flow/channel_configuration_screen.dart'; import 'package:get_10101/features/trade/channel_creation_flow/channel_funding_screen.dart'; +import 'package:get_10101/features/trade/trade_screen.dart'; import 'package:get_10101/features/wallet/domain/destination.dart'; +import 'package:get_10101/features/wallet/domain/wallet_type.dart'; +import 'package:get_10101/features/wallet/receive_screen.dart'; +import 'package:get_10101/features/wallet/scanner_screen.dart'; import 'package:get_10101/features/wallet/send/send_onchain_screen.dart'; +import 'package:get_10101/features/wallet/wallet_screen.dart'; import 'package:get_10101/features/welcome/error_screen.dart'; import 'package:get_10101/features/welcome/loading_screen.dart'; -import 'package:get_10101/common/scaffold_with_nav_bar.dart'; -import 'package:get_10101/common/settings/app_info_screen.dart'; -import 'package:get_10101/common/settings/collab_close_screen.dart'; -import 'package:get_10101/common/settings/force_close_screen.dart'; -import 'package:get_10101/common/settings/settings_screen.dart'; -import 'package:get_10101/common/settings/share_logs_screen.dart'; import 'package:get_10101/features/welcome/onboarding.dart'; -import 'package:get_10101/features/trade/trade_screen.dart'; -import 'package:get_10101/features/wallet/domain/wallet_type.dart'; -import 'package:get_10101/features/wallet/receive_screen.dart'; -import 'package:get_10101/features/wallet/scanner_screen.dart'; import 'package:get_10101/features/welcome/seed_import_screen.dart'; -import 'package:get_10101/common/settings/seed_screen.dart'; -import 'package:get_10101/features/wallet/wallet_screen.dart'; import 'package:get_10101/features/welcome/welcome_screen.dart'; import 'package:go_router/go_router.dart'; @@ -242,7 +243,7 @@ GoRouter createRoutes() { final data = state.extra! as Map; return ChannelFundingScreen( amount: data["amount"], - address: data["address"], + funding: data["funding"] as ExternalFunding, ); }, routes: const [], diff --git a/mobile/lib/features/trade/application/order_service.dart b/mobile/lib/features/trade/application/order_service.dart index 3b7890074..757c2a49f 100644 --- a/mobile/lib/features/trade/application/order_service.dart +++ b/mobile/lib/features/trade/application/order_service.dart @@ -5,6 +5,18 @@ import 'package:get_10101/features/trade/domain/leverage.dart'; import 'package:get_10101/features/trade/domain/order.dart'; import 'package:get_10101/ffi.dart' as rust; +class ExternalFunding { + final String bitcoinAddress; + final String paymentRequest; + + const ExternalFunding({required this.bitcoinAddress, required this.paymentRequest}); + + static ExternalFunding fromApi(rust.ExternalFunding funding) { + return ExternalFunding( + bitcoinAddress: funding.bitcoinAddress, paymentRequest: funding.paymentRequest); + } +} + class OrderService { Future submitMarketOrder(Leverage leverage, Usd quantity, ContractSymbol contractSymbol, Direction direction, bool stable) async { @@ -43,7 +55,7 @@ class OrderService { // starts a process to watch for funding an address before creating the order // returns the address to watch for - Future submitUnfundedChannelOpeningMarketOrder( + Future submitUnfundedChannelOpeningMarketOrder( Leverage leverage, Usd quantity, ContractSymbol contractSymbol, @@ -60,15 +72,13 @@ class OrderService { orderType: const rust.OrderType.market(), stable: stable); - var address = await rust.api.getNewAddress(); - - await rust.api.submitUnfundedChannelOpeningOrder( - fundingAddress: address, + final funding = await rust.api.submitUnfundedChannelOpeningOrder( order: order, coordinatorReserve: coordinatorReserve.sats, traderReserve: traderReserve.sats, estimatedMargin: margin.sats); - return address; + + return ExternalFunding.fromApi(funding); } Future> fetchOrders() async { diff --git a/mobile/lib/features/trade/channel_creation_flow/channel_configuration_screen.dart b/mobile/lib/features/trade/channel_creation_flow/channel_configuration_screen.dart index 6cea7d137..994575f23 100644 --- a/mobile/lib/features/trade/channel_creation_flow/channel_configuration_screen.dart +++ b/mobile/lib/features/trade/channel_creation_flow/channel_configuration_screen.dart @@ -9,6 +9,7 @@ import 'package:get_10101/common/dlc_channel_service.dart'; import 'package:get_10101/common/domain/model.dart'; import 'package:get_10101/common/snack_bar.dart'; import 'package:get_10101/common/value_data_row.dart'; +import 'package:get_10101/features/trade/application/order_service.dart'; import 'package:get_10101/features/trade/channel_creation_flow/channel_funding_screen.dart'; import 'package:get_10101/features/trade/channel_creation_flow/custom_framed_container.dart'; import 'package:get_10101/features/trade/channel_creation_flow/fee_expansion_widget.dart'; @@ -419,9 +420,9 @@ class _ChannelConfiguration extends State { ChannelOpeningParams( coordinatorReserve: counterpartyCollateral, traderReserve: ownTotalCollateral)) - .then((address) { + .then((ExternalFunding funding) { GoRouter.of(context).push(ChannelFundingScreen.route, extra: { - "address": address, + "funding": funding, "amount": totalAmountToBeFunded }); }).onError((error, stackTrace) { diff --git a/mobile/lib/features/trade/channel_creation_flow/channel_funding_screen.dart b/mobile/lib/features/trade/channel_creation_flow/channel_funding_screen.dart index 1c40ed379..761b1346d 100644 --- a/mobile/lib/features/trade/channel_creation_flow/channel_funding_screen.dart +++ b/mobile/lib/features/trade/channel_creation_flow/channel_funding_screen.dart @@ -8,6 +8,7 @@ import 'package:get_10101/common/domain/funding_channel_task.dart'; import 'package:get_10101/common/domain/model.dart'; import 'package:get_10101/common/funding_channel_task_change_notifier.dart'; import 'package:get_10101/common/snack_bar.dart'; +import 'package:get_10101/features/trade/application/order_service.dart'; import 'package:get_10101/features/trade/channel_creation_flow/channel_configuration_screen.dart'; import 'package:get_10101/features/trade/trade_screen.dart'; import 'package:go_router/go_router.dart'; @@ -20,19 +21,19 @@ class ChannelFundingScreen extends StatelessWidget { static const route = "${ChannelConfigurationScreen.route}/$subRouteName"; static const subRouteName = "fund_tx"; final Amount amount; - final String address; + final ExternalFunding funding; const ChannelFundingScreen({ super.key, required this.amount, - required this.address, + required this.funding, }); @override Widget build(BuildContext context) { return ChannelFunding( amount: amount, - address: address, + funding: funding, ); } } @@ -46,9 +47,9 @@ enum FundingType { class ChannelFunding extends StatefulWidget { final Amount amount; - final String address; + final ExternalFunding funding; - const ChannelFunding({super.key, required this.amount, required this.address}); + const ChannelFunding({super.key, required this.amount, required this.funding}); @override State createState() => _ChannelFunding(); @@ -59,27 +60,26 @@ class _ChannelFunding extends State { @override Widget build(BuildContext context) { - String address = widget.address; - // TODO: creating a bip21 qr code should be generic once we support other desposit methods - String qcCodeContent = "bitcoin:$address?amount=${widget.amount.btc.toString()}"; - - var qrCode = CustomQrCode( - data: qcCodeContent, - embeddedImage: const AssetImage("assets/10101_logo_icon_white_background.png"), - dimension: 300, - ); - - if (selectedBox != FundingType.onchain) { - qcCodeContent = "Follow us on Twitter for news: @get10101"; - - qrCode = CustomQrCode( - data: qcCodeContent, - embeddedImage: const AssetImage("assets/coming_soon.png"), - embeddedImageSizeHeight: 350, - embeddedImageSizeWidth: 350, - dimension: 300, - ); - } + final qrCode = switch (selectedBox) { + FundingType.lightning => CustomQrCode( + data: widget.funding.paymentRequest, + embeddedImage: const AssetImage("assets/10101_logo_icon_white_background.png"), + dimension: 300, + ), + FundingType.onchain => CustomQrCode( + // TODO: creating a bip21 qr code should be generic once we support other desposit methods + data: "bitcoin:${widget.funding.bitcoinAddress}?amount=${widget.amount.btc.toString()}", + embeddedImage: const AssetImage("assets/10101_logo_icon_white_background.png"), + dimension: 300, + ), + FundingType.unified || FundingType.external => const CustomQrCode( + data: "https://x.com/get10101", + embeddedImage: AssetImage("assets/coming_soon.png"), + embeddedImageSizeHeight: 350, + embeddedImageSizeWidth: 350, + dimension: 300, + ) + }; return Scaffold( body: SafeArea( @@ -137,7 +137,7 @@ class _ChannelFunding extends State { decoration: BoxDecoration( color: Colors.grey.shade100, border: Border.all(color: Colors.grey, width: 1), - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(10), shape: BoxShape.rectangle, ), child: Center( @@ -161,9 +161,9 @@ class _ChannelFunding extends State { ), GestureDetector( onTap: () { - Clipboard.setData(ClipboardData(text: qcCodeContent)).then((_) { - showSnackBar(ScaffoldMessenger.of(context), - "Address copied: $qcCodeContent"); + Clipboard.setData(ClipboardData(text: qrCode.data)).then((_) { + showSnackBar( + ScaffoldMessenger.of(context), "Copied: ${qrCode.data}"); }); }, child: Padding( @@ -179,13 +179,13 @@ class _ChannelFunding extends State { padding: const EdgeInsets.only(left: 10.0, right: 10.0), child: GestureDetector( onTap: () { - Clipboard.setData(ClipboardData(text: address)).then((_) { + Clipboard.setData(ClipboardData(text: qrCode.data)).then((_) { showSnackBar(ScaffoldMessenger.of(context), - "Address copied: $address"); + "Copied: ${qrCode.data}"); }); }, child: Text( - address, + qrCode.data, style: const TextStyle(fontSize: 14), textAlign: TextAlign.center, maxLines: 1, diff --git a/mobile/lib/features/trade/submit_order_change_notifier.dart b/mobile/lib/features/trade/submit_order_change_notifier.dart index 98b8ff82a..62c7bd3fd 100644 --- a/mobile/lib/features/trade/submit_order_change_notifier.dart +++ b/mobile/lib/features/trade/submit_order_change_notifier.dart @@ -1,16 +1,16 @@ import 'dart:math'; -import 'package:flutter_rust_bridge/flutter_rust_bridge.dart'; -import 'package:get_10101/features/trade/domain/channel_opening_params.dart'; -import 'package:get_10101/features/trade/domain/leverage.dart'; -import 'package:get_10101/logger/logger.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge.dart'; import 'package:get_10101/common/domain/model.dart'; import 'package:get_10101/features/trade/application/order_service.dart'; import 'package:get_10101/features/trade/application/trade_values_service.dart'; +import 'package:get_10101/features/trade/domain/channel_opening_params.dart'; import 'package:get_10101/features/trade/domain/contract_symbol.dart'; +import 'package:get_10101/features/trade/domain/leverage.dart'; import 'package:get_10101/features/trade/domain/position.dart'; import 'package:get_10101/features/trade/domain/trade_values.dart'; +import 'package:get_10101/logger/logger.dart'; class SubmitOrderChangeNotifier extends ChangeNotifier { final OrderService orderService; @@ -46,7 +46,7 @@ class SubmitOrderChangeNotifier extends ChangeNotifier { } } - Future submitUnfundedOrder( + Future submitUnfundedOrder( TradeValues tradeValues, ChannelOpeningParams channelOpeningParams) async { try { // TODO(holzeis): The coordinator leverage should not be hard coded here. diff --git a/mobile/native/src/api.rs b/mobile/native/src/api.rs index 8161dd7e9..801f0889f 100644 --- a/mobile/native/src/api.rs +++ b/mobile/native/src/api.rs @@ -18,27 +18,29 @@ use crate::event; use crate::event::api::FlutterSubscriber; use crate::event::BackgroundTask; use crate::event::EventInternal; +use crate::event::FundingChannelTask; use crate::event::TaskStatus; use crate::health; use crate::hodl_invoice; use crate::logger; use crate::max_quantity::max_quantity; use crate::polls; +use crate::state::get_node; use crate::trade::order; use crate::trade::order::api::NewOrder; use crate::trade::order::api::Order; use crate::trade::position; use crate::trade::position::api::Position; use crate::trade::users; -use crate::unfunded_orders; +use crate::watcher; use anyhow::ensure; use anyhow::Context; use anyhow::Result; use bdk::FeeRate; -use bitcoin::Address; use bitcoin::Amount; use flutter_rust_bridge::StreamSink; use flutter_rust_bridge::SyncReturn; +use futures::FutureExt; use lightning::chain::chaininterface::ConfirmationTarget as LnConfirmationTarget; use rust_decimal::prelude::FromPrimitive; use rust_decimal::prelude::ToPrimitive; @@ -47,7 +49,6 @@ use std::backtrace::Backtrace; use std::fmt; use std::path::Path; use std::path::PathBuf; -use std::str::FromStr; use std::time::Duration; use time::OffsetDateTime; use tokio::sync::broadcast; @@ -904,29 +905,88 @@ pub fn has_traded_once() -> Result> { Ok(SyncReturn(!db::get_all_trades()?.is_empty())) } +pub struct ExternalFunding { + pub bitcoin_address: String, + pub payment_request: String, +} + #[tokio::main(flavor = "current_thread")] pub async fn submit_unfunded_channel_opening_order( - funding_address: String, order: NewOrder, coordinator_reserve: u64, trader_reserve: u64, estimated_margin: u64, -) -> Result<()> { - let funding_address = Address::from_str(funding_address.as_str())?.assume_checked(); - - unfunded_orders::submit_unfunded_wallet_channel_opening_order( - funding_address, - order, - coordinator_reserve, - trader_reserve, - estimated_margin + trader_reserve, - ) - .await?; +) -> Result { + let node = get_node(); + let bitcoin_address = node.inner.get_new_address()?; + let funding_amount = Amount::from_sat(estimated_margin + trader_reserve); + let hodl_invoice = hodl_invoice::get_hodl_invoice_from_coordinator(funding_amount).await?; - Ok(()) -} + let runtime = crate::state::get_or_create_tokio_runtime()?; + let (future, remote_handle) = runtime.spawn({ + let bitcoin_address = bitcoin_address.clone(); + async move { + event::publish(&EventInternal::FundingChannelNotification( + FundingChannelTask::Pending, + )); + + // we must only create the order on either event. If the bitcoin address is funded we cancel the watch for the lightning invoice and vice versa. + tokio::select! { + _ = watcher::watch_funding_address(bitcoin_address.clone(), funding_amount) => { + // received bitcoin payment. + tracing::info!(%bitcoin_address, %funding_amount, "Found funding amount on bitcoin address.") + } + _ = watcher::watch_lightning_payment() => { + // received lightning payment. + tracing::info!(%funding_amount, "Found lighting payment.") + } + } -#[tokio::main(flavor = "current_thread")] -pub async fn get_hodl_invoice_from_coordinator(amount: u64) -> Result { - hodl_invoice::get_hodl_invoice_from_coordinator(Amount::from_sat(amount)).await + event::publish(&EventInternal::FundingChannelNotification( + FundingChannelTask::Funded, + )); + + tracing::debug!( + coordinator_reserve, + %funding_amount, + "Creating new order with values {order:?}" + ); + + match order::handler::submit_order( + order.into(), + Some(ChannelOpeningParams { + coordinator_reserve: Amount::from_sat(coordinator_reserve), + trader_reserve: Amount::from_sat(trader_reserve), + }), + ) + .await + .map_err(anyhow::Error::new) + .map(|id| id.to_string()) + { + Ok(order_id) => { + tracing::info!(order_id, "Order created"); + event::publish(&EventInternal::FundingChannelNotification( + FundingChannelTask::OrderCreated(order_id), + )); + } + Err(error) => { + tracing::error!("Failed at submitting order {error:?}"); + event::publish(&EventInternal::FundingChannelNotification( + FundingChannelTask::Failed("Failed at posting the order".to_string()), + )); + } + }; + } + }).remote_handle(); + + // We need to store the handle which will drop any old handler if present. + node.watcher_handle.lock().replace(remote_handle); + + // Only now we can spawn the future, as otherwise we might have two competing handlers + runtime.spawn(future); + + Ok(ExternalFunding { + bitcoin_address: bitcoin_address.to_string(), + payment_request: hodl_invoice.payment_request, + }) } diff --git a/mobile/native/src/backup.rs b/mobile/native/src/backup.rs index b246d68ce..d69dc5d6e 100644 --- a/mobile/native/src/backup.rs +++ b/mobile/native/src/backup.rs @@ -67,9 +67,6 @@ impl Subscriber for DBBackupSubscriber { fn events(&self) -> Vec { vec![ - EventType::PaymentClaimed, - EventType::PaymentSent, - EventType::PaymentFailed, EventType::PositionUpdateNotification, EventType::PositionClosedNotification, EventType::OrderUpdateNotification, diff --git a/mobile/native/src/dlc/node.rs b/mobile/native/src/dlc/node.rs index 4066d03e2..9a2b9bb06 100644 --- a/mobile/native/src/dlc/node.rs +++ b/mobile/native/src/dlc/node.rs @@ -31,6 +31,7 @@ use std::collections::HashSet; use std::sync::Arc; use std::time::Duration; use time::OffsetDateTime; +use tokio::task::JoinError; use tracing::instrument; use uuid::Uuid; use xxi_node::bitcoin_conversion::to_secp_pk_30; @@ -76,7 +77,8 @@ pub struct Node { // good enough pub pending_usdp_invoices: Arc>>, - pub unfunded_order_handle: Arc>>>, + #[allow(clippy::type_complexity)] + pub watcher_handle: Arc>>>>, } impl Node { @@ -94,7 +96,7 @@ impl Node { inner: node, _running: Arc::new(running), pending_usdp_invoices: Arc::new(Default::default()), - unfunded_order_handle: Arc::new(Default::default()), + watcher_handle: Arc::new(Default::default()), } } } diff --git a/mobile/native/src/event/api.rs b/mobile/native/src/event/api.rs index 004a484c1..502f9f1c5 100644 --- a/mobile/native/src/event/api.rs +++ b/mobile/native/src/event/api.rs @@ -31,6 +31,8 @@ pub enum Event { Authenticated(TenTenOneConfig), DlcChannelEvent(DlcChannel), FundingChannelNotification(FundingChannelTask), + // TODO(holzeis): Add payload r_hash and amount. + PaymentReceived, } #[frb] @@ -92,6 +94,7 @@ impl From for Event { EventInternal::FundingChannelNotification(status) => { Event::FundingChannelNotification(status.into()) } + EventInternal::PaymentReceived => Event::PaymentReceived, } } } @@ -130,9 +133,7 @@ impl Subscriber for FlutterSubscriber { EventType::ChannelStatusUpdate, EventType::BackgroundNotification, EventType::FundingChannelNotification, - EventType::PaymentClaimed, - EventType::PaymentSent, - EventType::PaymentFailed, + EventType::PaymentReceived, EventType::Authenticated, EventType::DlcChannelEvent, ] diff --git a/mobile/native/src/event/mod.rs b/mobile/native/src/event/mod.rs index ac9d09253..54a296c48 100644 --- a/mobile/native/src/event/mod.rs +++ b/mobile/native/src/event/mod.rs @@ -40,6 +40,7 @@ pub enum EventInternal { SpendableOutputs, DlcChannelEvent(DlcChannel), FundingChannelNotification(FundingChannelTask), + PaymentReceived, } #[derive(Clone, Debug)] @@ -85,6 +86,7 @@ impl fmt::Display for EventInternal { EventInternal::AskPriceUpdateNotification(_) => "AskPriceUpdateNotification", EventInternal::BidPriceUpdateNotification(_) => "BidPriceUpdateNotification", EventInternal::FundingChannelNotification(_) => "FundingChannelNotification", + EventInternal::PaymentReceived => "PaymentReceived", } .fmt(f) } @@ -109,6 +111,7 @@ impl From for EventType { EventInternal::AskPriceUpdateNotification(_) => EventType::AskPriceUpdateNotification, EventInternal::BidPriceUpdateNotification(_) => EventType::BidPriceUpdateNotification, EventInternal::FundingChannelNotification(_) => EventType::FundingChannelNotification, + EventInternal::PaymentReceived => EventType::PaymentReceived, } } } @@ -123,9 +126,7 @@ pub enum EventType { PositionUpdateNotification, PositionClosedNotification, ChannelReady, - PaymentClaimed, - PaymentSent, - PaymentFailed, + PaymentReceived, ServiceHealthUpdate, ChannelStatusUpdate, BackgroundNotification, diff --git a/mobile/native/src/hodl_invoice.rs b/mobile/native/src/hodl_invoice.rs index 83727d42b..bf5c75a4b 100644 --- a/mobile/native/src/hodl_invoice.rs +++ b/mobile/native/src/hodl_invoice.rs @@ -7,8 +7,14 @@ use bitcoin::Amount; use reqwest::Url; use xxi_node::commons; -pub async fn get_hodl_invoice_from_coordinator(amount: Amount) -> Result { - // TODO: store the preimage in the node so that we can remember it +pub struct HodlInvoice { + pub payment_request: String, + pub pre_image: String, + pub r_hash: String, + pub amt_sats: Amount, +} + +pub async fn get_hodl_invoice_from_coordinator(amount: Amount) -> Result { let pre_image = commons::create_pre_image(); let client = reqwest_client(); @@ -19,7 +25,7 @@ pub async fn get_hodl_invoice_from_coordinator(amount: Amount) -> Result let invoice_params = commons::HodlInvoiceParams { trader_pubkey: get_node_pubkey(), amt_sats: amount.to_sat(), - r_hash: pre_image.hash, + r_hash: pre_image.hash.clone(), }; let invoice_params = commons::SignedValue::new(invoice_params, get_node_key())?; @@ -31,5 +37,11 @@ pub async fn get_hodl_invoice_from_coordinator(amount: Amount) -> Result .error_for_status()?; let payment_request = response.json::().await?; - Ok(payment_request) + let hodl_invoice = HodlInvoice { + payment_request, + pre_image: pre_image.get_base64_encoded_pre_image(), + r_hash: pre_image.hash, + amt_sats: Default::default(), + }; + Ok(hodl_invoice) } diff --git a/mobile/native/src/lib.rs b/mobile/native/src/lib.rs index 9721cabc3..956a53bf2 100644 --- a/mobile/native/src/lib.rs +++ b/mobile/native/src/lib.rs @@ -12,7 +12,7 @@ pub mod logger; pub mod schema; pub mod state; pub mod trade; -pub mod unfunded_orders; +pub mod watcher; mod backup; mod cipher; diff --git a/mobile/native/src/orderbook.rs b/mobile/native/src/orderbook.rs index a64b5a9cf..b5e54f5ae 100644 --- a/mobile/native/src/orderbook.rs +++ b/mobile/native/src/orderbook.rs @@ -284,6 +284,10 @@ async fn handle_orderbook_message( BackgroundTask::Rollover(TaskStatus::Failed(format!("{error:#}"))), )); } + Message::PaymentReceived { r_hash, amount } => { + tracing::info!(r_hash, %amount, "Received a payment received event."); + event::publish(&EventInternal::PaymentReceived) + } }; Ok(()) diff --git a/mobile/native/src/unfunded_orders.rs b/mobile/native/src/unfunded_orders.rs deleted file mode 100644 index e099a48da..000000000 --- a/mobile/native/src/unfunded_orders.rs +++ /dev/null @@ -1,117 +0,0 @@ -use crate::event; -use crate::event::EventInternal; -use crate::event::FundingChannelTask; -use crate::state; -use crate::trade::order; -use crate::trade::order::api::NewOrder; -use anyhow::Result; -use bitcoin::Address; -use bitcoin::Amount; -use futures::FutureExt; -use std::time::Duration; -use xxi_node::commons::ChannelOpeningParams; - -pub(crate) async fn submit_unfunded_wallet_channel_opening_order( - funding_address: Address, - new_order: NewOrder, - coordinator_reserve: u64, - trader_reserve: u64, - needed_channel_size: u64, -) -> Result<()> { - let node = state::get_node().clone(); - let bdk_node = node.inner.clone(); - event::publish(&EventInternal::FundingChannelNotification( - FundingChannelTask::Pending, - )); - let runtime = crate::state::get_or_create_tokio_runtime()?; - let (future, remote_handle) = async move { - loop { - match bdk_node.get_unspent_txs(&funding_address).await { - Ok(ref v) if v.is_empty() => { - tracing::debug!( - address = funding_address.to_string(), - amount = needed_channel_size.to_string(), - "No tx found for address" - ); - } - Ok(txs) => { - // we sum up the total value in this output and check if it is big enough - // for the order - let total_unspent_amount_received = txs - .into_iter() - .map(|(_, amount)| amount.to_sat()) - .sum::(); - - if total_unspent_amount_received >= needed_channel_size { - tracing::info!( - amount = total_unspent_amount_received.to_string(), - address = funding_address.to_string(), - "Address has been funded enough" - ); - break; - } - tracing::debug!( - amount = total_unspent_amount_received.to_string(), - address = funding_address.to_string(), - "Address has not enough funds yet" - ); - } - Err(err) => { - tracing::error!("Could not get utxo for address {err:?}") - } - } - tokio::time::sleep(Duration::from_secs(10)).await; - } - - event::publish(&EventInternal::FundingChannelNotification( - FundingChannelTask::Funded, - )); - - if let Err(error) = bdk_node.sync_on_chain_wallet().await { - tracing::error!("Failed at syncing wallet {error:?}") - } - - let balance = bdk_node.get_on_chain_balance(); - tracing::debug!(balance = balance.to_string(), "Wallet synced"); - - tracing::debug!( - coordinator_reserve, - needed_channel_size, - "Creating new order with values {new_order:?}" - ); - - match order::handler::submit_order( - new_order.into(), - Some(ChannelOpeningParams { - coordinator_reserve: Amount::from_sat(coordinator_reserve), - trader_reserve: Amount::from_sat(trader_reserve), - }), - ) - .await - .map_err(anyhow::Error::new) - .map(|id| id.to_string()) - { - Ok(order_id) => { - tracing::info!(order_id, "Order created"); - event::publish(&EventInternal::FundingChannelNotification( - FundingChannelTask::OrderCreated(order_id), - )); - } - Err(error) => { - tracing::error!("Failed at submitting order {error:?}"); - event::publish(&EventInternal::FundingChannelNotification( - FundingChannelTask::Failed("Failed at posting the order".to_string()), - )); - } - } - } - .remote_handle(); - - // We need to store the handle which will drop any old handler if present. - node.unfunded_order_handle.lock().replace(remote_handle); - - // Only now we can spawn the future, as otherwise we might have two competing handlers - runtime.spawn(future); - - Ok(()) -} diff --git a/mobile/native/src/watcher.rs b/mobile/native/src/watcher.rs new file mode 100644 index 000000000..1e4312646 --- /dev/null +++ b/mobile/native/src/watcher.rs @@ -0,0 +1,88 @@ +use crate::event; +use crate::event::subscriber::Subscriber; +use crate::event::EventInternal; +use crate::event::EventType; +use crate::state; +use anyhow::Result; +use bitcoin::Address; +use bitcoin::Amount; +use std::time::Duration; +use tokio::sync::mpsc; + +#[derive(Clone)] +pub struct InvoiceWatcher { + sender: mpsc::Sender, +} + +impl Subscriber for InvoiceWatcher { + fn notify(&self, _: &EventInternal) { + tokio::spawn({ + let sender = self.sender.clone(); + async move { + if let Err(e) = sender.send(true).await { + tracing::error!("Failed to send accepted invoice event. Error: {e:#}"); + } + } + }); + } + + fn events(&self) -> Vec { + vec![EventType::PaymentReceived] + } +} + +pub(crate) async fn watch_lightning_payment() -> Result<()> { + let (sender, mut receiver) = mpsc::channel::(1); + event::subscribe(InvoiceWatcher { sender }); + + receiver.recv().await; + + Ok(()) +} + +/// Watches for the funding address to receive the given amount. +pub(crate) async fn watch_funding_address( + funding_address: Address, + funding_amount: Amount, +) -> Result<()> { + let node = state::get_node().clone(); + let bdk_node = node.inner.clone(); + + 'watch_address: loop { + match bdk_node.get_unspent_txs(&funding_address).await { + Ok(ref v) if v.is_empty() => { + tracing::debug!(%funding_address, %funding_amount, "No tx found for address"); + } + Ok(txs) => { + // we sum up the total value in this output and check if it is big enough + // for the order + let total_unspent_amount_received = txs + .into_iter() + .map(|(_, amount)| amount.to_sat()) + .sum::(); + + if total_unspent_amount_received >= funding_amount.to_sat() { + tracing::info!( + amount = total_unspent_amount_received.to_string(), + address = funding_address.to_string(), + "Address has been funded enough" + ); + + break 'watch_address; + } + tracing::debug!( + amount = total_unspent_amount_received.to_string(), + address = funding_address.to_string(), + "Address has not enough funds yet" + ); + } + Err(err) => { + tracing::error!("Could not get utxo for address {err:?}"); + break 'watch_address; + } + } + tokio::time::sleep(Duration::from_secs(10)).await; + } + + Ok(()) +}