diff --git a/Cargo.lock b/Cargo.lock index 337e954a8..450e9c6a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,6 +58,11 @@ dependencies = [ "byteorder", ] +[[package]] +name = "bdk_coin_select" +version = "0.1.0" +source = "git+https://github.com/LLFourn/bdk?branch=new_bdk_coin_select#9129b43f1ee75185f3cc3a0f27edcd00df59a61d" + [[package]] name = "bech32" version = "0.9.1" @@ -220,6 +225,7 @@ version = "1.0.0" dependencies = [ "backtrace", "base64", + "bdk_coin_select", "bip39", "dirs", "fern", diff --git a/Cargo.toml b/Cargo.toml index 155524d7c..82d5e001f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,8 @@ daemon = ["libc"] # For managing transactions (it re-exports the bitcoin crate) miniscript = { git = "https://github.com/darosior/rust-miniscript", branch = "multipath_descriptors_on_9.0", features = ["serde", "compiler"] } +bdk_coin_select = { git = "https://github.com/LLFourn/bdk", branch = "new_bdk_coin_select" } + # Don't reinvent the wheel dirs = "5.0" diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f125bf709..d91219716 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -11,8 +11,8 @@ use crate::{ }; use utils::{ - deser_amount_from_sats, deser_base64, deser_hex, ser_amount, ser_base64, ser_hex, - to_base64_string, + deser_amount_from_sats, deser_base64, deser_hex, select_coins_for_spend, ser_amount, + ser_base64, ser_hex, to_base64_string, }; use std::{ @@ -34,6 +34,9 @@ use serde::{Deserialize, Serialize}; // That's 1$ at 20_000$ per BTC. const DUST_OUTPUT_SATS: u64 = 5_000; +// TODO: should this be configurable by user? E.g. like bitcoin core's `-consolidatefeerate` option. +const LONG_TERM_FEERATE_VB: u64 = 10; + // Assume that paying more than 1BTC in fee is a bug. const MAX_FEE: u64 = bitcoin::blockdata::constants::COIN_VALUE; @@ -68,6 +71,7 @@ pub enum CommandError { /// An error that might occur in the racy rescan triggering logic. RescanTrigger(String), RecoveryNotAvailable, + CoinSelectionError(String), } impl fmt::Display for CommandError { @@ -132,6 +136,7 @@ impl fmt::Display for CommandError { f, "No coin currently spendable through this timelocked recovery path." ), + Self::CoinSelectionError(s) => write!(f, "Coin selection failed: '{}'", s), } } } @@ -328,13 +333,36 @@ impl DaemonControl { feerate_vb: u64, ) -> Result { let is_self_send = destinations.is_empty(); - if coins_outpoints.is_empty() { - return Err(CommandError::NoOutpoint); - } if feerate_vb < 1 { return Err(CommandError::InvalidFeerate(feerate_vb)); } let mut db_conn = self.db.connection(); + let selected_coins_outpoints: Vec; + let final_coins_outpoints: &[bitcoin::OutPoint] = if coins_outpoints.is_empty() { + log::info!("No outpoints specified. Selecting coins..."); + let candidate_coins: Vec = db_conn + .coins(CoinType::Unspent) + .into_iter() + .filter_map(|(_, coin)| { + if coin.block_info.is_some() { + Some(coin) + } else { + None + } + }) + .collect(); + let selected_coins = select_coins_for_spend( + candidate_coins, + destinations, + feerate_vb, + self.config.main_descriptor.max_sat_weight(), + ) + .map_err(|e| CommandError::CoinSelectionError(e.to_string()))?; + selected_coins_outpoints = selected_coins.iter().map(|c| c.outpoint).collect(); + &selected_coins_outpoints[..] + } else { + coins_outpoints + }; // Iterate through given outpoints to fetch the coins (hence checking their existence // at the same time). We checked there is at least one, therefore after this loop the @@ -344,11 +372,11 @@ impl DaemonControl { let mut in_value = bitcoin::Amount::from_sat(0); let txin_sat_vb = self.config.main_descriptor.max_sat_vbytes(); let mut sat_vb = 0; - let mut txins = Vec::with_capacity(coins_outpoints.len()); - let mut psbt_ins = Vec::with_capacity(coins_outpoints.len()); - let mut spent_txs = HashMap::with_capacity(coins_outpoints.len()); - let coins = db_conn.coins_by_outpoints(coins_outpoints); - for op in coins_outpoints { + let mut txins = Vec::with_capacity(final_coins_outpoints.len()); + let mut psbt_ins = Vec::with_capacity(final_coins_outpoints.len()); + let mut spent_txs = HashMap::with_capacity(final_coins_outpoints.len()); + let coins = db_conn.coins_by_outpoints(final_coins_outpoints); + for op in final_coins_outpoints { // Get the coin from our in-DB unspent txos let coin = coins.get(op).ok_or(CommandError::UnknownOutpoint(*op))?; if coin.is_spent() { @@ -953,7 +981,9 @@ mod tests { .collect(); assert_eq!( control.create_spend(&destinations, &[], 1), - Err(CommandError::NoOutpoint) + Err(CommandError::CoinSelectionError( + "No candidate coins have been provided.".to_string() + )) ); assert_eq!( control.create_spend(&destinations, &[dummy_op], 0), diff --git a/src/commands/utils.rs b/src/commands/utils.rs index 85ac95eab..c4eb31214 100644 --- a/src/commands/utils.rs +++ b/src/commands/utils.rs @@ -1,6 +1,16 @@ +use std::{collections::HashMap, error, fmt}; + +use bdk_coin_select::{ + change_policy, metrics::Waste, Candidate, CoinSelector, Drain, FeeRate, Target, + TXIN_BASE_WEIGHT, TXOUT_BASE_WEIGHT, +}; use miniscript::bitcoin::{self, consensus, hashes::hex::FromHex}; use serde::{de, Deserialize, Deserializer, Serializer}; +use crate::database::Coin; + +use super::{DUST_OUTPUT_SATS, LONG_TERM_FEERATE_VB}; + /// Serialize an amount as sats pub fn ser_amount(amount: &bitcoin::Amount, s: S) -> Result { s.serialize_u64(amount.to_sat()) @@ -54,3 +64,113 @@ where let s = Vec::from_hex(&s).map_err(de::Error::custom)?; consensus::deserialize(&s).map_err(de::Error::custom) } + +#[derive(Debug)] +pub enum CoinSelectionError { + // TODO: Required? + NoCandidates, + // TODO: Required? + FundsSanityCheckFailed( + /* available */ bitcoin::Amount, + /* required */ bitcoin::Amount, + ), + UnableToSelectCoins, +} + +impl std::fmt::Display for CoinSelectionError { + fn fmt(&self, f: &mut fmt::Formatter) -> std::fmt::Result { + match self { + Self::NoCandidates => write!(f, "No candidate coins have been provided."), + Self::FundsSanityCheckFailed(available_amt, required_amt) => write!( + f, + "Candidate coins have total value {}, but required value is {}.", + available_amt, required_amt + ), + Self::UnableToSelectCoins => write!(f, "Coin selection was not possible"), + } + } +} + +impl error::Error for CoinSelectionError {} + +/// Select coins for spend. +pub fn select_coins_for_spend( + candidate_coins: Vec, + destinations: &HashMap, + feerate_vb: u64, + max_satisfaction_weight: usize, +) -> Result, CoinSelectionError> { + // TODO: Do we need to perform these checks or just let coin selector run? + if candidate_coins.is_empty() { + return Err(CoinSelectionError::NoCandidates); + } + let available_amt: bitcoin::Amount = candidate_coins.iter().map(|c| c.amount).sum(); + let required_amt = destinations + .values() + .map(|v| bitcoin::Amount::from_sat(*v)) + .sum(); + if available_amt < required_amt { + return Err(CoinSelectionError::FundsSanityCheckFailed( + available_amt, + required_amt, + )); + } + let max_input_weight = TXIN_BASE_WEIGHT + max_satisfaction_weight as u32; + let candidates: Vec = candidate_coins + .iter() + .map(|coin| Candidate { + input_count: 1, + value: coin.amount.to_sat(), + weight: max_input_weight, + is_segwit: true, // Liana only supports receiving on Segwit scripts. + }) + .collect(); + let feerate = FeeRate::from_sat_per_vb(feerate_vb as f32); + let long_term_feerate = FeeRate::from_sat_per_vb(LONG_TERM_FEERATE_VB as f32); + let target = Target { + value: destinations.values().sum(), + feerate, + min_fee: 0, // Non-zero value only required for replacement transactions. + }; + let drain = Drain { + weight: TXOUT_BASE_WEIGHT, + spend_weight: max_input_weight, + value: 0, // This is ignored when using `change_policy::min_value` below. + }; + // Ensure any change output is not too small. + let change_policy = change_policy::min_value(drain, DUST_OUTPUT_SATS); + + // Transaction base weight calculated from transaction with no inputs. + // Segwit marker flag and size of the witnesses are taken care of by `CoinSelector`. + let base_weight = bitcoin::Transaction { + input: Vec::new(), + output: destinations + .iter() + .map(|(address, value)| bitcoin::TxOut { + value: *value, + script_pubkey: address.script_pubkey(), + }) + .collect(), + lock_time: bitcoin::PackedLockTime(0), + version: 2, + } + .weight() as u32; + + let cs = CoinSelector::new(&candidates, base_weight); + // TODO: use our own custom metric function. + let solutions = cs + .branch_and_bound(Waste { + target, + long_term_feerate, + change_policy: &change_policy, + }) + .take(100_000); // Ensure iterator does not take too long or run forever, e.g. if there are no solutions. + + // TODO: use explicit API to handle different cases of solution being found (https://github.com/wizardsardine/liana/pull/560#discussion_r1236232805). + if let Some(Some(best)) = solutions.filter(|sol| sol.is_some()).last() { + return Ok(best.0.selected().map(|(i, _)| candidate_coins[i]).collect()); + } + // TODO: try to select coins by other means (via explicit API mentioned above). + // If still not possible to select coins, return error. + Err(CoinSelectionError::UnableToSelectCoins) +} diff --git a/src/jsonrpc/mod.rs b/src/jsonrpc/mod.rs index 132eca6fc..c3e80455b 100644 --- a/src/jsonrpc/mod.rs +++ b/src/jsonrpc/mod.rs @@ -168,6 +168,7 @@ impl From for Error { } commands::CommandError::FetchingTransaction(..) | commands::CommandError::SanityCheckFailure(_) + | commands::CommandError::CoinSelectionError(..) | commands::CommandError::RescanTrigger(..) => { Error::new(ErrorCode::InternalError, e.to_string()) }