Skip to content

Commit

Permalink
api: auto-select coins if none provided
Browse files Browse the repository at this point in the history
  • Loading branch information
jp1ac4 committed Jun 28, 2023
1 parent 3bea691 commit b561dd6
Show file tree
Hide file tree
Showing 5 changed files with 170 additions and 11 deletions.
6 changes: 6 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
52 changes: 41 additions & 11 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand All @@ -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;

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
}
}
}
Expand Down Expand Up @@ -328,13 +333,36 @@ impl DaemonControl {
feerate_vb: u64,
) -> Result<CreateSpendResult, CommandError> {
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<bitcoin::OutPoint>;
let final_coins_outpoints: &[bitcoin::OutPoint] = if coins_outpoints.is_empty() {
log::info!("No outpoints specified. Selecting coins...");
let candidate_coins: Vec<Coin> = 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
Expand All @@ -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() {
Expand Down Expand Up @@ -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),
Expand Down
120 changes: 120 additions & 0 deletions src/commands/utils.rs
Original file line number Diff line number Diff line change
@@ -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<S: Serializer>(amount: &bitcoin::Amount, s: S) -> Result<S::Ok, S::Error> {
s.serialize_u64(amount.to_sat())
Expand Down Expand Up @@ -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<Coin>,
destinations: &HashMap<bitcoin::Address, u64>,
feerate_vb: u64,
max_satisfaction_weight: usize,
) -> Result<Vec<Coin>, 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> = 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)
}
1 change: 1 addition & 0 deletions src/jsonrpc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ impl From<commands::CommandError> for Error {
}
commands::CommandError::FetchingTransaction(..)
| commands::CommandError::SanityCheckFailure(_)
| commands::CommandError::CoinSelectionError(..)
| commands::CommandError::RescanTrigger(..) => {
Error::new(ErrorCode::InternalError, e.to_string())
}
Expand Down

0 comments on commit b561dd6

Please sign in to comment.