diff --git a/Cargo.lock b/Cargo.lock index 6bdf5b904..c0445435f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -630,6 +630,7 @@ dependencies = [ "coordinator-commons", "diesel", "diesel_migrations", + "dlc", "dlc-manager", "dlc-messages", "dlc-trie", @@ -3412,6 +3413,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..237cc19ff 100644 --- a/coordinator/src/lib.rs +++ b/coordinator/src/lib.rs @@ -6,6 +6,7 @@ pub mod metrics; pub mod node; pub mod orderbook; pub mod position; +mod rollover; pub mod routes; pub mod routing_fee; pub mod schema; diff --git a/coordinator/src/node.rs b/coordinator/src/node.rs index bdb2bb93a..ce564a7a7 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; @@ -401,7 +402,7 @@ impl Node { "Processing message" ); - let resp = match msg { + let resp = match msg.clone() { Message::OnChain(_) | Message::Channel(_) => self .inner .dlc_manager @@ -425,6 +426,13 @@ impl Node { .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..c85e7b74a --- /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 signed. {:?}", + 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 78969c391..4b612bc88 100644 --- a/crates/tests-e2e/Cargo.toml +++ b/crates/tests-e2e/Cargo.toml @@ -20,6 +20,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 2613ad9d2..452fbb8ef 100644 --- a/crates/tests-e2e/src/coordinator.rs +++ b/crates/tests-e2e/src/coordinator.rs @@ -136,6 +136,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..766967bf1 --- /dev/null +++ b/crates/tests-e2e/tests/rollover_position.rs @@ -0,0 +1,39 @@ +use native::api; +use native::trade::position; +use position::PositionState; +use tests_e2e::setup; +use tests_e2e::wait_until; + +#[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(); + + coordinator + .rollover(&dlc_channel.dlc_channel_id.unwrap()) + .await + .unwrap(); + + wait_until!(test + .app + .rx + .position() + .map(|p| PositionState::Rollover == p.position_state) + .unwrap_or(false)); + wait_until!(test + .app + .rx + .position() + .map(|p| PositionState::Open == p.position_state) + .unwrap_or(false)); +} 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 d7d1fe25a..3fa0c6045 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; @@ -189,6 +192,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.clone() { + 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..546b235d8 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,27 @@ 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; 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)]