Skip to content

Commit

Permalink
feat: Sync dlc channel state with position
Browse files Browse the repository at this point in the history
This change will do the following things depending on the dlc channel state and position.

- DLC Channel in state `Signed` but no position: Create position from `filling` order.
- DLC Channel in state `OffChainClosed` and a position exists. Delete the position.
- DLC Channel in state `CloseOffered` or `CloseAccepted`: Inform the UI that the dlc channel is recovering.
- DLC Channel in state `Offered`, `Accepted` or `Finalized`: Inform the UI that the dlc channel is recovering.
- DLC Channel in any other state but with position: Delete position the channel might have been force closed.
  • Loading branch information
holzeis committed Sep 14, 2023
1 parent d8dc9eb commit 2444340
Show file tree
Hide file tree
Showing 10 changed files with 300 additions and 45 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Show loading screen when app starts with an expired position
- Changed expiry to next Sunday 3 pm UTC
- Automatically rollover if user opens app during rollover weekend
- Sync position with dlc channel state

## [1.2.6] - 2023-09-06

Expand Down
46 changes: 46 additions & 0 deletions crates/ln-dlc-node/src/node/dlc_channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ use dlc_messages::OnChainMessage;
use dlc_messages::SubChannelMessage;
use lightning::ln::channelmanager::ChannelDetails;
use std::sync::Arc;
use time::OffsetDateTime;
use tokio::task::spawn_blocking;

impl<P> Node<P>
Expand Down Expand Up @@ -294,6 +295,51 @@ where
Ok(())
}

/// Gets the collateral and expiry from a signed contract of that given channel_id. Will return
/// an error if the contract is not confirmed.
pub fn get_collateral_and_expiry_from(
&self,
channel_id: ChannelId,
) -> Result<(u64, OffsetDateTime)> {
let storage = self.dlc_manager.get_store();
let sub_channel = storage.get_sub_channel(channel_id)?.with_context(|| {
format!(
"Could not find sub channel by channel id {}",
channel_id.to_hex()
)
})?;
let dlc_channel_id = sub_channel
.get_dlc_channel_id(0)
.context("Could not fetch dlc channel id")?;

match self.get_contract_by_dlc_channel_id(dlc_channel_id)? {
Contract::Confirmed(contract) => {
let offered_contract = contract.accepted_contract.offered_contract;
let contract_info = offered_contract
.contract_info
.first()
.context("contract info to exist on a signed contract")?;
let oracle_announcement = contract_info
.oracle_announcements
.first()
.context("oracle announcement to exist on signed contract")?;

let expiry_timestamp = OffsetDateTime::from_unix_timestamp(
oracle_announcement.oracle_event.event_maturity_epoch as i64,
)?;

Ok((
contract.accepted_contract.accept_params.collateral,
expiry_timestamp,
))
}
_ => bail!(
"Confirmed contract not found for channel ID: {}",
hex::encode(channel_id)
),
}
}

fn get_dlc_channel(
&self,
matcher: impl FnMut(&&SubChannel) -> bool,
Expand Down
8 changes: 8 additions & 0 deletions crates/ln-dlc-node/src/node/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,14 @@ where
}
}
}

pub async fn periodic_check(&self) -> Result<()> {
sub_channel_manager_periodic_check(
self.sub_channel_manager.clone(),
&self.dlc_message_handler,
)
.await
}
}

async fn update_fee_rate_estimates(
Expand Down
14 changes: 14 additions & 0 deletions mobile/lib/common/domain/background_task.dart
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,17 @@ class Rollover {
return bridge.BackgroundTask_Rollover(TaskStatus.apiDummy());
}
}

class RecoverDlc {
final TaskStatus taskStatus;

RecoverDlc({required this.taskStatus});

static RecoverDlc fromApi(bridge.BackgroundTask_RecoverDlc recoverDlc) {
return RecoverDlc(taskStatus: TaskStatus.fromApi(recoverDlc.field0));
}

static bridge.BackgroundTask apiDummy() {
return bridge.BackgroundTask_RecoverDlc(TaskStatus.apiDummy());
}
}
55 changes: 55 additions & 0 deletions mobile/lib/common/recover_dlc_change_notifier.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import 'package:f_logs/model/flog/flog.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/common/domain/background_task.dart';
import 'package:get_10101/common/global_keys.dart';
import 'package:get_10101/features/trade/order_submission_status_dialog.dart';
import 'package:provider/provider.dart';

class RecoverDlcChangeNotifier extends ChangeNotifier implements Subscriber {
late TaskStatus taskStatus;

@override
void notify(bridge.Event event) {
if (event is bridge.Event_BackgroundNotification) {
if (event.field0 is! bridge.BackgroundTask_RecoverDlc) {
// ignoring other kinds of background tasks
return;
}
RecoverDlc recoverDlc = RecoverDlc.fromApi(event.field0 as bridge.BackgroundTask_RecoverDlc);
FLog.debug(text: "Received a recover dlc event. Status: ${recoverDlc.taskStatus}");

taskStatus = recoverDlc.taskStatus;

if (taskStatus == TaskStatus.pending) {
// initialize dialog for the pending task
showDialog(
context: shellNavigatorKey.currentContext!,
builder: (context) {
TaskStatus status = context.watch<RecoverDlcChangeNotifier>().taskStatus;

// todo(holzeis): Reusing the order submission status dialog is not nice, but it's actually suitable for any task execution that has pending,
// failed and success states. We may should consider renaming this dialog for its more generic purpose.
OrderSubmissionStatusDialogType type = OrderSubmissionStatusDialogType.pendingSubmit;
switch (status) {
case TaskStatus.pending:
type = OrderSubmissionStatusDialogType.successfulSubmit;
case TaskStatus.failed:
type = OrderSubmissionStatusDialogType.failedFill;
case TaskStatus.success:
type = OrderSubmissionStatusDialogType.filled;
}

late Widget content = const Text("Recovering your dlc channel");

return OrderSubmissionStatusDialog(title: "Catching up!", type: type, content: content);
},
);
} else {
// notify dialog about changed task status
notifyListeners();
}
}
}
}
6 changes: 6 additions & 0 deletions mobile/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import 'package:get_10101/common/color.dart';
import 'package:get_10101/common/domain/background_task.dart';
import 'package:get_10101/common/domain/service_status.dart';
import 'package:get_10101/common/global_keys.dart';
import 'package:get_10101/common/recover_dlc_change_notifier.dart';
import 'package:get_10101/common/service_status_notifier.dart';
import 'package:get_10101/common/snack_bar.dart';
import 'package:get_10101/features/stable/stable_screen.dart';
Expand Down Expand Up @@ -91,6 +92,7 @@ void main() async {
ChangeNotifierProvider(create: (context) => ChannelStatusNotifier()),
ChangeNotifierProvider(create: (context) => AsyncOrderChangeNotifier(OrderService())),
ChangeNotifierProvider(create: (context) => RolloverChangeNotifier()),
ChangeNotifierProvider(create: (context) => RecoverDlcChangeNotifier()),
Provider(create: (context) => Environment.parse()),
Provider(create: (context) => channelInfoService)
], child: const TenTenOneApp()));
Expand Down Expand Up @@ -460,6 +462,7 @@ void subscribeToNotifiers(BuildContext context) {
final stableValuesChangeNotifier = context.read<StableValuesChangeNotifier>();
final asyncOrderChangeNotifier = context.read<AsyncOrderChangeNotifier>();
final rolloverChangeNotifier = context.read<RolloverChangeNotifier>();
final recoverDlcChangeNotifier = context.read<RecoverDlcChangeNotifier>();

eventService.subscribe(
orderChangeNotifier, bridge.Event.orderUpdateNotification(Order.apiDummy()));
Expand Down Expand Up @@ -498,6 +501,9 @@ void subscribeToNotifiers(BuildContext context) {
eventService.subscribe(
rolloverChangeNotifier, bridge.Event.backgroundNotification(Rollover.apiDummy()));

eventService.subscribe(
recoverDlcChangeNotifier, bridge.Event.backgroundNotification(RecoverDlc.apiDummy()));

channelStatusNotifier.subscribe(eventService);

eventService.subscribe(
Expand Down
2 changes: 2 additions & 0 deletions mobile/native/src/event/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ pub enum Event {
pub enum BackgroundTask {
AsyncTrade(OrderReason),
Rollover(TaskStatus),
RecoverDlc(TaskStatus),
}

impl From<EventInternal> for Event {
Expand Down Expand Up @@ -128,6 +129,7 @@ impl From<event::BackgroundTask> for BackgroundTask {
BackgroundTask::AsyncTrade(order_reason.into())
}
event::BackgroundTask::Rollover(status) => BackgroundTask::Rollover(status.into()),
event::BackgroundTask::RecoverDlc(status) => BackgroundTask::RecoverDlc(status.into()),
}
}
}
Expand Down
1 change: 1 addition & 0 deletions mobile/native/src/event/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ pub enum EventInternal {
pub enum BackgroundTask {
AsyncTrade(OrderReason),
Rollover(TaskStatus),
RecoverDlc(TaskStatus),
}

#[derive(Clone, Debug)]
Expand Down
141 changes: 139 additions & 2 deletions mobile/native/src/ln_dlc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ use crate::calculations;
use crate::channel_fee::ChannelFeePaymentSubscriber;
use crate::commons::reqwest_client;
use crate::config;
use crate::db;
use crate::event;
use crate::event::BackgroundTask;
use crate::event::EventInternal;
use crate::event::TaskStatus;
use crate::ln_dlc::channel_status::track_channel_status;
use crate::ln_dlc::node::Node;
use crate::ln_dlc::node::NodeStorage;
Expand All @@ -26,6 +29,7 @@ use bdk::BlockTime;
use bdk::FeeRate;
use bitcoin::hashes::hex::ToHex;
use bitcoin::Amount;
pub use channel_status::ChannelStatus;
use coordinator_commons::LspConfig;
use coordinator_commons::TradeParams;
use itertools::chain;
Expand Down Expand Up @@ -69,11 +73,10 @@ mod node;

pub mod channel_status;

pub use channel_status::ChannelStatus;

const PROCESS_INCOMING_DLC_MESSAGES_INTERVAL: Duration = Duration::from_millis(200);
const UPDATE_WALLET_HISTORY_INTERVAL: Duration = Duration::from_secs(5);
const CHECK_OPEN_ORDERS_INTERVAL: Duration = Duration::from_secs(60);
const DLC_RECOVERY_PROTOCOL_TIMEOUT: Duration = Duration::from_secs(60);

/// The weight estimate of the funding transaction
///
Expand Down Expand Up @@ -282,6 +285,10 @@ pub fn run(data_dir: String, seed_dir: String, runtime: &Runtime) -> Result<()>

event::publish(&EventInternal::Init("10101 is ready.".to_string()));

if let Err(e) = sync_position_with_dlc_channel_state().await {
tracing::error!("Failed to sync position with dlc channel state. Error: {e:#}");
}

Ok(())
})
}
Expand Down Expand Up @@ -525,6 +532,136 @@ pub fn get_usable_channel_details() -> Result<Vec<ChannelDetails>> {
Ok(channels)
}

/// Syncs the position with the dlc channel state.
///
/// - DLC Channel in state `Signed` but no position: Create position from `filling` order.
/// - DLC Channel in state `OffChainClosed` and a position exists. Delete the position.
/// - DLC Channel in state `CloseOffered` or `CloseAccepted`: Inform the UI that the dlc channel is
/// recovering.
/// - DLC Channel in state `Offered`, `Accepted` or `Finalized`: Inform the UI that the dlc channel
/// is recovering.
/// - DLC Channel in any other state but with position: Delete position the channel might have been
/// force closed.
async fn sync_position_with_dlc_channel_state() -> Result<()> {
let node = NODE.try_get().context("failed to get ln dlc node")?;
let channels = node.inner.channel_manager.list_channels();
let channel_details = match channels.first() {
Some(channel_details) => channel_details,
None => return Ok(()),
};
let dlc_channels = node.inner.dlc_manager.get_store().get_sub_channels()?;
let dlc_channel = dlc_channels
.iter()
.find(|dlc_channel| dlc_channel.channel_id == channel_details.channel_id);

let positions = db::get_positions()?;
let position = positions.first();

match (position, dlc_channel) {
(Some(_), Some(dlc_channel)) => {
if matches!(dlc_channel.state, SubChannelState::Signed(_)) {
tracing::info!("DLC channel and position are in sync");
} else {
tracing::warn!("Found unexpected sub channel state {:?}", dlc_channel.state);
if matches!(dlc_channel.state, SubChannelState::OffChainClosed) {
let filled_order = order::handler::order_filled()?;
tracing::warn!("Deleting position as dlc channel is already closed!");
position::handler::update_position_after_dlc_closure(Some(filled_order))?;
} else if matches!(
dlc_channel.state,
SubChannelState::CloseOffered(_) | SubChannelState::CloseAccepted(_)
) {
recover_dlc().await?;
} else {
tracing::warn!(
"The DLC is in a state that can not be recovered. Removing position."
);
// maybe a left over after a force-closure
position::handler::update_position_after_dlc_closure(None)?;
}
}
}
(None, Some(dlc_channel)) => {
if matches!(dlc_channel.state, SubChannelState::OffChainClosed) {
tracing::info!("DLC channel and position are in sync");
} else {
tracing::warn!("Found unexpected sub channel state {:?}", dlc_channel.state);
if matches!(dlc_channel.state, SubChannelState::Signed(_)) {
tracing::warn!("Trying to recover position from order");
match order::handler::order_filled() {
Ok(order) => {
let (accept_collateral, expiry_timestamp) = node
.inner
.get_collateral_and_expiry_from(dlc_channel.channel_id)?;

position::handler::update_position_after_dlc_creation(
order,
accept_collateral,
expiry_timestamp,
)?;

tracing::info!("Successfully recovered position from order.");
}
Err(e) => {
tracing::error!("Could not recover position from order as no filling order was found! Error: {e:#}");
}
}
} else if matches!(
dlc_channel.state,
SubChannelState::Offered(_)
| SubChannelState::Accepted(_)
| SubChannelState::Finalized(_)
) {
recover_dlc().await?;
} else {
tracing::warn!("The DLC is in a state that can not be recovered.");
}
}
}
_ => {}
}

Ok(())
}

/// Sends a pending RecoverDlc background task notification to the UI, allowing the UI to show a
/// dialog with a spinner that the DLC protocol is still in progress.
/// Also triggers the `periodic_check` to process any actions that might have been created after the
/// channel reestablishment.
///
/// fixme(holzeis): We currently use different events for show the recovery of a dlc and the waiting
/// for an order execution in the happy case (without an restart in between). Those events and
/// dialogs should be aligned.
async fn recover_dlc() -> Result<()> {
tracing::warn!("It looks like the app was closed while the protocol was not finished yet.");
event::publish(&EventInternal::BackgroundNotification(
BackgroundTask::RecoverDlc(TaskStatus::Pending),
));

// fixme(holzeis): We are manually calling periodic check here to speed up the processing of
// pending actions. Note, this might also not speed up the process, as the coordinator might
// have to resend a message to continue the protocol. This should be fixed in `rust-dlc` and any
// pending actions should be processed immediately once the channel is ready instead of
// periodically checking if a pending action needs to be sent. Note, pending actions can only
// get created on channel reestablishment.
tokio::time::sleep(Duration::from_secs(5)).await;
let node = NODE.try_get().context("failed to get ln dlc node")?;
if let Err(e) = node.inner.periodic_check().await {
tracing::error!("Failed to process periodic check! Error: {e:#}");
}

// Sending a failed event with a delay. This event will be ignored by the UI if the task has
// been successful in the meantime.
tokio::spawn(async {
tokio::time::sleep(DLC_RECOVERY_PROTOCOL_TIMEOUT).await;
event::publish(&EventInternal::BackgroundNotification(
BackgroundTask::RecoverDlc(TaskStatus::Failed),
));
});

Ok(())
}

pub fn get_fee_rate() -> Result<FeeRate> {
let node = NODE.try_get().context("failed to get ln dlc node")?;
Ok(node.inner.wallet().get_fee_rate(CONFIRMATION_TARGET))
Expand Down
Loading

0 comments on commit 2444340

Please sign in to comment.