From c0af623a3671f82ef1c239aeb3257bba17f7e3dd Mon Sep 17 00:00:00 2001 From: Lucas Soriano del Pino Date: Wed, 31 Jan 2024 17:22:53 +1100 Subject: [PATCH] chore(tests-e2e): Reproduce some problems related to #1912 --- crates/ln-dlc-node/src/ldk_node_wallet.rs | 4 + crates/ln-dlc-node/src/ln_dlc_wallet.rs | 4 + crates/ln-dlc-node/src/node/wallet.rs | 6 + crates/tests-e2e/src/bitcoind.rs | 147 +++++++++++++++++- crates/tests-e2e/src/setup.rs | 63 +++++--- crates/tests-e2e/tests/basic.rs | 2 +- crates/tests-e2e/tests/open_position.rs | 20 +-- .../tests/open_position_small_utxos.rs | 95 +++++++++++ crates/tests-e2e/tests/reject_offer.rs | 5 +- mobile/native/src/api.rs | 4 + mobile/native/src/ln_dlc/mod.rs | 6 + 11 files changed, 312 insertions(+), 44 deletions(-) create mode 100644 crates/tests-e2e/tests/open_position_small_utxos.rs diff --git a/crates/ln-dlc-node/src/ldk_node_wallet.rs b/crates/ln-dlc-node/src/ldk_node_wallet.rs index 634b5e732..d5da8bc09 100644 --- a/crates/ln-dlc-node/src/ldk_node_wallet.rs +++ b/crates/ln-dlc-node/src/ldk_node_wallet.rs @@ -166,6 +166,10 @@ where .address) } + pub(crate) fn get_new_address(&self) -> Result
{ + Ok(self.bdk_lock().get_address(AddressIndex::New)?.address) + } + pub fn is_mine(&self, script: &Script) -> Result { Ok(self.bdk_lock().is_mine(script)?) } diff --git a/crates/ln-dlc-node/src/ln_dlc_wallet.rs b/crates/ln-dlc-node/src/ln_dlc_wallet.rs index 9b2235e8d..f62268860 100644 --- a/crates/ln-dlc-node/src/ln_dlc_wallet.rs +++ b/crates/ln-dlc-node/src/ln_dlc_wallet.rs @@ -122,6 +122,10 @@ impl LnDlcWallet { self.address_cache.read().clone() } + pub fn new_address(&self) -> Result
{ + self.ldk_wallet().get_new_address() + } + pub fn is_mine(&self, script: &Script) -> Result { self.ldk_wallet().is_mine(script) } diff --git a/crates/ln-dlc-node/src/node/wallet.rs b/crates/ln-dlc-node/src/node/wallet.rs index 259f4fafb..c060c13ed 100644 --- a/crates/ln-dlc-node/src/node/wallet.rs +++ b/crates/ln-dlc-node/src/node/wallet.rs @@ -59,6 +59,12 @@ impl Node { self.wallet.unused_address() } + pub fn get_new_address(&self) -> Result
{ + self.wallet + .new_address() + .context("Failed to get new address") + } + pub fn get_blockchain_height(&self) -> Result { self.wallet .get_blockchain_height() diff --git a/crates/tests-e2e/src/bitcoind.rs b/crates/tests-e2e/src/bitcoind.rs index 1bb649a8c..f3a533281 100644 --- a/crates/tests-e2e/src/bitcoind.rs +++ b/crates/tests-e2e/src/bitcoind.rs @@ -5,6 +5,7 @@ use bitcoin::Amount; use reqwest::Client; use reqwest::Response; use serde::Deserialize; +use serde_json::json; use std::time::Duration; /// A wrapper over the bitcoind HTTP API @@ -27,12 +28,7 @@ impl Bitcoind { /// Instructs `bitcoind` to generate to address. pub async fn mine(&self, n: u16) -> Result<()> { - #[derive(Deserialize, Debug)] - struct BitcoindResponse { - result: String, - } - - let response: BitcoindResponse = self + let response: GetNewAddressResponse = self .client .post(&self.host) .body(r#"{"jsonrpc": "1.0", "method": "getnewaddress", "params": []}"#.to_string()) @@ -75,6 +71,106 @@ impl Bitcoind { Ok(response) } + pub async fn send_multiple_utxos_to_address( + &self, + address_fn: F, + utxo_amount: Amount, + n_utxos: u64, + ) -> Result<()> + where + F: Fn() -> Address, + { + let total_amount = utxo_amount * n_utxos; + + let response: ListUnspentResponse = self + .client + .post(&self.host) + .body(r#"{"jsonrpc": "1.0", "method": "listunspent", "params": []}"#) + .send() + .await? + .json() + .await?; + + let utxo = response + .result + .iter() + // We try to find one UTXO that can cover the whole transaction. We could cover the + // amount with multiple UTXOs too, but this is simpler and will probably succeed. + .find(|utxo| utxo.spendable && utxo.amount >= total_amount) + .expect("to find UTXO to cover multi-payment"); + + let mut outputs = serde_json::value::Map::new(); + + for _ in 0..n_utxos { + let address = address_fn(); + outputs.insert(address.to_string(), json!(utxo_amount.to_btc())); + } + + let create_raw_tx_request = json!( + { + "jsonrpc": "1.0", + "method": "createrawtransaction", + "params": + [ + [ {"txid": utxo.txid, "vout": utxo.vout} ], + outputs + ] + } + ); + + let create_raw_tx_response: CreateRawTransactionResponse = self + .client + .post(&self.host) + .json(&create_raw_tx_request) + .send() + .await? + .json() + .await?; + + let sign_raw_tx_with_wallet_request = json!( + { + "jsonrpc": "1.0", + "method": "signrawtransactionwithwallet", + "params": [ create_raw_tx_response.result ] + } + ); + + let sign_raw_tx_with_wallet_response: SignRawTransactionWithWalletResponse = self + .client + .post(&self.host) + .json(&sign_raw_tx_with_wallet_request) + .send() + .await? + .json() + .await?; + + let send_raw_tx_request = json!( + { + "jsonrpc": "1.0", + "method": "sendrawtransaction", + "params": [ sign_raw_tx_with_wallet_response.result.hex, 0 ] + } + ); + + let send_raw_tx_response: SendRawTransactionResponse = self + .client + .post(&self.host) + .json(&send_raw_tx_request) + .send() + .await? + .json() + .await?; + + tracing::info!( + txid = %send_raw_tx_response.result, + %utxo_amount, + %n_utxos, + "Published multi-utxo transaction" + ); + + Ok(()) + } + pub async fn post(&self, endpoint: &str, body: Option) -> Result { let mut builder = self.client.post(endpoint.to_string()); if let Some(body) = body { @@ -88,3 +184,42 @@ impl Bitcoind { Ok(response) } } + +#[derive(Deserialize, Debug)] +struct GetNewAddressResponse { + result: String, +} + +#[derive(Deserialize, Debug)] +struct ListUnspentResponse { + result: Vec, +} + +#[derive(Deserialize, Debug)] +struct Utxo { + txid: String, + vout: usize, + #[serde(with = "bitcoin::util::amount::serde::as_btc")] + amount: Amount, + spendable: bool, +} + +#[derive(Deserialize, Debug)] +struct CreateRawTransactionResponse { + result: String, +} + +#[derive(Deserialize, Debug)] +struct SignRawTransactionWithWalletResponse { + result: SignRawTransactionWithWalletResponseBody, +} + +#[derive(Deserialize, Debug)] +struct SignRawTransactionWithWalletResponseBody { + hex: String, +} + +#[derive(Deserialize, Debug)] +struct SendRawTransactionResponse { + result: String, +} diff --git a/crates/tests-e2e/src/setup.rs b/crates/tests-e2e/src/setup.rs index 0e3ae46e6..60958eb7d 100644 --- a/crates/tests-e2e/src/setup.rs +++ b/crates/tests-e2e/src/setup.rs @@ -22,8 +22,7 @@ pub struct TestSetup { } impl TestSetup { - /// Start test with a running app and a funded wallet. - pub async fn new_after_funding(fund_amount: Option) -> Self { + pub async fn new() -> Self { init_tracing(); let client = init_reqwest(); @@ -35,17 +34,6 @@ impl TestSetup { assert!(coordinator.is_running().await); - // Ensure that the coordinator has a free UTXO available. - let address = coordinator.get_new_address().await.unwrap(); - - bitcoind - .send_to_address(&address, Amount::ONE_BTC) - .await - .unwrap(); - - bitcoind.mine(1).await.unwrap(); - coordinator.sync_node().await.unwrap(); - // App setup let app = run_app(None).await; @@ -62,37 +50,64 @@ impl TestSetup { "App should start with empty off-chain wallet" ); - let fund_amount = fund_amount.unwrap_or(Amount::ONE_BTC); + Self { + app, + coordinator, + bitcoind, + } + } + + pub async fn fund_coordinator(&self, amount: Amount) { + // Ensure that the coordinator has a free UTXO available. + let address = self.coordinator.get_new_address().await.unwrap(); + + self.bitcoind + .send_to_address(&address, amount) + .await + .unwrap(); + + self.bitcoind.mine(1).await.unwrap(); + self.coordinator.sync_node().await.unwrap(); + + // TODO: Get coordinator balance to verify this claim. + tracing::info!("Successfully funded coordinator"); + } + pub async fn fund_app(&self, fund_amount: Amount) { let address = api::get_unused_address(); let address = &address.0.parse().unwrap(); - bitcoind + self.bitcoind .send_to_address(address, fund_amount) .await .unwrap(); - bitcoind.mine(1).await.unwrap(); + self.bitcoind.mine(1).await.unwrap(); wait_until!({ refresh_wallet_info(); - app.rx.wallet_info().unwrap().balances.on_chain == fund_amount.to_sat() + self.app.rx.wallet_info().unwrap().balances.on_chain >= fund_amount.to_sat() }); - let on_chain_balance = app.rx.wallet_info().unwrap().balances.on_chain; + let on_chain_balance = self.app.rx.wallet_info().unwrap().balances.on_chain; tracing::info!(%fund_amount, %on_chain_balance, "Successfully funded app"); + } - Self { - app, - coordinator, - bitcoind, - } + /// Start test with a running app and a funded wallet. + pub async fn new_after_funding() -> Self { + let setup = Self::new().await; + + setup.fund_coordinator(Amount::ONE_BTC).await; + + setup.fund_app(Amount::ONE_BTC).await; + + setup } /// Start test with a running app with a funded wallet and an open position. pub async fn new_with_open_position() -> Self { - let setup = Self::new_after_funding(None).await; + let setup = Self::new_after_funding().await; let rx = &setup.app.rx; tracing::info!("Opening a position"); diff --git a/crates/tests-e2e/tests/basic.rs b/crates/tests-e2e/tests/basic.rs index f3d8f281e..53afb9d43 100644 --- a/crates/tests-e2e/tests/basic.rs +++ b/crates/tests-e2e/tests/basic.rs @@ -4,7 +4,7 @@ use tests_e2e::setup::TestSetup; #[tokio::test(flavor = "multi_thread")] #[ignore = "need to be run with 'just e2e' command"] async fn app_can_be_funded_with_bitcoind() -> Result<()> { - TestSetup::new_after_funding(None).await; + TestSetup::new_after_funding().await; Ok(()) } diff --git a/crates/tests-e2e/tests/open_position.rs b/crates/tests-e2e/tests/open_position.rs index c5a53eb0a..4b555d483 100644 --- a/crates/tests-e2e/tests/open_position.rs +++ b/crates/tests-e2e/tests/open_position.rs @@ -9,24 +9,20 @@ use tests_e2e::setup::TestSetup; use tests_e2e::wait_until; use tokio::task::spawn_blocking; -fn dummy_order() -> NewOrder { - NewOrder { +#[tokio::test(flavor = "multi_thread")] +#[ignore = "need to be run with 'just e2e' command"] +async fn can_open_position() { + let test = TestSetup::new_after_funding().await; + let app = &test.app; + + let order = NewOrder { leverage: 2.0, contract_symbol: ContractSymbol::BtcUsd, direction: api::Direction::Long, quantity: 1.0, order_type: Box::new(OrderType::Market), stable: false, - } -} - -#[tokio::test(flavor = "multi_thread")] -#[ignore = "need to be run with 'just e2e' command"] -async fn can_open_position() { - let test = TestSetup::new_after_funding(None).await; - let app = &test.app; - - let order = dummy_order(); + }; spawn_blocking({ let order = order.clone(); move || api::submit_order(order).unwrap() diff --git a/crates/tests-e2e/tests/open_position_small_utxos.rs b/crates/tests-e2e/tests/open_position_small_utxos.rs new file mode 100644 index 000000000..3cd0dac2c --- /dev/null +++ b/crates/tests-e2e/tests/open_position_small_utxos.rs @@ -0,0 +1,95 @@ +use bitcoin::Amount; +use native::api; +use native::api::calculate_margin; +use native::api::ContractSymbol; +use native::trade::order::api::NewOrder; +use native::trade::order::api::OrderType; +use native::trade::position::PositionState; +use rust_decimal::prelude::ToPrimitive; +use std::str::FromStr; +use tests_e2e::app::refresh_wallet_info; +use tests_e2e::setup::TestSetup; +use tests_e2e::wait_until; +use tokio::task::spawn_blocking; + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "need to be run with 'just e2e' command"] +#[should_panic] +async fn can_open_position_with_multiple_small_utxos() { + // Arrange + + let setup = TestSetup::new().await; + + setup.fund_coordinator(Amount::ONE_BTC).await; + + let app = &setup.app; + + // Fund app with multiple small UTXOs that can cover the required margin. + + let order = NewOrder { + leverage: 2.0, + contract_symbol: ContractSymbol::BtcUsd, + direction: api::Direction::Long, + quantity: 100.0, + order_type: Box::new(OrderType::Market), + stable: false, + }; + + // We take the ask price because the app is going long. + let ask_price = app + .rx + .prices() + .unwrap() + .get(&ContractSymbol::BtcUsd) + .unwrap() + .ask + .unwrap() + .to_f32() + .unwrap(); + + let margin_app = calculate_margin(ask_price, order.quantity, order.leverage).0; + + // We want to use small UTXOs. + let utxo_size = 1_000; + + let n_utxos = margin_app / utxo_size; + + // Double the number of UTXOs to cover costs beyond the margin i.e. fees. + let n_utxos = 2 * n_utxos; + + let address_fn = || bitcoin::Address::from_str(&api::get_new_address().unwrap()).unwrap(); + + setup + .bitcoind + .send_multiple_utxos_to_address(address_fn, Amount::from_sat(utxo_size), n_utxos) + .await + .unwrap(); + + let fund_amount = n_utxos * utxo_size; + + setup.bitcoind.mine(1).await.unwrap(); + + wait_until!({ + refresh_wallet_info(); + app.rx.wallet_info().unwrap().balances.on_chain >= fund_amount + }); + + // Act + + spawn_blocking({ + let order = order.clone(); + move || api::submit_order(order).unwrap() + }) + .await + .unwrap(); + + // Assert + + wait_until!(matches!( + app.rx.position(), + Some(native::trade::position::Position { + position_state: PositionState::Open, + .. + }) + )); +} diff --git a/crates/tests-e2e/tests/reject_offer.rs b/crates/tests-e2e/tests/reject_offer.rs index 3bcc8c371..8f1e0890c 100644 --- a/crates/tests-e2e/tests/reject_offer.rs +++ b/crates/tests-e2e/tests/reject_offer.rs @@ -14,7 +14,10 @@ use tokio::task::spawn_blocking; #[tokio::test(flavor = "multi_thread")] #[ignore = "need to be run with 'just e2e' command"] async fn reject_offer() { - let test = TestSetup::new_after_funding(Some(Amount::from_sat(250_000))).await; + let test = TestSetup::new().await; + test.fund_coordinator(Amount::ONE_BTC).await; + test.fund_app(Amount::from_sat(250_000)).await; + let app = &test.app; let invalid_order = NewOrder { diff --git a/mobile/native/src/api.rs b/mobile/native/src/api.rs index 2748094bf..af6738289 100644 --- a/mobile/native/src/api.rs +++ b/mobile/native/src/api.rs @@ -405,6 +405,10 @@ pub fn get_unused_address() -> SyncReturn { SyncReturn(ln_dlc::get_unused_address()) } +pub fn get_new_address() -> Result { + ln_dlc::get_new_address() +} + #[tokio::main(flavor = "current_thread")] pub async fn close_channel() -> Result<()> { ln_dlc::close_channel(false).await diff --git a/mobile/native/src/ln_dlc/mod.rs b/mobile/native/src/ln_dlc/mod.rs index 749c367dc..8ad652571 100644 --- a/mobile/native/src/ln_dlc/mod.rs +++ b/mobile/native/src/ln_dlc/mod.rs @@ -697,6 +697,12 @@ pub fn get_unused_address() -> String { state::get_node().inner.get_unused_address().to_string() } +pub fn get_new_address() -> Result { + let address = state::get_node().inner.get_new_address()?; + + Ok(address.to_string()) +} + pub async fn close_channel(is_force_close: bool) -> Result<()> { tracing::info!(force = is_force_close, "Offering to close a channel"); let node = state::try_get_node().context("failed to get ln dlc node")?;