diff --git a/Cargo.lock b/Cargo.lock index 2523c0160..8e4d3cab9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -413,6 +413,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bdk_coin_select" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd50028fb9c48ce062859565fc59974cd1ee614f8db1c4bfc8c094ef1ffe6ff6" + [[package]] name = "bech32" version = "0.9.1" @@ -2209,6 +2215,7 @@ dependencies = [ "anyhow", "async-trait", "bdk", + "bdk_coin_select", "bip39", "bitcoin", "dlc", diff --git a/crates/ln-dlc-node/Cargo.toml b/crates/ln-dlc-node/Cargo.toml index 2442351c1..c6b632313 100644 --- a/crates/ln-dlc-node/Cargo.toml +++ b/crates/ln-dlc-node/Cargo.toml @@ -10,6 +10,7 @@ description = "A common interface for using Lightning and DLC channels side-by-s anyhow = { version = "1", features = ["backtrace"] } async-trait = "0.1.71" bdk = { version = "0.28.0", default-features = false, features = ["key-value-db", "use-esplora-blocking", "std"] } +bdk_coin_select = "0.2.0" bip39 = { version = "2", features = ["rand_core"] } bitcoin = "0.29.2" dlc = { version = "0.4.0" } diff --git a/crates/ln-dlc-node/src/ldk_node_wallet.rs b/crates/ln-dlc-node/src/ldk_node_wallet.rs index d5da8bc09..1981e450f 100644 --- a/crates/ln-dlc-node/src/ldk_node_wallet.rs +++ b/crates/ln-dlc-node/src/ldk_node_wallet.rs @@ -15,6 +15,12 @@ use bdk::FeeRate; use bdk::SignOptions; use bdk::SyncOptions; use bdk::TransactionDetails; +use bdk_coin_select::metrics::LowestFee; +use bdk_coin_select::Candidate; +use bdk_coin_select::ChangePolicy; +use bdk_coin_select::CoinSelector; +use bdk_coin_select::DrainWeights; +use bdk_coin_select::Target; use bitcoin::consensus::encode::serialize_hex; use bitcoin::psbt::PartiallySignedTransaction; use bitcoin::Address; @@ -23,13 +29,14 @@ use bitcoin::BlockHash; use bitcoin::OutPoint; use bitcoin::Script; use bitcoin::Transaction; +use bitcoin::TxIn; use bitcoin::Txid; +use bitcoin::VarInt; use dlc_manager::Utxo; use lightning::chain::chaininterface::BroadcasterInterface; use lightning::chain::chaininterface::ConfirmationTarget; use parking_lot::Mutex; use parking_lot::MutexGuard; -use rust_bitcoin_coin_selection::select_coins; use std::sync::Arc; use std::time::Instant; use tokio::sync::RwLock; @@ -49,6 +56,8 @@ where inner: Mutex>, settings: RwLock, fee_rate_estimator: Arc, + // Only cleared upon restart. This means that if a locked outpoint ends up unspent, it will + // remain locked until the binary is restarted. locked_outpoints: Mutex>, node_storage: Arc, } @@ -186,55 +195,118 @@ where pub fn get_utxos_for_dlc_funding_transaction( &self, amount: u64, - lock_utxos: bool, + fee_rate: Option, + should_lock_utxos: bool, ) -> Result> { - let utxos = self.get_utxos()?; - // get temporarily reserved utxo from in-memory storage + let network = { + let bdk = self.bdk_lock(); + bdk.network() + }; + + let fee_rate = fee_rate.map(|fee_rate| fee_rate as f32).unwrap_or_else(|| { + self.get_fee_rate(ConfirmationTarget::Normal) + .as_sat_per_vb() + }); + + // Get temporarily reserved UTXOs from in-memory storage. let mut reserved_outpoints = self.locked_outpoints.lock(); - // filter reserved utxos from all known utxos to not accidentally double spend and those who - // have actually been spent already + let utxos = self.get_utxos()?; + + // Filter out reserved and spent UTXOs to prevent double-spending attempts. let utxos = utxos .iter() .filter(|utxo| !reserved_outpoints.contains(&utxo.outpoint)) .filter(|utxo| !utxo.is_spent) .collect::>(); - let mut utxos = utxos - .into_iter() - .map(|x| UtxoWrap { - utxo: Utxo { - tx_out: x.txout.clone(), - outpoint: x.outpoint, - address: Address::from_script( - &x.txout.script_pubkey, - self.bdk_lock().network(), - ) - .expect("to be a valid address"), - redeem_script: Default::default(), - reserved: false, - }, + let candidates = utxos + .iter() + .map(|utxo| { + let tx_in = TxIn { + previous_output: utxo.outpoint, + ..Default::default() + }; + + // Inspired by `rust-bitcoin:0.30.2`. + let segwit_weight = { + let legacy_weight = { + let script_sig_size = tx_in.script_sig.len(); + (36 + VarInt(script_sig_size as u64).len() + script_sig_size + 4) * 4 + }; + + legacy_weight + tx_in.witness.serialized_len() + }; + + // The 10101 wallet always generates SegWit addresses. + // + // TODO: Rework this once we use Taproot. + let is_witness_program = true; + + Candidate::new(utxo.txout.value, segwit_weight as u32, is_witness_program) }) .collect::>(); - // select enough utxos for our needs - let selected_local_utxos = select_coins(amount, 20, &mut utxos).with_context(|| { - format!("Could not reach target of {amount} sats with given UTXO pool") - })?; - - // update our temporarily reserved utxos with the selected once. - // note: this storage is only cleared up on a restart, meaning, if the protocol - // fails later on, the utxos will remain reserved - if lock_utxos { - for utxo in selected_local_utxos.clone() { - reserved_outpoints.push(utxo.utxo.outpoint); + // This is a standard base weight (without inputs or change outputs) for on-chain DLCs. We + // assume that this value is still correct for DLC channels. + let funding_tx_base_weight = 212; + + let target = Target { + feerate: bdk_coin_select::FeeRate::from_sat_per_vb(fee_rate), + min_fee: 0, + value: amount, + }; + + let mut coin_selector = CoinSelector::new(&candidates, funding_tx_base_weight); + + let dust_limit = 0; + let long_term_feerate = bdk_coin_select::FeeRate::default_min_relay_fee(); + + let change_policy = ChangePolicy::min_value_and_waste( + DrainWeights::default(), + dust_limit, + target.feerate, + long_term_feerate, + ); + + let metric = LowestFee { + target, + long_term_feerate, + change_policy, + }; + + coin_selector + .run_bnb(metric, 100_000) + .context("Failed to select coins")?; + + debug_assert!(coin_selector.is_target_met(target)); + + let indices = coin_selector.selected_indices(); + + let mut selected_utxos: Vec = Vec::with_capacity(indices.len()); + for index in indices { + let utxo = &utxos[*index]; + + let address = Address::from_script(&utxo.txout.script_pubkey, network) + .expect("to be a valid address"); + + let utxo = Utxo { + tx_out: utxo.txout.clone(), + outpoint: utxo.outpoint, + address, + redeem_script: Script::new(), + reserved: false, + }; + + if should_lock_utxos { + // Add selected UTXOs to reserve to prevent future double-spend attempts. + reserved_outpoints.push(utxo.outpoint); } + + selected_utxos.push(utxo); } - Ok(selected_local_utxos - .into_iter() - .map(|utxo| utxo.utxo) - .collect()) + Ok(selected_utxos) } /// Build the PSBT for sending funds to a given script and signs it diff --git a/crates/ln-dlc-node/src/ln_dlc_wallet.rs b/crates/ln-dlc-node/src/ln_dlc_wallet.rs index f62268860..bf007c3f8 100644 --- a/crates/ln-dlc-node/src/ln_dlc_wallet.rs +++ b/crates/ln-dlc-node/src/ln_dlc_wallet.rs @@ -278,12 +278,12 @@ impl dlc_manager::Wallet for LnDlcWallet fn get_utxos_for_amount( &self, amount: u64, - _: Option, + fee_rate: Option, lock_utxos: bool, ) -> Result, Error> { let utxos = self .ldk_wallet() - .get_utxos_for_dlc_funding_transaction(amount, lock_utxos) + .get_utxos_for_dlc_funding_transaction(amount, fee_rate, lock_utxos) .map_err(|error| { Error::InvalidState(format!("Could not find utxos for amount: {error:?}")) })?; diff --git a/crates/tests-e2e/tests/open_position_small_utxos.rs b/crates/tests-e2e/tests/open_position_small_utxos.rs index 3cd0dac2c..86e23e58d 100644 --- a/crates/tests-e2e/tests/open_position_small_utxos.rs +++ b/crates/tests-e2e/tests/open_position_small_utxos.rs @@ -14,7 +14,6 @@ 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