diff --git a/crates/tests-e2e/src/app.rs b/crates/tests-e2e/src/app.rs index 13f30e78f..036543cbb 100644 --- a/crates/tests-e2e/src/app.rs +++ b/crates/tests-e2e/src/app.rs @@ -94,6 +94,14 @@ pub fn force_close_dlc_channel() { block_in_place(move || api::force_close_channel().unwrap()); } +/// Get the ID of the currently open DLC channel, if there is one. +/// +/// To call this make sure that you are either outside of a runtime or in a multi-threaded runtime +/// (i.e. use `flavor = "multi_thread"` in a `tokio::test`). +pub fn get_dlc_channel_id() -> Option { + block_in_place(move || api::get_dlc_channel_id().unwrap()) +} + // Values mostly taken from `environment.dart` fn test_config() -> native::config::api::Config { native::config::api::Config { diff --git a/crates/tests-e2e/src/coordinator.rs b/crates/tests-e2e/src/coordinator.rs index f7a22f209..ef02ef3df 100644 --- a/crates/tests-e2e/src/coordinator.rs +++ b/crates/tests-e2e/src/coordinator.rs @@ -2,7 +2,9 @@ use anyhow::Context; use anyhow::Result; use bitcoin::Address; use reqwest::Client; +use rust_decimal::Decimal; use serde::Deserialize; +use serde::Serialize; /// A wrapper over the coordinator HTTP API. /// @@ -30,7 +32,7 @@ impl Coordinator { } pub async fn sync_node(&self) -> Result<()> { - self.post("/api/admin/sync").await?; + self.post::<()>("/api/admin/sync", None).await?; Ok(()) } @@ -43,10 +45,20 @@ impl Coordinator { } pub async fn rollover(&self, dlc_channel_id: &str) -> Result { - self.post(format!("/api/rollover/{dlc_channel_id}").as_str()) + self.post::<()>(format!("/api/rollover/{dlc_channel_id}").as_str(), None) .await } + pub async fn collaborative_revert( + &self, + request: CollaborativeRevertCoordinatorRequest, + ) -> Result<()> { + self.post("/api/admin/channels/revert", Some(request)) + .await?; + + Ok(()) + } + async fn get(&self, path: &str) -> Result { self.client .get(format!("{0}{path}", self.host)) @@ -57,9 +69,20 @@ impl Coordinator { .context("Coordinator did not return 200 OK") } - async fn post(&self, path: &str) -> Result { - self.client - .post(format!("{0}{path}", self.host)) + async fn post(&self, path: &str, body: Option) -> Result { + let request = self.client.post(format!("{0}{path}", self.host)); + + let request = match body { + Some(ref body) => { + let body = serde_json::to_string(body)?; + request + .header("Content-Type", "application/json") + .body(body) + } + None => request, + }; + + request .send() .await .context("Could not send POST request to coordinator")? @@ -112,3 +135,11 @@ pub enum SignedChannelState { Closing, CollaborativeCloseOffered, } + +#[derive(Serialize)] +pub struct CollaborativeRevertCoordinatorRequest { + pub channel_id: String, + pub fee_rate_sats_vb: u64, + pub counter_payout: u64, + pub price: Decimal, +} diff --git a/crates/tests-e2e/src/setup.rs b/crates/tests-e2e/src/setup.rs index 03366bc09..2d74d6822 100644 --- a/crates/tests-e2e/src/setup.rs +++ b/crates/tests-e2e/src/setup.rs @@ -117,6 +117,8 @@ impl TestSetup { tokio::time::sleep(std::time::Duration::from_secs(10)).await; sync_dlc_channels(); + refresh_wallet_info(); + setup.coordinator.sync_node().await.unwrap(); setup diff --git a/crates/tests-e2e/tests/collaborative_revert.rs b/crates/tests-e2e/tests/collaborative_revert.rs new file mode 100644 index 000000000..e49ba5c34 --- /dev/null +++ b/crates/tests-e2e/tests/collaborative_revert.rs @@ -0,0 +1,87 @@ +#![allow(clippy::unwrap_used)] + +use native::api::PaymentFlow; +use native::api::WalletHistoryItemType; +use rust_decimal_macros::dec; +use tests_e2e::app::get_dlc_channel_id; +use tests_e2e::app::refresh_wallet_info; +use tests_e2e::app::sync_dlc_channels; +use tests_e2e::coordinator::CollaborativeRevertCoordinatorRequest; +use tests_e2e::setup; +use tests_e2e::wait_until; + +// Use `flavor = "multi_thread"` to be able to call `block_in_place`. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "need to be run with 'just e2e' command"] +async fn can_revert_channel() { + // Arrange + + let test = setup::TestSetup::new_with_open_position().await; + let coordinator = &test.coordinator; + let bitcoin = &test.bitcoind; + let app = &test.app; + + let position = app.rx.position().unwrap(); + let app_margin = position.collateral; + + let dlc_channel_id = get_dlc_channel_id().unwrap(); + + // let coordinator_balance_before = coordinator.get_balance().await.unwrap(); + let app_balance_before = app.rx.wallet_info().unwrap().balances.on_chain; + + app.rx.channel_status(); + + // Act + + let collaborative_revert_app_payout = app_margin / 2; + + coordinator + .collaborative_revert(CollaborativeRevertCoordinatorRequest { + channel_id: dlc_channel_id, + counter_payout: collaborative_revert_app_payout, + price: dec!(40_000), + fee_rate_sats_vb: 1, + }) + .await + .unwrap(); + + // Assert + + wait_until!({ + bitcoin.mine(1).await.unwrap(); + + sync_dlc_channels(); + refresh_wallet_info(); + + app.rx.wallet_info().unwrap().balances.on_chain > app_balance_before + }); + + let wallet_info = app.rx.wallet_info().unwrap(); + let collab_revert_entry = wallet_info + .history + .iter() + .filter(|entry| { + matches!(entry.flow, PaymentFlow::Inbound) + && matches!(entry.wallet_type, WalletHistoryItemType::OnChain { .. }) + }) + .max_by(|a, b| a.timestamp.cmp(&b.timestamp)) + .unwrap(); + + let total_tx_fee = match collab_revert_entry.wallet_type { + WalletHistoryItemType::OnChain { + fee_sats: Some(fee_sats), + .. + } => fee_sats, + _ => unreachable!(), + }; + + // The transaction fee for the collaborative revert transaction is split evenly among the two + // parties. + let tx_fee = total_tx_fee / 2; + + let expected_payout = collaborative_revert_app_payout - tx_fee; + + assert_eq!(collab_revert_entry.amount_sats, expected_payout); + + // TODO: Check coordinator balance too. +} diff --git a/mobile/native/src/api.rs b/mobile/native/src/api.rs index 0b3d3b269..82adecae8 100644 --- a/mobile/native/src/api.rs +++ b/mobile/native/src/api.rs @@ -27,6 +27,7 @@ use anyhow::ensure; use anyhow::Context; use anyhow::Result; use bdk::FeeRate; +use bitcoin::hashes::hex::ToHex; use bitcoin::Amount; use commons::order_matching_fee_taker; use commons::OrderbookRequest; @@ -707,3 +708,10 @@ pub fn get_expiry_timestamp(network: String) -> SyncReturn { let network = config::api::parse_network(&network); SyncReturn(commons::calculate_next_expiry(OffsetDateTime::now_utc(), network).unix_timestamp()) } + +pub fn get_dlc_channel_id() -> Result> { + let dlc_channel_id = + ln_dlc::get_signed_dlc_channel()?.map(|channel| channel.channel_id.to_hex()); + + Ok(dlc_channel_id) +}