From d8ee300a34100625fd1cf3c4f47bc9b127d29ee2 Mon Sep 17 00:00:00 2001 From: Lucas Soriano del Pino Date: Tue, 15 Aug 2023 12:48:16 +1000 Subject: [PATCH 1/2] feat: Display channel status in app There are many reasons why the app might end up with a channel (vanilla or split) closed on-chain, particularly at this stage of the project. As such we want to give some visibility to the app user, so that they know what to expect if they run into this situation. The main objective is to prevent loss of funds, something that could happen if the user deletes the app data before certain transactions have been confirmed. Interestingly, for the DLC channel branch of the split channel, the user could choose to trust 10101, as the coordinator will eventually publish the correct CET, partially paying to the user's on-chain wallet. The LN branch is more dangerous, as the user must wait for the funds to be claimed from the commitment transaction by their own LN node. Since we currently do not back up this data, deleting the app data at the wrong time could lead to loss of funds. The main limitation of this patch, captured in a TODO, is that we currently do not model the LN _closing_ channel state in our shadow channel representation. This means that we will end up telling the user that the LN channel is closed before it is fully closed, which is dangerous. The user can also use the channel status information to know if they are able to open a new channel _with the same app_. If the channel status is `NotOpen` they can create a new JIT channel. This information is useful because they will know that they can continue using 10101 whilst their previous channel might still be being closed in the background. Also: - Register new incoming JIT channels with a `UserChannelId`. This is important for this patch because otherwise we cannot identify the channel, which prevented us from marking it as closed in its shadow representation. --- crates/ln-dlc-node/src/channel.rs | 30 ++- .../ln-dlc-node/src/ln/app_event_handler.rs | 4 +- crates/ln-dlc-node/src/ln/common_handlers.rs | 12 +- crates/tests-e2e/examples/fund.rs | 4 - crates/tests-e2e/src/test_subscriber.rs | 14 ++ mobile/lib/common/app_bar_wrapper.dart | 8 +- .../lib/common/channel_status_notifier.dart | 46 ++++ mobile/lib/common/status_screen.dart | 113 ++++++--- mobile/lib/main.dart | 6 +- mobile/native/src/event/api.rs | 4 + mobile/native/src/event/mod.rs | 14 +- mobile/native/src/health.rs | 1 + mobile/native/src/lib.rs | 8 +- mobile/native/src/ln_dlc/channel_status.rs | 228 ++++++++++++++++++ mobile/native/src/ln_dlc/mod.rs | 8 + 15 files changed, 449 insertions(+), 51 deletions(-) create mode 100644 mobile/lib/common/channel_status_notifier.dart create mode 100644 mobile/native/src/ln_dlc/channel_status.rs diff --git a/crates/ln-dlc-node/src/channel.rs b/crates/ln-dlc-node/src/channel.rs index ef87c5fe6..6d9a551c9 100644 --- a/crates/ln-dlc-node/src/channel.rs +++ b/crates/ln-dlc-node/src/channel.rs @@ -21,8 +21,13 @@ use uuid::Uuid; /// reporting purposes. #[derive(Debug, Clone, PartialEq)] pub struct Channel { - /// The `user_channel_id` is set by 10101 at the time the `Event::HTLCIntercepted` when - /// we are attempting to create a JIT channel. + /// Custom identifier for a channel which is generated outside of LDK. + /// + /// The coordinator sets it after receiving `Event::HTLCIntercepted` as a result of trying to + /// create a JIT channel. + /// + /// The app sets its own when calling `accept_inbound_channel_from_trusted_peer_0conf` when + /// accepting an inbound JIT channel from the coordinator. pub user_channel_id: UserChannelId, /// Until the `Event::ChannelReady` we do not have a `channel_id`, which is derived from /// the funding transaction. We use the `user_channel_id` as identifier over the entirety @@ -45,13 +50,14 @@ pub struct Channel { impl Channel { pub fn new( + user_channel_id: UserChannelId, inbound: u64, outbound: u64, counterparty: PublicKey, intercept_id: Option, ) -> Self { Channel { - user_channel_id: UserChannelId::new(), + user_channel_id, channel_state: ChannelState::Pending, inbound, outbound, @@ -123,10 +129,18 @@ impl Channel { let mut channel = match channel { Some(channel) => channel, None => { - let user_channel_id = - UserChannelId::from(channel_details.user_channel_id).to_string(); - tracing::warn!(%user_channel_id, channel_id = %channel_details.channel_id.to_hex(), public = channel_details.is_public, outbound = channel_details.is_outbound, "Cannot open non-existent shadow channel. Creating a new one."); + let user_channel_id = UserChannelId::from(channel_details.user_channel_id); + + tracing::info!( + user_channel_id = %user_channel_id.to_string(), + channel_id = %channel_details.channel_id.to_hex(), + public = channel_details.is_public, + outbound = channel_details.is_outbound, + "Cannot open non-existent shadow channel. Creating a new one." + ); + Channel::new( + user_channel_id, channel_details.inbound_capacity_msat, 0, channel_details.counterparty.node_id, @@ -153,6 +167,10 @@ impl Channel { #[derive(PartialEq, Debug, Clone)] pub enum ChannelState { /// Corresponds to a JIT channel which an app user has registered interest in opening. + /// + /// TODO: This enum is shared between all consumers, but this variant is only used by the + /// coordinator. This is problematic because all other consumers still have to handle this + /// unreachable variant. Announced, Pending, Open, diff --git a/crates/ln-dlc-node/src/ln/app_event_handler.rs b/crates/ln-dlc-node/src/ln/app_event_handler.rs index 1b5c47c8e..9c0355cdf 100644 --- a/crates/ln-dlc-node/src/ln/app_event_handler.rs +++ b/crates/ln-dlc-node/src/ln/app_event_handler.rs @@ -1,6 +1,7 @@ use super::common_handlers; use super::event_handler::EventSender; use super::event_handler::PendingInterceptedHtlcs; +use crate::channel::UserChannelId; use crate::node::ChannelManager; use crate::node::Node; use crate::node::Storage; @@ -226,11 +227,12 @@ pub(crate) fn handle_open_channel_request_0_conf( push_msat, "Accepting open channel request" ); + channel_manager .accept_inbound_channel_from_trusted_peer_0conf( &temporary_channel_id, &counterparty_node_id, - 0, + UserChannelId::new().to_u128(), ) .map_err(|e| anyhow!("{e:?}")) .context("To be able to accept a 0-conf channel")?; diff --git a/crates/ln-dlc-node/src/ln/common_handlers.rs b/crates/ln-dlc-node/src/ln/common_handlers.rs index b7ae1d031..f71ba6921 100644 --- a/crates/ln-dlc-node/src/ln/common_handlers.rs +++ b/crates/ln-dlc-node/src/ln/common_handlers.rs @@ -249,8 +249,16 @@ where } } - node.sub_channel_manager - .notify_ln_channel_closed(channel_id)?; + match node + .sub_channel_manager + .notify_ln_channel_closed(channel_id) + { + Ok(()) => {} + Err(dlc_manager::error::Error::InvalidParameters(msg)) => { + tracing::debug!("Irrelevant LDK closure notification: {msg}"); + } + e @ Err(_) => e.context("Failed to notify subchannel manager about LDK closure")?, + }; anyhow::Ok(()) })?; diff --git a/crates/tests-e2e/examples/fund.rs b/crates/tests-e2e/examples/fund.rs index 61e746f8b..f2b1ef697 100644 --- a/crates/tests-e2e/examples/fund.rs +++ b/crates/tests-e2e/examples/fund.rs @@ -40,10 +40,6 @@ async fn main() { } async fn fund_everything(faucet: &str, coordinator: &str) -> Result<()> { - // let node_info = get_node_info(faucet).await?; - // dbg!(node_info); - // return Ok(()); - let coordinator = Coordinator::new(init_reqwest(), coordinator); let coord_addr = coordinator.get_new_address().await?; fund(&coord_addr, Amount::ONE_BTC, faucet).await?; diff --git a/crates/tests-e2e/src/test_subscriber.rs b/crates/tests-e2e/src/test_subscriber.rs index eed5e3000..7d429e101 100644 --- a/crates/tests-e2e/src/test_subscriber.rs +++ b/crates/tests-e2e/src/test_subscriber.rs @@ -6,6 +6,7 @@ use native::event::EventType; use native::health::Service; use native::health::ServiceStatus; use native::health::ServiceUpdate; +use native::ln_dlc::ChannelStatus; use native::trade::order::Order; use native::trade::position::Position; use orderbook_commons::Prices; @@ -24,6 +25,7 @@ pub struct Senders { prices: watch::Sender>, position_close: watch::Sender>, service: watch::Sender>, + channel_status: watch::Sender>, } /// Subscribes to events destined for the frontend (typically Flutter app) and @@ -37,6 +39,7 @@ pub struct TestSubscriber { prices: watch::Receiver>, position_close: watch::Receiver>, services: Arc>>, + channel_status: watch::Receiver>, _service_map_updater: tokio::task::JoinHandle<()>, } @@ -50,6 +53,7 @@ impl TestSubscriber { let (prices_tx, prices_rx) = watch::channel(None); let (position_close_tx, position_close_rx) = watch::channel(None); let (service_tx, mut service_rx) = watch::channel(None); + let (channel_status_tx, channel_status_rx) = watch::channel(None); let senders = Senders { wallet_info: wallet_info_tx, @@ -60,6 +64,7 @@ impl TestSubscriber { prices: prices_tx, position_close: position_close_tx, service: service_tx, + channel_status: channel_status_tx, }; let services = Arc::new(Mutex::new(HashMap::new())); @@ -89,6 +94,7 @@ impl TestSubscriber { prices: prices_rx, position_close: position_close_rx, services, + channel_status: channel_status_rx, _service_map_updater, }; (subscriber, ThreadSafeSenders(Arc::new(Mutex::new(senders)))) @@ -130,6 +136,10 @@ impl TestSubscriber { .copied() .unwrap_or_default() } + + pub fn channel_status(&self) -> Option { + self.channel_status.borrow().as_ref().cloned() + } } impl Subscriber for Senders { @@ -148,6 +158,7 @@ impl Subscriber for Senders { EventType::PositionClosedNotification, EventType::PriceUpdateNotification, EventType::ServiceHealthUpdate, + EventType::ChannelStatusUpdate, ] } } @@ -183,6 +194,9 @@ impl Senders { native::event::EventInternal::ServiceHealthUpdate(update) => { self.service.send(Some(update.clone()))?; } + native::event::EventInternal::ChannelStatusUpdate(update) => { + self.channel_status.send(Some(*update))?; + } native::event::EventInternal::ChannelReady(_channel_id) => { unreachable!("ChannelReady event should not be sent to the subscriber"); } diff --git a/mobile/lib/common/app_bar_wrapper.dart b/mobile/lib/common/app_bar_wrapper.dart index 6e94fe762..12e967f1d 100644 --- a/mobile/lib/common/app_bar_wrapper.dart +++ b/mobile/lib/common/app_bar_wrapper.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:get_10101/common/channel_status_notifier.dart'; import 'package:get_10101/common/color.dart'; import 'package:get_10101/common/settings_screen.dart'; import 'package:get_10101/features/trade/trade_screen.dart'; import 'package:get_10101/features/wallet/wallet_screen.dart'; import 'package:get_10101/features/wallet/status_screen.dart'; import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; class AppBarWrapper extends StatelessWidget { const AppBarWrapper({ @@ -16,9 +18,13 @@ class AppBarWrapper extends StatelessWidget { final currentRoute = GoRouterState.of(context).location; const appBarHeight = 35.0; + ChannelStatusNotifier channelStatusNotifier = context.watch(); + var actionButtons = [ IconButton( - icon: const Icon(Icons.thermostat), + icon: channelStatusNotifier.isClosing() + ? const Icon(Icons.thermostat, color: Colors.red) + : const Icon(Icons.thermostat), tooltip: 'Status', onPressed: () { context.go(WalletStatusScreen.route); diff --git a/mobile/lib/common/channel_status_notifier.dart b/mobile/lib/common/channel_status_notifier.dart new file mode 100644 index 000000000..ba82d8bf4 --- /dev/null +++ b/mobile/lib/common/channel_status_notifier.dart @@ -0,0 +1,46 @@ +import 'package:f_logs/f_logs.dart'; +import 'package:flutter/material.dart'; +import 'package:get_10101/bridge_generated/bridge_definitions.dart' as bridge; +import 'package:get_10101/common/application/event_service.dart'; +import 'package:get_10101/ffi.dart'; + +/// Sends channel status notifications to subscribers. +/// +/// Subscribers can learn about the latest [bridge.ChannelStatus] of the LN-DLC channel. +class ChannelStatusNotifier extends ChangeNotifier implements Subscriber { + bridge.ChannelStatus latest = ChannelStatus.Unknown; + + ChannelStatusNotifier(); + + /// Get the latest status of the LN-DLC channel. + bridge.ChannelStatus getChannelStatus() { + return latest; + } + + /// Whether the current LN-DLC channel is closed or not. + bool isClosing() { + final status = getChannelStatus(); + + return status == ChannelStatus.LnDlcForceClosing; + } + + void subscribe(EventService eventService) { + eventService.subscribe(this, const bridge.Event.channelStatusUpdate(ChannelStatus.Unknown)); + } + + @override + + /// Handle events coming from the Rust backend. + /// + /// We only care about [bridge.Event_ChannelStatusUpdate], as they pertain to + /// the channel status. If we get a relevant event we update our state and + /// notify all listeners. + void notify(bridge.Event event) { + if (event is bridge.Event_ChannelStatusUpdate) { + FLog.debug(text: "Received channel status update: ${event.toString()}"); + latest = event.field0; + + notifyListeners(); + } + } +} diff --git a/mobile/lib/common/status_screen.dart b/mobile/lib/common/status_screen.dart index 0926cfeaa..50fb18b7d 100644 --- a/mobile/lib/common/status_screen.dart +++ b/mobile/lib/common/status_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get_10101/bridge_generated/bridge_definitions.dart'; +import 'package:get_10101/common/channel_status_notifier.dart'; import 'package:get_10101/common/scrollable_safe_area.dart'; import 'package:get_10101/common/service_status_notifier.dart'; import 'package:get_10101/common/value_data_row.dart'; @@ -20,39 +21,78 @@ class _StatusScreenState extends State { ServiceStatusNotifier serviceStatusNotifier = context.watch(); final orderbookStatus = - statusToString(serviceStatusNotifier.getServiceStatus(Service.Orderbook)); + serviceStatusToString(serviceStatusNotifier.getServiceStatus(Service.Orderbook)); final coordinatorStatus = - statusToString(serviceStatusNotifier.getServiceStatus(Service.Coordinator)); - final overallStatus = statusToString(serviceStatusNotifier.overall()); + serviceStatusToString(serviceStatusNotifier.getServiceStatus(Service.Coordinator)); + final overallStatus = serviceStatusToString(serviceStatusNotifier.overall()); + + ChannelStatusNotifier channelStatusNotifier = context.watch(); + + final channelStatus = channelStatusToString(channelStatusNotifier.getChannelStatus()); + + var widgets = [ + Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + children: [ + const SizedBox(height: 20), + ValueDataRow( + type: ValueType.text, + value: overallStatus, + label: "Services", + ), + const Divider(), + ValueDataRow( + type: ValueType.text, + value: orderbookStatus, + label: "Orderbook", + ), + const SizedBox(height: 10), + ValueDataRow( + type: ValueType.text, + value: coordinatorStatus, + label: "LSP", + ), + ], + )), + Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + children: [ + const SizedBox(height: 20), + ValueDataRow( + type: ValueType.text, + value: channelStatus, + label: "Channel status", + ), + ], + )), + ]; + + if (channelStatusNotifier.isClosing()) { + widgets.add(Padding( + padding: const EdgeInsets.all(32.0), + child: RichText( + text: const TextSpan(style: TextStyle(color: Colors.black, fontSize: 18), children: [ + TextSpan( + text: "Your channel with 10101 is being closed on-chain!\n\n", + style: TextStyle(fontWeight: FontWeight.bold)), + TextSpan( + text: + "Your Lightning funds will return back to your on-chain wallet after some time. You will have to reopen the app at some point in the future so that your node can claim them back.\n\n"), + TextSpan( + text: + "If you had a position open your payout will arrive in your on-chain wallet soon after the expiry time. \n") + ])))); + } return Scaffold( appBar: AppBar(title: const Text("Status")), body: ScrollableSafeArea( child: Center( - child: Padding( - padding: const EdgeInsets.all(32.0), - child: Column( - children: [ - const SizedBox(height: 20), - ValueDataRow( - type: ValueType.text, - value: overallStatus, - label: "App health", - ), - const Divider(), - ValueDataRow( - type: ValueType.text, - value: orderbookStatus, - label: "Orderbook", - ), - const SizedBox(height: 10), - ValueDataRow( - type: ValueType.text, - value: coordinatorStatus, - label: "LSP", - ), - ], - ))), + child: Column( + children: widgets, + )), ), ); } @@ -63,7 +103,7 @@ class _StatusScreenState extends State { } } -String statusToString(ServiceStatus enumValue) { +String serviceStatusToString(ServiceStatus enumValue) { switch (enumValue) { case ServiceStatus.Offline: return "Offline"; @@ -75,3 +115,20 @@ String statusToString(ServiceStatus enumValue) { throw Exception("Unknown enum value: $enumValue"); } } + +String channelStatusToString(ChannelStatus status) { + switch (status) { + case ChannelStatus.NotOpen: + return "Not open"; + case ChannelStatus.LnOpen: + return "Lightning open"; + case ChannelStatus.LnDlcOpen: + return "LN-DLC open"; + case ChannelStatus.LnDlcForceClosing: + return "Force-closing"; + case ChannelStatus.Inconsistent: + return "Inconsistent"; + case ChannelStatus.Unknown: + return "Unknown"; + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 63183a53c..7c33b9e8f 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -6,6 +6,7 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter/material.dart'; import 'package:get_10101/firebase_options.dart'; +import 'package:get_10101/common/channel_status_notifier.dart'; import 'dart:io'; import 'package:http/http.dart' as http; import 'package:flutter_native_splash/flutter_native_splash.dart'; @@ -56,7 +57,6 @@ import 'package:get_10101/features/trade/domain/price.dart'; import 'package:get_10101/features/wallet/domain/wallet_info.dart'; import 'package:get_10101/ffi.dart' as rust; import 'package:version/version.dart'; - import 'common/settings_screen.dart'; final GlobalKey _rootNavigatorKey = GlobalKey(debugLabel: 'root'); @@ -85,6 +85,7 @@ void main() { ChangeNotifierProvider( create: (context) => CandlestickChangeNotifier(const CandlestickService())), ChangeNotifierProvider(create: (context) => ServiceStatusNotifier()), + ChangeNotifierProvider(create: (context) => ChannelStatusNotifier()), Provider(create: (context) => Environment.parse()), Provider(create: (context) => channelInfoService) ], child: const TenTenOneApp())); @@ -463,6 +464,7 @@ void subscribeToNotifiers(BuildContext context) { final tradeValuesChangeNotifier = context.read(); final submitOrderChangeNotifier = context.read(); final serviceStatusNotifier = context.read(); + final channelStatusNotifier = context.read(); eventService.subscribe( orderChangeNotifier, bridge.Event.orderUpdateNotification(Order.apiDummy())); @@ -490,6 +492,8 @@ void subscribeToNotifiers(BuildContext context) { eventService.subscribe( serviceStatusNotifier, bridge.Event.serviceHealthUpdate(serviceUpdateApiDummy())); + channelStatusNotifier.subscribe(eventService); + eventService.subscribe( AnonSubscriber((event) => FLog.info(text: event.field0)), const bridge.Event.log("")); } diff --git a/mobile/native/src/event/api.rs b/mobile/native/src/event/api.rs index 8fe7350b7..286489b70 100644 --- a/mobile/native/src/event/api.rs +++ b/mobile/native/src/event/api.rs @@ -3,6 +3,7 @@ use crate::event::subscriber::Subscriber; use crate::event::EventInternal; use crate::event::EventType; use crate::health::ServiceUpdate; +use crate::ln_dlc::ChannelStatus; use crate::trade::order::api::Order; use crate::trade::position::api::Position; use core::convert::From; @@ -22,6 +23,7 @@ pub enum Event { PositionClosedNotification(PositionClosed), PriceUpdateNotification(BestPrice), ServiceHealthUpdate(ServiceUpdate), + ChannelStatusUpdate(ChannelStatus), } impl From for Event { @@ -53,6 +55,7 @@ impl From for Event { Event::PriceUpdateNotification(best_price) } EventInternal::ServiceHealthUpdate(update) => Event::ServiceHealthUpdate(update), + EventInternal::ChannelStatusUpdate(update) => Event::ChannelStatusUpdate(update), EventInternal::ChannelReady(_) => { unreachable!("This internal event is not exposed to the UI") } @@ -94,6 +97,7 @@ impl Subscriber for FlutterSubscriber { EventType::PositionClosedNotification, EventType::PriceUpdateNotification, EventType::ServiceHealthUpdate, + EventType::ChannelStatusUpdate, ] } } diff --git a/mobile/native/src/event/mod.rs b/mobile/native/src/event/mod.rs index 956356808..3a01273a5 100644 --- a/mobile/native/src/event/mod.rs +++ b/mobile/native/src/event/mod.rs @@ -1,11 +1,8 @@ -pub mod api; -mod event_hub; -pub mod subscriber; - use crate::api::WalletInfo; use crate::event::event_hub::get; use crate::event::subscriber::Subscriber; use crate::health::ServiceUpdate; +use crate::ln_dlc::ChannelStatus; use crate::trade::order::Order; use crate::trade::position::Position; use coordinator_commons::TradeParams; @@ -15,6 +12,11 @@ use std::fmt; use std::hash::Hash; use trade::ContractSymbol; +mod event_hub; + +pub mod api; +pub mod subscriber; + pub fn subscribe(subscriber: impl Subscriber + 'static + Send + Sync + Clone) { get().subscribe(subscriber); } @@ -36,6 +38,7 @@ pub enum EventInternal { ChannelReady(ChannelId), PaymentClaimed(u64), ServiceHealthUpdate(ServiceUpdate), + ChannelStatusUpdate(ChannelStatus), } impl fmt::Display for EventInternal { @@ -52,6 +55,7 @@ impl fmt::Display for EventInternal { EventInternal::ChannelReady(_) => "ChannelReady", EventInternal::PaymentClaimed(_) => "PaymentClaimed", EventInternal::ServiceHealthUpdate(_) => "ServiceHealthUpdate", + EventInternal::ChannelStatusUpdate(_) => "ChannelStatusUpdate", } .fmt(f) } @@ -73,6 +77,7 @@ impl From for EventType { EventInternal::ChannelReady(_) => EventType::ChannelReady, EventInternal::PaymentClaimed(_) => EventType::PaymentClaimed, EventInternal::ServiceHealthUpdate(_) => EventType::ServiceHealthUpdate, + EventInternal::ChannelStatusUpdate(_) => EventType::ChannelStatusUpdate, } } } @@ -90,4 +95,5 @@ pub enum EventType { ChannelReady, PaymentClaimed, ServiceHealthUpdate, + ChannelStatusUpdate, } diff --git a/mobile/native/src/health.rs b/mobile/native/src/health.rs index ba1bd005d..883dcdf4d 100644 --- a/mobile/native/src/health.rs +++ b/mobile/native/src/health.rs @@ -98,6 +98,7 @@ async fn publish_status_updates(service: Service, mut rx: watch::Receiver { let status = rx.borrow(); + event::publish(&EventInternal::ServiceHealthUpdate( (service, *status).into(), )); diff --git a/mobile/native/src/lib.rs b/mobile/native/src/lib.rs index 3b67c71df..e9bf785dd 100644 --- a/mobile/native/src/lib.rs +++ b/mobile/native/src/lib.rs @@ -1,18 +1,18 @@ -// NOTE: these needs to be the first mod, otherwise flutter rust bridge tries to import from here +// These modules need to be define at the top so that FRB doesn't try to import from them. pub mod db; pub mod ln_dlc; pub mod trade; -// autogenerated file, do not edit -pub mod schema; pub mod api; pub mod calculations; -mod channel_fee; pub mod commons; pub mod config; pub mod event; pub mod health; pub mod logger; +pub mod schema; + +mod channel_fee; mod orderbook; #[allow( diff --git a/mobile/native/src/ln_dlc/channel_status.rs b/mobile/native/src/ln_dlc/channel_status.rs new file mode 100644 index 000000000..6107bc91b --- /dev/null +++ b/mobile/native/src/ln_dlc/channel_status.rs @@ -0,0 +1,228 @@ +use crate::db::get_all_non_pending_channels; +use crate::event; +use crate::ln_dlc::node::Node; +use anyhow::Result; +use ln_dlc_node::channel::Channel; +use ln_dlc_node::channel::ChannelState; +use ln_dlc_node::node::rust_dlc_manager::subchannel::SubChannel; +use std::borrow::Borrow; +use std::time::Duration; + +const UPDATE_CHANNEL_STATUS_INTERVAL: Duration = Duration::from_secs(5); + +/// The status of the app channel +#[derive(Debug, Clone, Copy)] +pub enum ChannelStatus { + /// No channel is open. + /// + /// This means that it is possible to open a new Lightning channel. This does _not_ indicate if + /// there was a previous channel nor does it imply that a previous channel was completely + /// closed i.e. there might be pending transactions. + NotOpen, + /// There is a vanilla Lightning channel, without a subchannel.1 + /// + /// This means that it is possible to use the Lightning channel for payments and that the + /// channel can be upgraded to an LN-DLC channel. + LnOpen, + /// There is an LN-DLC channel. + /// + /// This corresponds to an app which currently has an open position as a result of trading. + LnDlcOpen, + /// The LN-DLC channel is in the process of being force-closed. + LnDlcForceClosing, + /// The LN-DLC channel is in an inconsistent state. + Inconsistent, + /// The status of the channel is not known. + Unknown, +} + +pub async fn track_channel_status(node: impl Borrow) { + loop { + tracing::info!("Tracking channel status"); + + let status = channel_status(node.borrow()) + .await + .map_err(|e| { + tracing::error!("Could not compute LN-DLC channel status: {e:#}"); + e + }) + .into(); + + tracing::info!(?status, "Channel status udpate"); + + event::publish(&event::EventInternal::ChannelStatusUpdate(status)); + + tokio::time::sleep(UPDATE_CHANNEL_STATUS_INTERVAL).await; + } +} + +impl From> for ChannelStatus { + fn from(value: Result) -> Self { + match value { + Ok(ConcreteChannelStatus::NotOpen) => Self::NotOpen, + Ok(ConcreteChannelStatus::LnOpen) => Self::LnOpen, + Ok(ConcreteChannelStatus::LnDlcOpen) => Self::LnDlcOpen, + Ok(ConcreteChannelStatus::LnDlcForceClosing) => Self::LnDlcForceClosing, + Ok(ConcreteChannelStatus::Inconsistent) => Self::Inconsistent, + Err(_) => Self::Unknown, + } + } +} + +/// Figure out the status of the current channel. +async fn channel_status(node: impl Borrow) -> Result { + let node: &Node = node.borrow(); + let node = &node.inner; + + // We assume that the most recently created LN channel is the current one. We only care about + // that one because the app can only have one channel at a time. + let ln_channel = get_all_non_pending_channels()? + .iter() + .max_by(|a, b| a.created_at.cmp(&b.created_at)) + .cloned(); + + let ln_channel = match ln_channel { + Some(ln_channel) => ln_channel, + // We never even had one LN channel. + None => return Ok(ConcreteChannelStatus::NotOpen), + }; + + let subchannels = node.list_dlc_channels()?; + + let status = derive_ln_dlc_channel_status(ln_channel, subchannels); + + Ok(status) +} + +/// The concrete status of the app channels. +/// +/// By concrete we mean that the channel status can be determined because we have all the data +/// needed to derive it. +#[derive(Debug, Clone, Copy)] +enum ConcreteChannelStatus { + NotOpen, + LnOpen, + LnDlcOpen, + // We cannot easily model the vanilla Lightning channel being force-closed because LDK erases + // a closed channel from the `ChannelManager` as soon as the closing process begins. This is a + // problem because it's not safe to delete the app if the channel is not fully closed! + // + // TODO: Add support for `LnForceClosing` status and ensure that channel status remains in + // `LnDlcForceClosing` whilst the LN channel is still closing. + // LnForceClosing, + LnDlcForceClosing, + Inconsistent, +} + +// TODO: We currently only look at the state of the subchannel. We should also take into account the +// state of the DLC channel and the contract. Otherwise we won't be able to convey that the CET is +// not yet confirmed. +// +// TODO: We should map the argument types to our own types so that we can simplify them. This will +// make it so much easier to build test cases in order to add tests. +fn derive_ln_dlc_channel_status( + // The most recently created LN channel, whether open or not. + ln_channel: Channel, + // All the subchannels that we have ever recorded. + subchannels: Vec, +) -> ConcreteChannelStatus { + match ln_channel { + // If the LN channel is open (or practically open). + Channel { + channel_id: Some(channel_id), + channel_state: ChannelState::Pending | ChannelState::Open, + .. + } => { + // We might have more than one subchannel stored, but we only care about the one that + // corresponds to the open LN channel. + match subchannels + .iter() + .find(|subchannel| subchannel.channel_id == channel_id) + { + None => ConcreteChannelStatus::LnOpen, + Some(subchannel) => match SubChannelState::from(subchannel) { + SubChannelState::Rejected + | SubChannelState::Opening + | SubChannelState::CollabClosed => ConcreteChannelStatus::LnOpen, + SubChannelState::Open | SubChannelState::CollabClosing => { + ConcreteChannelStatus::LnDlcOpen + } + // We're still waiting for the LN channel to close. + SubChannelState::ForceClosing | SubChannelState::ForceClosed => { + ConcreteChannelStatus::LnDlcForceClosing + } + }, + } + } + // If the LN channel is closing or closed. To discern between the two we would need to know + // the status of the commitment transaction. + Channel { + channel_id: Some(channel_id), + channel_state: + ChannelState::Closed | ChannelState::ForceClosedLocal | ChannelState::ForceClosedRemote, + .. + } => { + match subchannels + .iter() + .find(|subchannel| subchannel.channel_id == channel_id) + { + // We never had a subchannel associated with the latest LN channel. + None => ConcreteChannelStatus::NotOpen, + Some(subchannel) => match SubChannelState::from(subchannel) { + // The subchannel was never fully open. + SubChannelState::Rejected | SubChannelState::Opening => { + ConcreteChannelStatus::NotOpen + } + // The subchannel was closed offchain. + SubChannelState::CollabClosed => ConcreteChannelStatus::NotOpen, + // The subchannel was close on-chain. + SubChannelState::ForceClosed => ConcreteChannelStatus::NotOpen, + // The subchannel is somehow still open even though the LN channel is not. + SubChannelState::Open | SubChannelState::CollabClosing => { + ConcreteChannelStatus::Inconsistent + } + // The subchannel is still open being closed on-chain. + SubChannelState::ForceClosing => ConcreteChannelStatus::LnDlcForceClosing, + }, + } + } + // If the LN channel does not have an ID associated with it, we assume that it's still not + // open, for it should not be usable yet. + Channel { + channel_id: None, .. + } => ConcreteChannelStatus::NotOpen, + Channel { + channel_state: ChannelState::Announced, + .. + } => unimplemented!("This state does not apply to the app"), + } +} + +enum SubChannelState { + Rejected, + Opening, + Open, + CollabClosing, + CollabClosed, + ForceClosing, + ForceClosed, +} + +impl From<&SubChannel> for SubChannelState { + fn from(value: &SubChannel) -> Self { + use ln_dlc_node::node::rust_dlc_manager::subchannel::SubChannelState::*; + match value.state { + Rejected => SubChannelState::Rejected, + Offered(_) | Accepted(_) | Confirmed(_) | Finalized(_) => SubChannelState::Opening, + Signed(_) => SubChannelState::Open, + CloseOffered(_) | CloseAccepted(_) | CloseConfirmed(_) => { + SubChannelState::CollabClosing + } + OffChainClosed => SubChannelState::CollabClosed, + Closing(_) => SubChannelState::ForceClosing, + OnChainClosed | CounterOnChainClosed | ClosedPunished(_) => { + SubChannelState::ForceClosed + } + } + } +} diff --git a/mobile/native/src/ln_dlc/mod.rs b/mobile/native/src/ln_dlc/mod.rs index e3c4482da..366176d87 100644 --- a/mobile/native/src/ln_dlc/mod.rs +++ b/mobile/native/src/ln_dlc/mod.rs @@ -6,6 +6,7 @@ use crate::commons::reqwest_client; use crate::config; use crate::event; use crate::event::EventInternal; +use crate::ln_dlc::channel_status::track_channel_status; use crate::ln_dlc::node::Node; use crate::ln_dlc::node::NodeStorage; use crate::trade::order; @@ -60,7 +61,12 @@ use tokio::task::spawn_blocking; mod lightning_subscriber; mod node; +pub mod channel_status; + +pub use channel_status::ChannelStatus; + static NODE: Storage> = Storage::new(); + const PROCESS_INCOMING_MESSAGES_INTERVAL: Duration = Duration::from_secs(5); const UPDATE_WALLET_HISTORY_INTERVAL: Duration = Duration::from_secs(5); const CHECK_OPEN_ORDERS_INTERVAL: Duration = Duration::from_secs(60); @@ -260,6 +266,8 @@ pub fn run(data_dir: String, seed_dir: String, runtime: &Runtime) -> Result<()> node.inner.channel_manager.clone(), )); + runtime.spawn(track_channel_status(node.clone())); + NODE.set(node); event::publish(&EventInternal::Init("10101 is ready.".to_string())); From 73e3a3af8dc9581ce74fdbf7757730ba135735aa Mon Sep 17 00:00:00 2001 From: Lucas Soriano del Pino Date: Thu, 17 Aug 2023 13:04:08 +1000 Subject: [PATCH 2/2] fix(app): Ensure that we only notify listeners when it matters --- mobile/lib/common/service_status_notifier.dart | 3 ++- mobile/lib/features/trade/order_change_notifier.dart | 4 ++-- mobile/lib/features/trade/position_change_notifier.dart | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/mobile/lib/common/service_status_notifier.dart b/mobile/lib/common/service_status_notifier.dart index 66999254d..17a1c4036 100644 --- a/mobile/lib/common/service_status_notifier.dart +++ b/mobile/lib/common/service_status_notifier.dart @@ -23,10 +23,11 @@ class ServiceStatusNotifier extends ChangeNotifier implements Subscriber { FLog.debug(text: "Received event: ${event.toString()}"); var update = event.field0; services[update.service] = update.status; + + notifyListeners(); } else { FLog.warning(text: "Received unexpected event: ${event.toString()}"); } - notifyListeners(); } } diff --git a/mobile/lib/features/trade/order_change_notifier.dart b/mobile/lib/features/trade/order_change_notifier.dart index fd103eb6e..1c2dbc006 100644 --- a/mobile/lib/features/trade/order_change_notifier.dart +++ b/mobile/lib/features/trade/order_change_notifier.dart @@ -31,11 +31,11 @@ class OrderChangeNotifier extends ChangeNotifier implements Subscriber { orders[order.id] = order; sortOrderByTimestampDesc(); + + notifyListeners(); } else { FLog.warning(text: "Received unexpected event: ${event.toString()}"); } - - notifyListeners(); } void sortOrderByTimestampDesc() { diff --git a/mobile/lib/features/trade/position_change_notifier.dart b/mobile/lib/features/trade/position_change_notifier.dart index 891da09f2..3b5106680 100644 --- a/mobile/lib/features/trade/position_change_notifier.dart +++ b/mobile/lib/features/trade/position_change_notifier.dart @@ -52,10 +52,10 @@ class PositionChangeNotifier extends ChangeNotifier implements Subscriber { } } } + + notifyListeners(); } else { FLog.warning(text: "Received unexpected event: ${event.toString()}"); } - - notifyListeners(); } }