From 71cde71fd17446fc8785e9f5e757af5fe0c43c42 Mon Sep 17 00:00:00 2001 From: Richard Holzeis Date: Fri, 18 Aug 2023 10:44:15 +0200 Subject: [PATCH 1/3] chore: Move calculate settlement amount to position model IMHO this is a bit cleaner as the api is simplified if we just call the calculate_settlement_amount on the position. --- coordinator/src/node.rs | 229 +---------------------------- coordinator/src/position/models.rs | 226 ++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+), 226 deletions(-) diff --git a/coordinator/src/node.rs b/coordinator/src/node.rs index 6c25c842e..b4cb1f352 100644 --- a/coordinator/src/node.rs +++ b/coordinator/src/node.rs @@ -1,5 +1,7 @@ use crate::db; use crate::node::storage::NodeStorage; +use crate::position::models::leverage_long; +use crate::position::models::leverage_short; use crate::position::models::NewPosition; use crate::position::models::Position; use crate::trade::models::NewTrade; @@ -41,7 +43,6 @@ use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use std::sync::Arc; use tokio::sync::RwLock; -use trade::cfd; use trade::cfd::calculate_long_liquidation_price; use trade::cfd::calculate_margin; use trade::cfd::calculate_short_liquidation_price; @@ -291,19 +292,7 @@ impl Node { channel_id: ChannelId, fee_payment_hash: PaymentHash, ) -> Result<()> { - let opening_price = Decimal::try_from(position.average_entry_price)?; - - let leverage_long = leverage_long(position.direction, position.leverage); - let leverage_short = leverage_short(position.direction, position.leverage); - - let accept_settlement_amount = calculate_accept_settlement_amount( - opening_price, - closing_price, - position.quantity, - leverage_long, - leverage_short, - position.direction, - )?; + let accept_settlement_amount = position.calculate_settlement_amount(closing_price)?; tracing::debug!( ?position, @@ -465,41 +454,6 @@ pub enum TradeAction { // Reduce, } -/// Calculates the accept settlement amount based on the pnl. -fn calculate_accept_settlement_amount( - opening_price: Decimal, - closing_price: Decimal, - quantity: f32, - long_leverage: f32, - short_leverage: f32, - direction: Direction, -) -> Result { - let pnl = cfd::calculate_pnl( - opening_price, - closing_price, - quantity, - long_leverage, - short_leverage, - direction, - )?; - - let leverage = match direction { - Direction::Long => long_leverage, - Direction::Short => short_leverage, - }; - - let margin_trader = calculate_margin(opening_price, quantity, leverage); - - let accept_settlement_amount = Decimal::from(margin_trader) + Decimal::from(pnl); - // the amount can only be positive, adding a safeguard here with the max comparison to - // ensure the i64 fits into u64 - let accept_settlement_amount = accept_settlement_amount - .max(Decimal::ZERO) - .to_u64() - .expect("to fit into u64"); - Ok(accept_settlement_amount) -} - fn margin_trader(trade_params: &TradeParams) -> u64 { calculate_margin( trade_params.average_execution_price(), @@ -528,20 +482,6 @@ fn liquidation_price(trade_params: &TradeParams) -> f32 { .expect("to fit into f32") } -fn leverage_long(direction: Direction, trader_leverage: f32) -> f32 { - match direction { - Direction::Long => trader_leverage, - Direction::Short => COORDINATOR_LEVERAGE, - } -} - -fn leverage_short(direction: Direction, trader_leverage: f32) -> f32 { - match direction { - Direction::Long => COORDINATOR_LEVERAGE, - Direction::Short => trader_leverage, - } -} - fn get_rounding_intervals() -> RoundingIntervals { RoundingIntervals { intervals: vec![RoundingInterval { @@ -654,166 +594,3 @@ fn build_payout_function( Ok(payout_function) } - -#[cfg(test)] -pub mod tests { - use crate::node::calculate_accept_settlement_amount; - use rust_decimal::Decimal; - use trade::cfd::calculate_margin; - use trade::Direction; - - // some basic sanity tests, that in case the position goes the right or wrong way the settlement - // amount is moving correspondingly up or down. - - #[test] - fn given_a_long_position_and_a_larger_closing_price() { - let opening_price = Decimal::from(22000); - let closing_price = Decimal::from(23000); - let quantity: f32 = 1.0; - let accept_settlement_amount = calculate_accept_settlement_amount( - opening_price, - closing_price, - quantity, - 1.0, - 1.0, - Direction::Long, - ) - .unwrap(); - - let margin_trader = calculate_margin(opening_price, quantity, 1.0); - assert!(accept_settlement_amount > margin_trader); - } - - #[test] - fn given_a_short_position_and_a_larger_closing_price() { - let opening_price = Decimal::from(22000); - let closing_price = Decimal::from(23000); - let quantity: f32 = 1.0; - let accept_settlement_amount = calculate_accept_settlement_amount( - opening_price, - closing_price, - quantity, - 1.0, - 1.0, - Direction::Short, - ) - .unwrap(); - - let margin_trader = calculate_margin(opening_price, quantity, 1.0); - assert!(accept_settlement_amount < margin_trader); - } - - #[test] - fn given_a_long_position_and_a_smaller_closing_price() { - let opening_price = Decimal::from(23000); - let closing_price = Decimal::from(22000); - let quantity: f32 = 1.0; - let accept_settlement_amount = calculate_accept_settlement_amount( - opening_price, - closing_price, - quantity, - 1.0, - 1.0, - Direction::Long, - ) - .unwrap(); - - let margin_trader = calculate_margin(opening_price, quantity, 1.0); - assert!(accept_settlement_amount < margin_trader); - } - - #[test] - fn given_a_short_position_and_a_smaller_closing_price() { - let opening_price = Decimal::from(23000); - let closing_price = Decimal::from(22000); - let quantity: f32 = 1.0; - let accept_settlement_amount = calculate_accept_settlement_amount( - opening_price, - closing_price, - quantity, - 1.0, - 1.0, - Direction::Short, - ) - .unwrap(); - - let margin_trader = calculate_margin(opening_price, quantity, 1.0); - assert!(accept_settlement_amount > margin_trader); - } - - #[test] - fn given_a_long_position_and_a_larger_closing_price_different_leverages() { - let opening_price = Decimal::from(22000); - let closing_price = Decimal::from(23000); - let quantity: f32 = 1.0; - let accept_settlement_amount = calculate_accept_settlement_amount( - opening_price, - closing_price, - quantity, - 1.0, - 2.0, - Direction::Long, - ) - .unwrap(); - - let margin_trader = calculate_margin(opening_price, quantity, 2.0); - assert!(accept_settlement_amount > margin_trader); - } - - #[test] - fn given_a_short_position_and_a_larger_closing_price_different_leverages() { - let opening_price = Decimal::from(22000); - let closing_price = Decimal::from(23000); - let quantity: f32 = 1.0; - let accept_settlement_amount = calculate_accept_settlement_amount( - opening_price, - closing_price, - quantity, - 2.0, - 1.0, - Direction::Short, - ) - .unwrap(); - - let margin_trader = calculate_margin(opening_price, quantity, 1.0); - assert!(accept_settlement_amount < margin_trader); - } - - #[test] - fn given_a_long_position_and_a_smaller_closing_price_different_leverages() { - let opening_price = Decimal::from(23000); - let closing_price = Decimal::from(22000); - let quantity: f32 = 1.0; - let accept_settlement_amount = calculate_accept_settlement_amount( - opening_price, - closing_price, - quantity, - 2.0, - 1.0, - Direction::Long, - ) - .unwrap(); - - let margin_trader = calculate_margin(opening_price, quantity, 2.0); - assert!(accept_settlement_amount < margin_trader); - } - - #[test] - fn given_a_short_position_and_a_smaller_closing_price_different_leverages() { - let opening_price = Decimal::from(23000); - let closing_price = Decimal::from(22000); - let quantity: f32 = 1.0; - let accept_settlement_amount = calculate_accept_settlement_amount( - opening_price, - closing_price, - quantity, - 1.0, - 2.0, - Direction::Short, - ) - .unwrap(); - - let margin_trader = calculate_margin(opening_price, quantity, 2.0); - assert!(accept_settlement_amount > margin_trader); - } -} diff --git a/coordinator/src/position/models.rs b/coordinator/src/position/models.rs index 69730d9f7..0027315de 100644 --- a/coordinator/src/position/models.rs +++ b/coordinator/src/position/models.rs @@ -3,9 +3,11 @@ use anyhow::Context; use anyhow::Result; use bitcoin::secp256k1::PublicKey; use dlc_manager::ContractId; +use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use time::OffsetDateTime; use trade::bitmex_client::Quote; +use trade::cfd::calculate_margin; use trade::cfd::calculate_pnl; use trade::ContractSymbol; use trade::Direction; @@ -103,12 +105,236 @@ impl Position { Ok(pnl) } + + pub fn calculate_settlement_amount(&self, closing_price: Decimal) -> Result { + let opening_price = Decimal::try_from(self.average_entry_price)?; + + let leverage_long = leverage_long(self.direction, self.leverage); + let leverage_short = leverage_short(self.direction, self.leverage); + + calculate_accept_settlement_amount( + opening_price, + closing_price, + self.quantity, + leverage_long, + leverage_short, + self.direction, + ) + } +} + +/// Calculates the accept settlement amount based on the pnl. +fn calculate_accept_settlement_amount( + opening_price: Decimal, + closing_price: Decimal, + quantity: f32, + long_leverage: f32, + short_leverage: f32, + direction: Direction, +) -> Result { + let pnl = calculate_pnl( + opening_price, + closing_price, + quantity, + long_leverage, + short_leverage, + direction, + )?; + + let leverage = match direction { + Direction::Long => long_leverage, + Direction::Short => short_leverage, + }; + + let margin_trader = calculate_margin(opening_price, quantity, leverage); + + let accept_settlement_amount = Decimal::from(margin_trader) + Decimal::from(pnl); + // the amount can only be positive, adding a safeguard here with the max comparison to + // ensure the i64 fits into u64 + let accept_settlement_amount = accept_settlement_amount + .max(Decimal::ZERO) + .to_u64() + .expect("to fit into u64"); + Ok(accept_settlement_amount) +} + +pub fn leverage_long(direction: Direction, trader_leverage: f32) -> f32 { + match direction { + Direction::Long => trader_leverage, + Direction::Short => COORDINATOR_LEVERAGE, + } +} + +pub fn leverage_short(direction: Direction, trader_leverage: f32) -> f32 { + match direction { + Direction::Long => COORDINATOR_LEVERAGE, + Direction::Short => trader_leverage, + } } #[cfg(test)] pub mod tests { use super::*; + use crate::position::models::calculate_accept_settlement_amount; + use rust_decimal::Decimal; use std::str::FromStr; + use trade::cfd::calculate_margin; + use trade::Direction; + + // some basic sanity tests, that in case the position goes the right or wrong way the settlement + // amount is moving correspondingly up or down. + + #[test] + fn given_a_long_position_and_a_larger_closing_price() { + let opening_price = Decimal::from(22000); + let closing_price = Decimal::from(23000); + let quantity: f32 = 1.0; + let accept_settlement_amount = calculate_accept_settlement_amount( + opening_price, + closing_price, + quantity, + 1.0, + 1.0, + Direction::Long, + ) + .unwrap(); + + let margin_trader = calculate_margin(opening_price, quantity, 1.0); + assert!(accept_settlement_amount > margin_trader); + } + + #[test] + fn given_a_short_position_and_a_larger_closing_price() { + let opening_price = Decimal::from(22000); + let closing_price = Decimal::from(23000); + let quantity: f32 = 1.0; + let accept_settlement_amount = calculate_accept_settlement_amount( + opening_price, + closing_price, + quantity, + 1.0, + 1.0, + Direction::Short, + ) + .unwrap(); + + let margin_trader = calculate_margin(opening_price, quantity, 1.0); + assert!(accept_settlement_amount < margin_trader); + } + + #[test] + fn given_a_long_position_and_a_smaller_closing_price() { + let opening_price = Decimal::from(23000); + let closing_price = Decimal::from(22000); + let quantity: f32 = 1.0; + let accept_settlement_amount = calculate_accept_settlement_amount( + opening_price, + closing_price, + quantity, + 1.0, + 1.0, + Direction::Long, + ) + .unwrap(); + + let margin_trader = calculate_margin(opening_price, quantity, 1.0); + assert!(accept_settlement_amount < margin_trader); + } + + #[test] + fn given_a_short_position_and_a_smaller_closing_price() { + let opening_price = Decimal::from(23000); + let closing_price = Decimal::from(22000); + let quantity: f32 = 1.0; + let accept_settlement_amount = calculate_accept_settlement_amount( + opening_price, + closing_price, + quantity, + 1.0, + 1.0, + Direction::Short, + ) + .unwrap(); + + let margin_trader = calculate_margin(opening_price, quantity, 1.0); + assert!(accept_settlement_amount > margin_trader); + } + + #[test] + fn given_a_long_position_and_a_larger_closing_price_different_leverages() { + let opening_price = Decimal::from(22000); + let closing_price = Decimal::from(23000); + let quantity: f32 = 1.0; + let accept_settlement_amount = calculate_accept_settlement_amount( + opening_price, + closing_price, + quantity, + 1.0, + 2.0, + Direction::Long, + ) + .unwrap(); + + let margin_trader = calculate_margin(opening_price, quantity, 2.0); + assert!(accept_settlement_amount > margin_trader); + } + + #[test] + fn given_a_short_position_and_a_larger_closing_price_different_leverages() { + let opening_price = Decimal::from(22000); + let closing_price = Decimal::from(23000); + let quantity: f32 = 1.0; + let accept_settlement_amount = calculate_accept_settlement_amount( + opening_price, + closing_price, + quantity, + 2.0, + 1.0, + Direction::Short, + ) + .unwrap(); + + let margin_trader = calculate_margin(opening_price, quantity, 1.0); + assert!(accept_settlement_amount < margin_trader); + } + + #[test] + fn given_a_long_position_and_a_smaller_closing_price_different_leverages() { + let opening_price = Decimal::from(23000); + let closing_price = Decimal::from(22000); + let quantity: f32 = 1.0; + let accept_settlement_amount = calculate_accept_settlement_amount( + opening_price, + closing_price, + quantity, + 2.0, + 1.0, + Direction::Long, + ) + .unwrap(); + + let margin_trader = calculate_margin(opening_price, quantity, 2.0); + assert!(accept_settlement_amount < margin_trader); + } + + #[test] + fn given_a_short_position_and_a_smaller_closing_price_different_leverages() { + let opening_price = Decimal::from(23000); + let closing_price = Decimal::from(22000); + let quantity: f32 = 1.0; + let accept_settlement_amount = calculate_accept_settlement_amount( + opening_price, + closing_price, + quantity, + 1.0, + 2.0, + Direction::Short, + ) + .unwrap(); + + let margin_trader = calculate_margin(opening_price, quantity, 2.0); + assert!(accept_settlement_amount > margin_trader); + } #[test] fn given_trader_long_position_when_no_bid_price_change_then_zero_coordinator_pnl() { From f88b34b0f3e19f97e5720f2eeb929f89eee36eb1 Mon Sep 17 00:00:00 2001 From: Richard Holzeis Date: Fri, 18 Aug 2023 19:52:50 +0200 Subject: [PATCH 2/3] fix: Use function on sub channel to get the dlc channel id It appears the channel_id is not always correct. Using the `get_dlc_channel_id(0)` seems to cover more edge cases. --- .../ln-dlc-node/src/ln/dlc_channel_details.rs | 13 ++++++ crates/tests-e2e/src/coordinator.rs | 1 + mobile/native/src/ln_dlc/node.rs | 41 ++++++++++--------- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/crates/ln-dlc-node/src/ln/dlc_channel_details.rs b/crates/ln-dlc-node/src/ln/dlc_channel_details.rs index 8f053a60c..52ae9b5b9 100644 --- a/crates/ln-dlc-node/src/ln/dlc_channel_details.rs +++ b/crates/ln-dlc-node/src/ln/dlc_channel_details.rs @@ -9,6 +9,8 @@ use serde::Serializer; pub struct DlcChannelDetails { #[serde(serialize_with = "channel_id_as_hex")] pub channel_id: ChannelId, + #[serde(serialize_with = "optional_channel_id_as_hex")] + pub dlc_channel_id: Option, #[serde(serialize_with = "pk_as_hex")] pub counter_party: PublicKey, pub update_idx: u64, @@ -41,6 +43,7 @@ impl From for DlcChannelDetails { fn from(sc: SubChannel) -> Self { DlcChannelDetails { channel_id: sc.channel_id, + dlc_channel_id: sc.get_dlc_channel_id(0), counter_party: sc.counter_party, update_idx: sc.update_idx, state: SubChannelState::from(sc.state), @@ -73,6 +76,16 @@ impl From for SubChannelState { } } +fn optional_channel_id_as_hex(channel_id: &Option, s: S) -> Result +where + S: Serializer, +{ + match channel_id { + Some(channel_id) => s.serialize_str(&channel_id.to_hex()), + None => s.serialize_none(), + } +} + fn channel_id_as_hex(channel_id: &ChannelId, s: S) -> Result where S: Serializer, diff --git a/crates/tests-e2e/src/coordinator.rs b/crates/tests-e2e/src/coordinator.rs index a70d0bea0..fc07d064f 100644 --- a/crates/tests-e2e/src/coordinator.rs +++ b/crates/tests-e2e/src/coordinator.rs @@ -24,6 +24,7 @@ pub struct DlcChannels { #[derive(Deserialize, Debug)] pub struct DlcChannel { pub channel_id: String, + pub dlc_channel_id: Option, pub counter_party: String, pub state: SubChannelState, } diff --git a/mobile/native/src/ln_dlc/node.rs b/mobile/native/src/ln_dlc/node.rs index 9c65af0f0..d6257a03e 100644 --- a/mobile/native/src/ln_dlc/node.rs +++ b/mobile/native/src/ln_dlc/node.rs @@ -1,6 +1,7 @@ use crate::db; use crate::trade::order; use crate::trade::position; +use anyhow::bail; use anyhow::Context; use anyhow::Result; use bdk::bitcoin::secp256k1::PublicKey; @@ -21,9 +22,8 @@ use ln_dlc_node::channel::Channel; use ln_dlc_node::channel::FakeScid; use ln_dlc_node::node; use ln_dlc_node::node::dlc_message_name; -use ln_dlc_node::node::rust_dlc_manager::contract::signed_contract::SignedContract; use ln_dlc_node::node::rust_dlc_manager::contract::Contract; -use ln_dlc_node::node::rust_dlc_manager::Storage as _; +use ln_dlc_node::node::rust_dlc_manager::Storage; use ln_dlc_node::node::sub_channel_message_name; use ln_dlc_node::node::NodeInfo; use ln_dlc_node::node::PaymentDetails; @@ -203,26 +203,27 @@ impl Node { channel_id, .. })) = msg { - let contracts = self.inner.dlc_manager.get_store().get_contracts()?; - - let accept_collateral = contracts - .iter() - // Taking the first `Confirmed` contract we find is just a - // heuristic. Ideally we would be able to match against the - // `ContractId` or the `ChannelId`, but the information is not - // guaranteed to be there - .find_map(|contract| match contract { - Contract::Confirmed(SignedContract { - accepted_contract, .. - }) => Some(accepted_contract.accept_params.collateral), - _ => None, - }) - .with_context(|| { - format!( + let storage = self.inner.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")?; + + let accept_collateral = + match self.inner.get_contract_by_dlc_channel_id(dlc_channel_id)? { + Contract::Confirmed(contract) => { + contract.accepted_contract.accept_params.collateral + } + _ => bail!( "Confirmed contract not found for channel ID: {}", hex::encode(channel_id) - ) - })?; + ), + }; let filled_order = order::handler::order_filled() .context("Cannot mark order as filled for confirmed DLC")?; From 6b20ca3e2bf4ae90f67660e8fa24a298b2b78536 Mon Sep 17 00:00:00 2001 From: Richard Holzeis Date: Fri, 18 Aug 2023 19:54:30 +0200 Subject: [PATCH 3/3] feat: Rollover DLC This introduces the rollover of a signed dlc. It uses the renew offer api of the rust dlc. A couple of design choices. - The payout amount is calculated from the original average entry price - as we are rolling over a position nothing should change. However, this is most likely the place where we would like to add paying the funding rates for the rollover. - The state `Rollover` is introduced to the position on the coordinator and the app side. It helps to show a rollover in progress as with the dlc creation a lot of messages are exchanged in between. We also need that state to prevent the coordinator from accidentally setting the position to `Closed` as the underlying contract is Closed after the new one is set. Therefore the `temporary_contract_id` is also updated. I left a some todos regarding the dlc messages. It would be nice if we could process them similar to the lightning messages through the or a event handler. That would allow for the design to be nicer decoupled. --- CHANGELOG.md | 1 + Cargo.lock | 2 + coordinator/Cargo.toml | 1 + .../2023-08-18-105400_rollover_dlc/down.sql | 2 + .../2023-08-18-105400_rollover_dlc/up.sql | 4 + coordinator/src/db/custom_types.rs | 2 + coordinator/src/db/positions.rs | 41 ++ coordinator/src/lib.rs | 22 +- coordinator/src/node.rs | 14 +- coordinator/src/node/closed_positions.rs | 5 + coordinator/src/position/models.rs | 1 + coordinator/src/rollover.rs | 361 ++++++++++++++++++ coordinator/src/routes.rs | 30 ++ crates/ln-dlc-node/src/node/dlc_channel.rs | 61 +++ crates/tests-e2e/Cargo.toml | 1 + crates/tests-e2e/src/coordinator.rs | 5 + crates/tests-e2e/tests/rollover_position.rs | 58 +++ .../lib/features/trade/domain/position.dart | 9 +- mobile/native/src/db/custom_types.rs | 2 + mobile/native/src/db/mod.rs | 11 + mobile/native/src/db/models.rs | 23 ++ mobile/native/src/ln_dlc/node.rs | 36 +- mobile/native/src/trade/order/handler.rs | 5 +- mobile/native/src/trade/position/api.rs | 16 +- mobile/native/src/trade/position/handler.rs | 27 +- mobile/native/src/trade/position/mod.rs | 15 +- 26 files changed, 725 insertions(+), 30 deletions(-) create mode 100644 coordinator/migrations/2023-08-18-105400_rollover_dlc/down.sql create mode 100644 coordinator/migrations/2023-08-18-105400_rollover_dlc/up.sql create mode 100644 coordinator/src/rollover.rs create mode 100644 crates/tests-e2e/tests/rollover_position.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4edba3927..7c7d11603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add support for push notifications. - Added new setting to coordinator to configure max channel size to traders. - Speed up DLC channel setup and settlement by checking for messages more often. +- Add support for perpetual futures. ## [1.2.0] - 2023-08-04 diff --git a/Cargo.lock b/Cargo.lock index b62bc253d..5da9beef2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -630,6 +630,7 @@ dependencies = [ "coordinator-commons", "diesel", "diesel_migrations", + "dlc", "dlc-manager", "dlc-messages", "dlc-trie", @@ -3414,6 +3415,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "tempfile", + "time 0.3.20", "tokio", "tracing", "tracing-subscriber", diff --git a/coordinator/Cargo.toml b/coordinator/Cargo.toml index 0532923d3..2f77c3d36 100644 --- a/coordinator/Cargo.toml +++ b/coordinator/Cargo.toml @@ -15,6 +15,7 @@ console-subscriber = "0.1.6" coordinator-commons = { path = "../crates/coordinator-commons" } diesel = { version = "2.0.0", features = ["r2d2", "postgres", "time", "uuid"] } diesel_migrations = "2.0.0" +dlc = "0.4.0" dlc-manager = { version = "0.4.0", features = ["use-serde"] } dlc-messages = "0.4.0" dlc-trie = "0.4.0" diff --git a/coordinator/migrations/2023-08-18-105400_rollover_dlc/down.sql b/coordinator/migrations/2023-08-18-105400_rollover_dlc/down.sql new file mode 100644 index 000000000..b466af083 --- /dev/null +++ b/coordinator/migrations/2023-08-18-105400_rollover_dlc/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +-- Note: There is no down migration for removing the `Rollover` variant that was added to `PositionState_Type` because it is not feasible to remove enum variants in the db! diff --git a/coordinator/migrations/2023-08-18-105400_rollover_dlc/up.sql b/coordinator/migrations/2023-08-18-105400_rollover_dlc/up.sql new file mode 100644 index 000000000..beb064e22 --- /dev/null +++ b/coordinator/migrations/2023-08-18-105400_rollover_dlc/up.sql @@ -0,0 +1,4 @@ +-- Your SQL goes here +ALTER TYPE "PositionState_Type" + ADD + VALUE IF NOT EXISTS 'Rollover'; diff --git a/coordinator/src/db/custom_types.rs b/coordinator/src/db/custom_types.rs index 71ac75d7e..54bad331b 100644 --- a/coordinator/src/db/custom_types.rs +++ b/coordinator/src/db/custom_types.rs @@ -44,6 +44,7 @@ impl ToSql for PositionState { PositionState::Open => out.write_all(b"Open")?, PositionState::Closing => out.write_all(b"Closing")?, PositionState::Closed => out.write_all(b"Closed")?, + PositionState::Rollover => out.write_all(b"Rollover")?, } Ok(IsNull::No) } @@ -55,6 +56,7 @@ impl FromSql for PositionState { b"Open" => Ok(PositionState::Open), b"Closing" => Ok(PositionState::Closing), b"Closed" => Ok(PositionState::Closed), + b"Rollover" => Ok(PositionState::Rollover), _ => Err("Unrecognized enum variant".into()), } } diff --git a/coordinator/src/db/positions.rs b/coordinator/src/db/positions.rs index 48fb21d89..b4edddc0a 100644 --- a/coordinator/src/db/positions.rs +++ b/coordinator/src/db/positions.rs @@ -3,6 +3,7 @@ use crate::schema::positions; use crate::schema::sql_types::ContractSymbolType; use crate::schema::sql_types::PositionStateType; use anyhow::bail; +use anyhow::ensure; use anyhow::Result; use autometrics::autometrics; use bitcoin::hashes::hex::ToHex; @@ -129,6 +130,25 @@ impl Position { Ok(()) } + pub fn set_position_to_open( + conn: &mut PgConnection, + trader_pubkey: String, + temporary_contract_id: ContractId, + ) -> Result<()> { + let affected_rows = diesel::update(positions::table) + .filter(positions::trader_pubkey.eq(trader_pubkey)) + .set(( + positions::position_state.eq(PositionState::Open), + positions::temporary_contract_id.eq(temporary_contract_id.to_hex()), + positions::update_timestamp.eq(OffsetDateTime::now_utc()), + )) + .execute(conn)?; + + ensure!(affected_rows > 0, "Could not set position to open"); + + Ok(()) + } + pub fn update_unrealized_pnl(conn: &mut PgConnection, id: i32, pnl: i64) -> Result<()> { let affected_rows = diesel::update(positions::table) .filter(positions::id.eq(id)) @@ -145,6 +165,25 @@ impl Position { Ok(()) } + pub fn rollover_position( + conn: &mut PgConnection, + trader_pubkey: String, + expiry_timestamp: &OffsetDateTime, + ) -> Result<()> { + let affected_rows = diesel::update(positions::table) + .filter(positions::trader_pubkey.eq(trader_pubkey)) + .set(( + positions::expiry_timestamp.eq(expiry_timestamp), + positions::position_state.eq(PositionState::Rollover), + positions::update_timestamp.eq(OffsetDateTime::now_utc()), + )) + .execute(conn)?; + + ensure!(affected_rows > 0, "Could not set position to rollover"); + + Ok(()) + } + /// inserts the given position into the db. Returns the position if successful #[autometrics] pub fn insert( @@ -226,6 +265,7 @@ impl From for NewPosition { pub enum PositionState { Open, Closing, + Rollover, Closed, } @@ -254,6 +294,7 @@ impl From<(PositionState, Option, Option)> for crate::position::models // `Closed` state pnl: realized_pnl.unwrap_or(0), }, + PositionState::Rollover => crate::position::models::PositionState::Rollover, } } } diff --git a/coordinator/src/lib.rs b/coordinator/src/lib.rs index ee19d05e0..e57a4e8b6 100644 --- a/coordinator/src/lib.rs +++ b/coordinator/src/lib.rs @@ -1,3 +1,15 @@ +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::response::Response; +use axum::Json; +use diesel::PgConnection; +use diesel_migrations::embed_migrations; +use diesel_migrations::EmbeddedMigrations; +use diesel_migrations::MigrationHarness; +use serde_json::json; + +mod rollover; + pub mod admin; pub mod cli; pub mod db; @@ -12,16 +24,6 @@ pub mod schema; pub mod settings; pub mod trade; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::response::Response; -use axum::Json; -use diesel::PgConnection; -use diesel_migrations::embed_migrations; -use diesel_migrations::EmbeddedMigrations; -use diesel_migrations::MigrationHarness; -use serde_json::json; - pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); pub fn run_migration(conn: &mut PgConnection) { diff --git a/coordinator/src/node.rs b/coordinator/src/node.rs index b4cb1f352..c7d157e24 100644 --- a/coordinator/src/node.rs +++ b/coordinator/src/node.rs @@ -28,6 +28,7 @@ use dlc_manager::payout_curve::RoundingInterval; use dlc_manager::payout_curve::RoundingIntervals; use dlc_manager::ChannelId; use dlc_manager::ContractId; +use dlc_messages::ChannelMessage; use dlc_messages::Message; use lightning::ln::channelmanager::ChannelDetails; use lightning::ln::PaymentHash; @@ -409,7 +410,7 @@ impl Node { "Processing message" ); - let resp = match msg { + let resp = match &msg { Message::OnChain(_) | Message::Channel(_) => self .inner .dlc_manager @@ -423,16 +424,23 @@ impl Node { Message::SubChannel(msg) => self .inner .sub_channel_manager - .on_sub_channel_message(&msg, &node_id) + .on_sub_channel_message(msg, &node_id) .with_context(|| { format!( "Failed to handle {} message from {node_id}", - sub_channel_message_name(&msg) + sub_channel_message_name(msg) ) })? .map(Message::SubChannel), }; + // todo(holzeis): It would be nice if dlc messages are also propagated via events, so the + // receiver can decide what events to process and we can skip this component specific logic + // here. + if let Message::Channel(ChannelMessage::RenewFinalize(r)) = msg { + self.finalize_rollover(r.channel_id)?; + } + if let Some(msg) = resp { tracing::info!( to = %node_id, diff --git a/coordinator/src/node/closed_positions.rs b/coordinator/src/node/closed_positions.rs index a730227a1..8233c1541 100644 --- a/coordinator/src/node/closed_positions.rs +++ b/coordinator/src/node/closed_positions.rs @@ -31,6 +31,11 @@ pub fn sync(node: Node) -> Result<()> { } }; + tracing::debug!( + ?position, + "Setting position to closed to match the contract state." + ); + if let Err(e) = db::positions::Position::set_position_to_closed(&mut conn, position.id, contract.pnl) { diff --git a/coordinator/src/position/models.rs b/coordinator/src/position/models.rs index 0027315de..5b0750500 100644 --- a/coordinator/src/position/models.rs +++ b/coordinator/src/position/models.rs @@ -38,6 +38,7 @@ pub enum PositionState { Closed { pnl: i64, }, + Rollover, } /// A trader's position diff --git a/coordinator/src/rollover.rs b/coordinator/src/rollover.rs new file mode 100644 index 000000000..803521f85 --- /dev/null +++ b/coordinator/src/rollover.rs @@ -0,0 +1,361 @@ +use crate::db; +use crate::node::Node; +use anyhow::bail; +use anyhow::Context; +use anyhow::Result; +use bitcoin::hashes::hex::ToHex; +use bitcoin::secp256k1::PublicKey; +use bitcoin::XOnlyPublicKey; +use dlc_manager::contract::contract_input::ContractInput; +use dlc_manager::contract::contract_input::ContractInputInfo; +use dlc_manager::contract::contract_input::OracleInput; +use dlc_manager::contract::Contract; +use dlc_manager::contract::ContractDescriptor; +use dlc_manager::ChannelId; +use std::str::FromStr; +use time::Duration; +use time::OffsetDateTime; +use trade::ContractSymbol; + +#[derive(Debug, Clone)] +struct Rollover { + counterparty_pubkey: PublicKey, + contract_descriptor: ContractDescriptor, + expiry_timestamp: OffsetDateTime, + margin_coordinator: u64, + margin_trader: u64, + contract_symbol: ContractSymbol, + oracle_pk: XOnlyPublicKey, +} + +impl Rollover { + pub fn new(contract: Contract) -> Result { + let contract = match contract { + Contract::Confirmed(contract) => contract, + _ => bail!( + "Cannot rollover a contract that is not 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, + )?; + + if expiry_timestamp < OffsetDateTime::now_utc() { + bail!("Cannot rollover an expired position"); + } + + let margin_coordinator = offered_contract.offer_params.collateral; + let margin_trader = offered_contract.total_collateral - margin_coordinator; + + Ok(Rollover { + counterparty_pubkey: offered_contract.counter_party, + contract_descriptor: contract_info.clone().contract_descriptor, + expiry_timestamp, + margin_coordinator, + margin_trader, + oracle_pk: oracle_announcement.oracle_public_key, + contract_symbol: ContractSymbol::from_str( + &oracle_announcement.oracle_event.event_id[..6], + )?, + }) + } + + pub fn event_id(&self) -> String { + let maturity_time = self.maturity_time().unix_timestamp(); + format!("{}{maturity_time}", self.contract_symbol) + } + + /// Calculates the maturity time based on the current expiry timestamp. + /// + /// todo(holzeis): this should come from a configuration https://github.com/get10101/10101/issues/1029 + pub fn maturity_time(&self) -> OffsetDateTime { + let tomorrow = self.expiry_timestamp.date() + Duration::days(2); + tomorrow.midnight().assume_utc() + } +} + +impl Node { + /// Initiates the rollover protocol with the app. + pub async fn propose_rollover(&self, dlc_channel_id: ChannelId) -> Result<()> { + let contract = self.inner.get_contract_by_dlc_channel_id(dlc_channel_id)?; + let rollover = Rollover::new(contract)?; + + tracing::debug!(?rollover, "Rollover dlc channel"); + + let contract_input: ContractInput = rollover.clone().into(); + + // As the average entry price does not change with a rollover, we can simply use the traders + // margin as payout here. The funding rate should be considered here once https://github.com/get10101/10101/issues/1069 gets implemented. + self.inner + .propose_dlc_channel_update(&dlc_channel_id, rollover.margin_trader, contract_input) + .await?; + + // Sets the position state to rollover indicating that a rollover is in progress. + let mut connection = self.pool.get()?; + db::positions::Position::rollover_position( + &mut connection, + rollover.counterparty_pubkey.to_string(), + &rollover.maturity_time(), + ) + } + + /// Finalizes the rollover protocol with the app setting the position to open. + pub fn finalize_rollover(&self, dlc_channel_id: ChannelId) -> Result<()> { + tracing::debug!( + "Finalizing rollover for dlc channel: {}", + dlc_channel_id.to_hex() + ); + let contract = self.inner.get_contract_by_dlc_channel_id(dlc_channel_id)?; + + let mut connection = self.pool.get()?; + db::positions::Position::set_position_to_open( + &mut connection, + contract.get_counter_party_id().to_string(), + contract.get_temporary_id(), + ) + } +} + +impl From for ContractInput { + fn from(rollover: Rollover) -> Self { + ContractInput { + offer_collateral: rollover.margin_coordinator, + accept_collateral: rollover.margin_trader, + fee_rate: ln_dlc_node::CONTRACT_TX_FEE_RATE, + contract_infos: vec![ContractInputInfo { + contract_descriptor: rollover.clone().contract_descriptor, + oracles: OracleInput { + public_keys: vec![rollover.oracle_pk], + event_id: rollover.event_id(), + threshold: 1, + }, + }], + } + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + use bitcoin::secp256k1; + use bitcoin::secp256k1::ecdsa::Signature; + use bitcoin::PackedLockTime; + use bitcoin::Script; + use bitcoin::Transaction; + use dlc::DlcTransactions; + use dlc::PartyParams; + use dlc_manager::contract::accepted_contract::AcceptedContract; + use dlc_manager::contract::contract_info::ContractInfo; + use dlc_manager::contract::enum_descriptor::EnumDescriptor; + use dlc_manager::contract::offered_contract::OfferedContract; + use dlc_manager::contract::signed_contract::SignedContract; + use dlc_messages::oracle_msgs::EnumEventDescriptor; + use dlc_messages::oracle_msgs::EventDescriptor; + use dlc_messages::oracle_msgs::OracleAnnouncement; + use dlc_messages::oracle_msgs::OracleEvent; + use dlc_messages::FundingSignatures; + use rand::Rng; + + #[test] + fn test_new_rollover_from_signed_contract() { + let expiry_timestamp = OffsetDateTime::now_utc().unix_timestamp() + 10_000; + let contract = dummy_signed_contract(200, 100, expiry_timestamp as u32); + let rollover = Rollover::new(Contract::Confirmed(contract)).unwrap(); + assert_eq!(rollover.contract_symbol, ContractSymbol::BtcUsd); + assert_eq!(rollover.margin_trader, 100); + assert_eq!(rollover.margin_coordinator, 200); + } + + #[test] + fn test_new_rollover_from_other_contract() { + let expiry_timestamp = OffsetDateTime::now_utc().unix_timestamp() + 10_000; + assert!(Rollover::new(Contract::Offered(dummy_offered_contract( + 200, + 100, + expiry_timestamp as u32 + ))) + .is_err()) + } + + #[test] + fn test_event_id() { + // Thu Aug 17 2023 19:13:13 GMT+0000 + let expiry = OffsetDateTime::from_unix_timestamp(1692299593).unwrap(); + let rollover = Rollover { + counterparty_pubkey: dummy_pubkey(), + contract_descriptor: dummy_contract_descriptor(), + expiry_timestamp: expiry, + margin_coordinator: 0, + margin_trader: 0, + contract_symbol: ContractSymbol::BtcUsd, + oracle_pk: XOnlyPublicKey::from(dummy_pubkey()), + }; + let event_id = rollover.event_id(); + + // expect expiry in two days at midnight. + // Sat Aug 19 2023 00:00:00 GMT+0000 + assert_eq!(event_id, format!("btcusd1692403200")) + } + + #[test] + fn test_from_rollover_to_contract_input() { + let margin_trader = 123; + let margin_coordinator = 234; + let rollover = Rollover { + counterparty_pubkey: dummy_pubkey(), + contract_descriptor: dummy_contract_descriptor(), + expiry_timestamp: OffsetDateTime::from_unix_timestamp(1692299593).unwrap(), + margin_coordinator, + margin_trader, + contract_symbol: ContractSymbol::BtcUsd, + oracle_pk: XOnlyPublicKey::from(dummy_pubkey()), + }; + + let contract_input: ContractInput = rollover.into(); + assert_eq!(contract_input.accept_collateral, margin_trader); + assert_eq!(contract_input.offer_collateral, margin_coordinator); + assert_eq!(contract_input.contract_infos.len(), 1); + } + + #[test] + fn test_rollover_expired_position() { + let expiry_timestamp = OffsetDateTime::now_utc().unix_timestamp() - 10_000; + assert!(Rollover::new(Contract::Confirmed(dummy_signed_contract( + 200, + 100, + expiry_timestamp as u32 + ))) + .is_err()) + } + + fn dummy_signed_contract( + margin_coordinator: u64, + margin_trader: u64, + expiry_timestamp: u32, + ) -> SignedContract { + SignedContract { + accepted_contract: AcceptedContract { + offered_contract: dummy_offered_contract( + margin_coordinator, + margin_trader, + expiry_timestamp, + ), + accept_params: dummy_params(margin_trader), + funding_inputs: vec![], + adaptor_infos: vec![], + adaptor_signatures: None, + dlc_transactions: DlcTransactions { + fund: dummy_tx(), + cets: vec![], + refund: dummy_tx(), + funding_script_pubkey: Script::new(), + }, + accept_refund_signature: dummy_signature(), + }, + adaptor_signatures: None, + offer_refund_signature: dummy_signature(), + funding_signatures: FundingSignatures { + funding_signatures: vec![], + }, + channel_id: None, + } + } + + fn dummy_offered_contract( + margin_coordinator: u64, + margin_trader: u64, + expiry_timestamp: u32, + ) -> OfferedContract { + OfferedContract { + id: dummy_id(), + is_offer_party: false, + contract_info: vec![ContractInfo { + contract_descriptor: dummy_contract_descriptor(), + oracle_announcements: vec![OracleAnnouncement { + announcement_signature: dummy_schnorr_signature(), + oracle_public_key: XOnlyPublicKey::from(dummy_pubkey()), + oracle_event: OracleEvent { + oracle_nonces: vec![], + event_maturity_epoch: expiry_timestamp, + event_descriptor: EventDescriptor::EnumEvent(EnumEventDescriptor { + outcomes: vec![], + }), + event_id: format!("btcusd{expiry_timestamp}"), + }, + }], + threshold: 0, + }], + counter_party: dummy_pubkey(), + offer_params: dummy_params(margin_coordinator), + total_collateral: margin_coordinator + margin_trader, + funding_inputs_info: vec![], + fund_output_serial_id: 0, + fee_rate_per_vb: 0, + cet_locktime: 0, + refund_locktime: 0, + } + } + + fn dummy_pubkey() -> PublicKey { + PublicKey::from_str("02bd998ebd176715fe92b7467cf6b1df8023950a4dd911db4c94dfc89cc9f5a655") + .expect("valid pubkey") + } + + fn dummy_contract_descriptor() -> ContractDescriptor { + ContractDescriptor::Enum(EnumDescriptor { + outcome_payouts: vec![], + }) + } + + fn dummy_id() -> [u8; 32] { + let mut rng = rand::thread_rng(); + let dummy_id: [u8; 32] = rng.gen(); + dummy_id + } + + fn dummy_schnorr_signature() -> secp256k1::schnorr::Signature { + secp256k1::schnorr::Signature::from_str( + "84526253c27c7aef56c7b71a5cd25bebb66dddda437826defc5b2568bde81f0784526253c27c7aef56c7b71a5cd25bebb66dddda437826defc5b2568bde81f07", + ).unwrap() + } + + fn dummy_params(collateral: u64) -> PartyParams { + PartyParams { + collateral, + change_script_pubkey: Script::new(), + change_serial_id: 0, + fund_pubkey: dummy_pubkey(), + input_amount: 0, + inputs: vec![], + payout_script_pubkey: Script::new(), + payout_serial_id: 0, + } + } + + fn dummy_tx() -> Transaction { + Transaction { + version: 1, + lock_time: PackedLockTime::ZERO, + input: vec![], + output: vec![], + } + } + + fn dummy_signature() -> Signature { + Signature::from_str( + "304402202f2545f818a5dac9311157d75065156b141e5a6437e817d1d75f9fab084e46940220757bb6f0916f83b2be28877a0d6b05c45463794e3c8c99f799b774443575910d", + ).unwrap() + } +} diff --git a/coordinator/src/routes.rs b/coordinator/src/routes.rs index 4b87b6689..9c168a247 100644 --- a/coordinator/src/routes.rs +++ b/coordinator/src/routes.rs @@ -30,6 +30,7 @@ use axum::routing::get; use axum::routing::post; use axum::Json; use axum::Router; +use bitcoin::hashes::hex::ToHex; use bitcoin::secp256k1::PublicKey; use bitcoin::Network; use coordinator_commons::LspConfig; @@ -38,6 +39,8 @@ use coordinator_commons::TradeParams; use diesel::r2d2::ConnectionManager; use diesel::r2d2::Pool; use diesel::PgConnection; +use dlc_manager::ChannelId; +use hex::FromHex; use lightning::ln::msgs::NetAddress; use ln_dlc_node::node::peer_manager::alias_as_bytes; use ln_dlc_node::node::peer_manager::broadcast_node_announcement; @@ -112,6 +115,7 @@ pub fn router( ) .route("/api/orderbook/websocket", get(websocket_handler)) .route("/api/trade", post(post_trade)) + .route("/api/rollover/:dlc_channel_id", post(rollover)) .route("/api/register", post(post_register)) .route("/api/admin/balance", get(get_balance)) .route("/api/admin/channels", get(list_channels).post(open_channel)) @@ -260,6 +264,32 @@ pub async fn post_trade( Ok(invoice.to_string()) } +#[instrument(skip_all, err(Debug))] +#[autometrics] +pub async fn rollover( + State(state): State>, + Path(dlc_channel_id): Path, +) -> Result<(), AppError> { + let dlc_channel_id = ChannelId::from_hex(dlc_channel_id.clone()).map_err(|e| { + AppError::InternalServerError(format!( + "Could not decode dlc channel id from {dlc_channel_id}: {e:#}" + )) + })?; + + state + .node + .propose_rollover(dlc_channel_id) + .await + .map_err(|e| { + AppError::InternalServerError(format!( + "Failed to rollover dlc channel with id {}: {e:#}", + dlc_channel_id.to_hex() + )) + })?; + + Ok(()) +} + pub async fn post_broadcast_announcement( State(state): State>, ) -> Result<(), AppError> { diff --git a/crates/ln-dlc-node/src/node/dlc_channel.rs b/crates/ln-dlc-node/src/node/dlc_channel.rs index a5b09d582..c2dd5e15a 100644 --- a/crates/ln-dlc-node/src/node/dlc_channel.rs +++ b/crates/ln-dlc-node/src/node/dlc_channel.rs @@ -62,6 +62,37 @@ where .await? } + /// Updates the dlc channel with the given contract input and triggers the `RenewOffer` dlc + /// message. + /// + /// Note, this is only initiating the protocol and is only finished once the finalize messages + /// are exchanged. + pub async fn propose_dlc_channel_update( + &self, + dlc_channel_id: &[u8; 32], + payout_amount: u64, + contract_input: ContractInput, + ) -> Result<()> { + tracing::info!(channel_id = %hex::encode(dlc_channel_id), "Proposing a DLC channel update"); + spawn_blocking({ + let dlc_manager = self.dlc_manager.clone(); + let dlc_message_handler = self.dlc_message_handler.clone(); + let dlc_channel_id = *dlc_channel_id; + move || { + let (renew_offer, counterparty_pubkey) = + dlc_manager.renew_offer(&dlc_channel_id, payout_amount, &contract_input)?; + + dlc_message_handler.send_message( + counterparty_pubkey, + Message::Channel(ChannelMessage::RenewOffer(renew_offer)), + ); + Ok(()) + } + }) + .await + .map_err(|e| anyhow!("{e:#}"))? + } + #[autometrics] pub fn accept_dlc_channel_offer(&self, channel_id: &[u8; 32]) -> Result<()> { let channel_id_hex = hex::encode(channel_id); @@ -281,6 +312,35 @@ where Ok(dlc_channel.cloned()) } + /// Fetches the contract for a given dlc channel id + #[autometrics] + pub fn get_contract_by_dlc_channel_id(&self, dlc_channel_id: ChannelId) -> Result { + let dlc_channel = self + .dlc_manager + .get_store() + .get_channel(&dlc_channel_id)? + .with_context(|| { + format!( + "Could not find dlc channel by channel id: {}", + dlc_channel_id.to_hex() + ) + })?; + + let contract_id = dlc_channel + .get_contract_id() + .context("Could not find contract id")?; + + self.dlc_manager + .get_store() + .get_contract(&contract_id)? + .with_context(|| { + format!( + "Couldn't find dlc channel with id: {}", + dlc_channel_id.to_hex() + ) + }) + } + #[cfg(test)] #[autometrics] pub fn process_incoming_messages(&self) -> Result<()> { @@ -288,6 +348,7 @@ where let dlc_manager = &self.dlc_manager; let sub_channel_manager = &self.sub_channel_manager; let messages = dlc_message_handler.get_and_clear_received_messages(); + tracing::debug!("Received and cleared {} messages", messages.len()); for (node_id, msg) in messages { match msg { diff --git a/crates/tests-e2e/Cargo.toml b/crates/tests-e2e/Cargo.toml index 07f26562b..f4208c6a1 100644 --- a/crates/tests-e2e/Cargo.toml +++ b/crates/tests-e2e/Cargo.toml @@ -21,6 +21,7 @@ serde = { version = "1.0.152", features = ["serde_derive"] } serde_json = "1" serde_urlencoded = "0.7.1" tempfile = "3.6.0" +time = { version = "0.3", features = ["serde", "serde-well-known"] } tokio = { version = "1", default-features = false, features = ["io-util", "macros", "rt", "rt-multi-thread", "sync", "net", "time", "tracing"] } tracing = "0.1.37" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/crates/tests-e2e/src/coordinator.rs b/crates/tests-e2e/src/coordinator.rs index fc07d064f..f7d4df74c 100644 --- a/crates/tests-e2e/src/coordinator.rs +++ b/crates/tests-e2e/src/coordinator.rs @@ -139,6 +139,11 @@ impl Coordinator { .await } + pub async fn rollover(&self, dlc_channel_id: &str) -> Result { + self.post(format!("/api/rollover/{dlc_channel_id}").as_str()) + .await + } + async fn get(&self, path: &str) -> Result { self.client .get(format!("{0}{path}", self.host)) diff --git a/crates/tests-e2e/tests/rollover_position.rs b/crates/tests-e2e/tests/rollover_position.rs new file mode 100644 index 000000000..98b5f9703 --- /dev/null +++ b/crates/tests-e2e/tests/rollover_position.rs @@ -0,0 +1,58 @@ +use native::api; +use native::trade::position; +use position::PositionState; +use tests_e2e::app::AppHandle; +use tests_e2e::setup; +use tests_e2e::wait_until; +use time::Duration; +use time::OffsetDateTime; + +#[tokio::test] +#[ignore] +async fn can_rollover_position() { + let test = setup::TestSetup::new_with_open_position().await; + let coordinator = &test.coordinator; + let dlc_channels = coordinator.get_dlc_channels().await.unwrap(); + let app_pubkey = api::get_node_id().0; + + tracing::info!("{:?}", dlc_channels); + + let dlc_channel = dlc_channels + .into_iter() + .find(|chan| chan.counter_party == app_pubkey) + .unwrap(); + + let position = test.app.rx.position().expect("position to exist"); + let tomorrow = position.expiry.date() + Duration::days(2); + let new_expiry = tomorrow.midnight().assume_utc(); + + coordinator + .rollover(&dlc_channel.dlc_channel_id.unwrap()) + .await + .unwrap(); + + wait_until!(check_rollover_position(&test.app, new_expiry)); + wait_until!(test + .app + .rx + .position() + .map(|p| PositionState::Open == p.position_state) + .unwrap_or(false)); +} + +fn check_rollover_position(app: &AppHandle, new_expiry: OffsetDateTime) -> bool { + let position = app.rx.position().expect("position to exist"); + tracing::debug!( + "expect {:?} to be {:?}", + position.position_state, + PositionState::Rollover + ); + tracing::debug!( + "expect {} to be {}", + position.expiry.unix_timestamp(), + new_expiry.unix_timestamp() + ); + + PositionState::Rollover == position.position_state + && new_expiry.unix_timestamp() == position.expiry.unix_timestamp() +} diff --git a/mobile/lib/features/trade/domain/position.dart b/mobile/lib/features/trade/domain/position.dart index 603697721..85ce81e37 100644 --- a/mobile/lib/features/trade/domain/position.dart +++ b/mobile/lib/features/trade/domain/position.dart @@ -1,14 +1,15 @@ +import 'package:get_10101/bridge_generated/bridge_definitions.dart' as bridge; +import 'package:get_10101/common/domain/model.dart'; import 'package:get_10101/features/trade/domain/contract_symbol.dart'; import 'package:get_10101/features/trade/domain/direction.dart'; import 'package:get_10101/features/trade/domain/leverage.dart'; -import 'package:get_10101/common/domain/model.dart'; -import 'package:get_10101/bridge_generated/bridge_definitions.dart' as bridge; enum PositionState { open, /// once the user pressed button to close position the button should be disabled otherwise the user can click it multiple times which would result in multiple orders and an open position in the other direction - closing; + closing, + rollover; static PositionState fromApi(bridge.PositionState positionState) { switch (positionState) { @@ -16,6 +17,8 @@ enum PositionState { return PositionState.open; case bridge.PositionState.Closing: return PositionState.closing; + case bridge.PositionState.Rollover: + return PositionState.rollover; } } } diff --git a/mobile/native/src/db/custom_types.rs b/mobile/native/src/db/custom_types.rs index f10a491a3..4df3aa348 100644 --- a/mobile/native/src/db/custom_types.rs +++ b/mobile/native/src/db/custom_types.rs @@ -155,6 +155,7 @@ impl ToSql for PositionState { let text = match *self { PositionState::Open => "Open", PositionState::Closing => "Closing", + PositionState::Rollover => "Rollover", }; out.set_value(text); Ok(IsNull::No) @@ -168,6 +169,7 @@ impl FromSql for PositionState { return match string.as_str() { "Open" => Ok(PositionState::Open), "Closing" => Ok(PositionState::Closing), + "Rollover" => Ok(PositionState::Rollover), _ => Err("Unrecognized enum variant".into()), }; } diff --git a/mobile/native/src/db/mod.rs b/mobile/native/src/db/mod.rs index 3f35abe92..e1557563d 100644 --- a/mobile/native/src/db/mod.rs +++ b/mobile/native/src/db/mod.rs @@ -246,6 +246,17 @@ pub fn update_position_state( Ok(()) } +pub fn rollover_position( + contract_symbol: ::trade::ContractSymbol, + expiry_timestamp: OffsetDateTime, +) -> Result<()> { + let mut db = connection()?; + Position::rollover(&mut db, contract_symbol.into(), expiry_timestamp) + .context("Failed to rollover position")?; + + Ok(()) +} + pub fn insert_payment( payment_hash: lightning::ln::PaymentHash, info: ln_dlc_node::PaymentInfo, diff --git a/mobile/native/src/db/models.rs b/mobile/native/src/db/models.rs index 22da208bf..7d8574418 100644 --- a/mobile/native/src/db/models.rs +++ b/mobile/native/src/db/models.rs @@ -305,6 +305,7 @@ pub(crate) struct Position { pub enum PositionState { Open, Closing, + Rollover, } impl Position { @@ -343,6 +344,26 @@ impl Position { Ok(()) } + // sets the position to rollover and updates the new expiry timestamp. + pub fn rollover( + conn: &mut SqliteConnection, + contract_symbol: ContractSymbol, + expiry_timestamp: OffsetDateTime, + ) -> Result<()> { + let affected_rows = diesel::update(positions::table) + .filter(schema::positions::contract_symbol.eq(contract_symbol)) + .set(( + positions::expiry_timestamp.eq(expiry_timestamp.unix_timestamp()), + positions::state.eq(PositionState::Rollover), + positions::updated_timestamp.eq(OffsetDateTime::now_utc().unix_timestamp()), + )) + .execute(conn)?; + + ensure!(affected_rows > 0, "Could not set position to rollover"); + + Ok(()) + } + // TODO: This is obviously only for the MVP :) /// deletes all positions in the database pub fn delete_all(conn: &mut SqliteConnection) -> QueryResult { @@ -394,6 +415,7 @@ impl From for PositionState { match value { crate::trade::position::PositionState::Open => PositionState::Open, crate::trade::position::PositionState::Closing => PositionState::Closing, + crate::trade::position::PositionState::Rollover => PositionState::Rollover, } } } @@ -403,6 +425,7 @@ impl From for crate::trade::position::PositionState { match value { PositionState::Open => crate::trade::position::PositionState::Open, PositionState::Closing => crate::trade::position::PositionState::Closing, + PositionState::Rollover => crate::trade::position::PositionState::Rollover, } } } diff --git a/mobile/native/src/ln_dlc/node.rs b/mobile/native/src/ln_dlc/node.rs index d6257a03e..0af78ed35 100644 --- a/mobile/native/src/ln_dlc/node.rs +++ b/mobile/native/src/ln_dlc/node.rs @@ -1,13 +1,16 @@ use crate::db; use crate::trade::order; use crate::trade::position; +use crate::trade::position::PositionState; use anyhow::bail; use anyhow::Context; use anyhow::Result; use bdk::bitcoin::secp256k1::PublicKey; use bdk::TransactionDetails; +use bitcoin::hashes::hex::ToHex; use dlc_messages::sub_channel::SubChannelCloseFinalize; use dlc_messages::sub_channel::SubChannelRevoke; +use dlc_messages::ChannelMessage; use dlc_messages::Message; use dlc_messages::SubChannelMessage; use lightning::chain::keysinterface::DelayedPaymentOutputDescriptor; @@ -135,7 +138,7 @@ impl Node { "Processing message" ); - let resp = match msg { + let resp = match &msg { Message::OnChain(_) | Message::Channel(_) => self .inner .dlc_manager @@ -197,6 +200,37 @@ impl Node { } }; + // todo(holzeis): It would be nice if dlc messages are also propagated via events, so the + // receiver can decide what events to process and we can skip this component specific logic + // here. + if let Message::Channel(channel_message) = &msg { + match channel_message { + ChannelMessage::RenewOffer(r) => { + tracing::info!("Automatically accepting a rollover position"); + let (accept_renew_offer, counterparty_pubkey) = + self.inner.dlc_manager.accept_renew_offer(&r.channel_id)?; + + self.send_dlc_message( + counterparty_pubkey, + Message::Channel(ChannelMessage::RenewAccept(accept_renew_offer)), + )?; + + let expiry_timestamp = OffsetDateTime::from_unix_timestamp( + r.contract_info.get_closest_maturity_date() as i64, + )?; + position::handler::rollover_position(expiry_timestamp)?; + } + ChannelMessage::RenewRevoke(_) => { + tracing::info!("Finished rollover position"); + // After handling the `RenewRevoke` message, we need to do some post-processing + // based on the fact that the DLC channel has been updated. + position::handler::set_position_state(PositionState::Open)?; + } + // ignoring all other channel events. + _ => (), + } + } + // After handling the `Revoke` message, we need to do some post-processing based on the fact // that the DLC channel has been established if let Message::SubChannel(SubChannelMessage::Revoke(SubChannelRevoke { diff --git a/mobile/native/src/trade/order/handler.rs b/mobile/native/src/trade/order/handler.rs index 3ca1ba122..bedf93b8d 100644 --- a/mobile/native/src/trade/order/handler.rs +++ b/mobile/native/src/trade/order/handler.rs @@ -9,6 +9,7 @@ use crate::trade::order::Order; use crate::trade::order::OrderState; use crate::trade::position; use crate::trade::position::handler::update_position_after_order_submitted; +use crate::trade::position::PositionState; use anyhow::anyhow; use anyhow::bail; use anyhow::Context; @@ -35,7 +36,7 @@ pub async fn submit_order(order: Order) -> Result { let order_id = order.id.to_string(); tracing::error!(order_id, "Failed to post new order. Error: {err:#}"); update_order_state_in_db_and_ui(order.id, OrderState::Rejected)?; - if let Err(e) = position::handler::set_position_to_open() { + if let Err(e) = position::handler::set_position_state(PositionState::Open) { bail!("Could not reset position to open because of {e:#}"); } bail!("Could not post order to orderbook"); @@ -100,7 +101,7 @@ pub(crate) fn order_failed( update_order_state_in_db_and_ui(order_id, OrderState::Failed { reason })?; - if let Err(e) = position::handler::set_position_to_open() { + if let Err(e) = position::handler::set_position_state(PositionState::Open) { bail!("Could not reset position to open because of {e:#}"); } diff --git a/mobile/native/src/trade/position/api.rs b/mobile/native/src/trade/position/api.rs index da5876f40..5b81435ef 100644 --- a/mobile/native/src/trade/position/api.rs +++ b/mobile/native/src/trade/position/api.rs @@ -4,7 +4,7 @@ use trade::ContractSymbol; use trade::Direction; #[frb] -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Copy)] pub enum PositionState { /// The position is open /// @@ -14,7 +14,8 @@ pub enum PositionState { /// the position), the position is in state "Closing". /// /// Transitions: - /// Open->Closing + /// ->Open + /// Rollover->Open Open, /// The position is in the process of being closed /// @@ -22,7 +23,17 @@ pub enum PositionState { /// Once this order has been filled the "closed" the position is not shown in the user /// interface, so we don't have a "closed" state because no position data will be provided to /// the user interface. + /// Transitions: + /// Open->Closing Closing, + + /// The position is in rollover + /// + /// This is a technical intermediate state indicating that a rollover is currently in progress. + /// + /// Transitions: + /// Open->Rollover + Rollover, } #[frb] @@ -44,6 +55,7 @@ impl From for PositionState { match value { position::PositionState::Open => PositionState::Open, position::PositionState::Closing => PositionState::Closing, + position::PositionState::Rollover => PositionState::Rollover, } } } diff --git a/mobile/native/src/trade/position/handler.rs b/mobile/native/src/trade/position/handler.rs index f4412ba66..07ff09a2b 100644 --- a/mobile/native/src/trade/position/handler.rs +++ b/mobile/native/src/trade/position/handler.rs @@ -10,6 +10,7 @@ use crate::trade::order::OrderState; use crate::trade::order::OrderType; use crate::trade::position::Position; use crate::trade::position::PositionState; +use anyhow::bail; use anyhow::ensure; use anyhow::Context; use anyhow::Result; @@ -88,16 +89,28 @@ pub fn get_position_matching_order(order: &Order) -> Result> { }) } -/// Resets the position to open again -/// -/// This should be called if a went in a dirty state, e.g. the position is currently in -/// `PositionState::Closing` but we didn't find a match. -pub fn set_position_to_open() -> Result<()> { +/// Sets the position to the given state +pub fn set_position_state(state: PositionState) -> Result<()> { + if let Some(position) = db::get_positions()?.first() { + db::update_position_state(position.contract_symbol, state)?; + let mut position = position.clone(); + position.position_state = state; + event::publish(&EventInternal::PositionUpdateNotification(position)); + } + + Ok(()) +} + +pub fn rollover_position(expiry_timestamp: OffsetDateTime) -> Result<()> { if let Some(position) = db::get_positions()?.first() { - db::update_position_state(position.contract_symbol, PositionState::Open)?; + tracing::debug!("Setting position to rollover"); + db::rollover_position(position.contract_symbol, expiry_timestamp)?; let mut position = position.clone(); - position.position_state = PositionState::Open; + position.position_state = PositionState::Rollover; + position.expiry = expiry_timestamp; event::publish(&EventInternal::PositionUpdateNotification(position)); + } else { + bail!("Cannot rollover non-existing position"); } Ok(()) diff --git a/mobile/native/src/trade/position/mod.rs b/mobile/native/src/trade/position/mod.rs index 26f5de547..c1c364601 100644 --- a/mobile/native/src/trade/position/mod.rs +++ b/mobile/native/src/trade/position/mod.rs @@ -6,7 +6,7 @@ pub mod api; pub mod handler; pub mod subscriber; -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Copy)] pub enum PositionState { /// The position is open /// @@ -16,7 +16,8 @@ pub enum PositionState { /// the position), the position is in state "Closing". /// /// Transitions: - /// Open->Closing + /// ->Open + /// Rollover->Open Open, /// The position is in the process of being closed /// @@ -24,7 +25,17 @@ pub enum PositionState { /// Once this order has been filled the "closed" the position is not shown in the user /// interface, so we don't have a "closed" state because no position data will be provided to /// the user interface. + /// Transitions: + /// Open->Closing Closing, + + /// The position is in rollover + /// + /// This is a technical intermediate state indicating that a rollover is currently in progress. + /// + /// Transitions: + /// Open->Rollover + Rollover, } #[derive(Debug, Clone)]