Use create_spend to calculate amount left to select

close #822
This commit is contained in:
edouardparis 2023-12-11 15:00:40 +01:00
parent b5a3e78b38
commit 4ccecd1cdf
7 changed files with 149 additions and 114 deletions

2
gui/Cargo.lock generated
View File

@ -2431,7 +2431,7 @@ dependencies = [
[[package]] [[package]]
name = "liana" name = "liana"
version = "2.0.0" version = "2.0.0"
source = "git+https://github.com/wizardsardine/liana?branch=master#6151c57af492dacc8502b0ea1ec1cd04580e08dc" source = "git+https://github.com/wizardsardine/liana?branch=master#3e0f82a71e031604fcf8bb0fd757ae95908e8ca6"
dependencies = [ dependencies = [
"backtrace", "backtrace",
"bdk_coin_select", "bdk_coin_select",

View File

@ -1,7 +1,7 @@
use std::convert::From; use std::convert::From;
use std::io::ErrorKind; use std::io::ErrorKind;
use liana::{config::ConfigError, descriptors::LianaDescError}; use liana::{config::ConfigError, descriptors::LianaDescError, spend::SpendCreationError};
use crate::{ use crate::{
app::{settings::SettingsError, wallet::WalletError}, app::{settings::SettingsError, wallet::WalletError},
@ -16,6 +16,7 @@ pub enum Error {
Unexpected(String), Unexpected(String),
HardwareWallet(async_hwi::Error), HardwareWallet(async_hwi::Error),
Desc(LianaDescError), Desc(LianaDescError),
Spend(SpendCreationError),
} }
impl std::fmt::Display for Error { impl std::fmt::Display for Error {
@ -23,6 +24,7 @@ impl std::fmt::Display for Error {
match self { match self {
Self::Config(e) => write!(f, "{}", e), Self::Config(e) => write!(f, "{}", e),
Self::Wallet(e) => write!(f, "{}", e), Self::Wallet(e) => write!(f, "{}", e),
Self::Spend(e) => write!(f, "{}", e),
Self::Daemon(e) => match e { Self::Daemon(e) => match e {
DaemonError::Unexpected(e) => write!(f, "{}", e), DaemonError::Unexpected(e) => write!(f, "{}", e),
DaemonError::NoAnswer => write!(f, "Daemon did not answer"), DaemonError::NoAnswer => write!(f, "Daemon did not answer"),
@ -84,3 +86,9 @@ impl From<async_hwi::Error> for Error {
Error::HardwareWallet(error) Error::HardwareWallet(error)
} }
} }
impl From<SpendCreationError> for Error {
fn from(error: SpendCreationError) -> Self {
Error::Spend(error)
}
}

View File

@ -216,6 +216,8 @@ mod tests {
spend_info: None, spend_info: None,
is_immature: false, is_immature: false,
address: dummy_address.clone(), address: dummy_address.clone(),
derivation_index: 0.into(),
is_change: false,
}, },
Coin { Coin {
outpoint: bitcoin::OutPoint { txid, vout: 3 }, outpoint: bitcoin::OutPoint { txid, vout: 3 },
@ -224,6 +226,8 @@ mod tests {
spend_info: None, spend_info: None,
is_immature: false, is_immature: false,
address: dummy_address.clone(), address: dummy_address.clone(),
derivation_index: 1.into(),
is_change: false,
}, },
Coin { Coin {
outpoint: bitcoin::OutPoint { txid, vout: 0 }, outpoint: bitcoin::OutPoint { txid, vout: 0 },
@ -232,6 +236,8 @@ mod tests {
spend_info: None, spend_info: None,
is_immature: false, is_immature: false,
address: dummy_address.clone(), address: dummy_address.clone(),
derivation_index: 2.into(),
is_change: false,
}, },
Coin { Coin {
outpoint: bitcoin::OutPoint { txid, vout: 1 }, outpoint: bitcoin::OutPoint { txid, vout: 1 },
@ -240,6 +246,8 @@ mod tests {
spend_info: None, spend_info: None,
is_immature: false, is_immature: false,
address: dummy_address, address: dummy_address,
derivation_index: 3.into(),
is_change: false,
}, },
]); ]);

View File

@ -32,7 +32,7 @@ impl CreateSpendPanel {
current: 0, current: 0,
steps: vec![ steps: vec![
Box::new( Box::new(
step::DefineSpend::new(descriptor, coins, timelock) step::DefineSpend::new(network, descriptor, coins, timelock)
.with_coins_sorted(blockheight), .with_coins_sorted(blockheight),
), ),
Box::new(step::SaveSpend::new(wallet)), Box::new(step::SaveSpend::new(wallet)),
@ -54,7 +54,7 @@ impl CreateSpendPanel {
current: 0, current: 0,
steps: vec![ steps: vec![
Box::new( Box::new(
step::DefineSpend::new(descriptor, coins, timelock) step::DefineSpend::new(network, descriptor, coins, timelock)
.with_preselected_coins(preselected_coins) .with_preselected_coins(preselected_coins)
.with_coins_sorted(blockheight) .with_coins_sorted(blockheight)
.self_send(), .self_send(),

View File

@ -6,7 +6,10 @@ use iced::{Command, Subscription};
use liana::{ use liana::{
descriptors::LianaDescriptor, descriptors::LianaDescriptor,
miniscript::bitcoin::{ miniscript::bitcoin::{
self, address, psbt::Psbt, Address, Amount, Denomination, Network, OutPoint, self, address, psbt::Psbt, secp256k1, Address, Amount, Denomination, Network, OutPoint,
},
spend::{
create_spend, CandidateCoin, SpendCreationError, SpendOutputAddress, SpendTxFees, TxGetter,
}, },
}; };
@ -19,7 +22,7 @@ use crate::{
app::{cache::Cache, error::Error, message::Message, state::psbt, view, wallet::Wallet}, app::{cache::Cache, error::Error, message::Message, state::psbt, view, wallet::Wallet},
daemon::{ daemon::{
model::{remaining_sequence, Coin, SpendTx}, model::{remaining_sequence, Coin, SpendTx},
Daemon, DaemonError, Daemon,
}, },
}; };
@ -73,7 +76,9 @@ pub struct DefineSpend {
is_valid: bool, is_valid: bool,
is_duplicate: bool, is_duplicate: bool,
network: Network,
descriptor: LianaDescriptor, descriptor: LianaDescriptor,
curve: secp256k1::Secp256k1<secp256k1::VerifyOnly>,
timelock: u16, timelock: u16,
coins: Vec<(Coin, bool)>, coins: Vec<(Coin, bool)>,
coins_labels: HashMap<String, String>, coins_labels: HashMap<String, String>,
@ -85,7 +90,12 @@ pub struct DefineSpend {
} }
impl DefineSpend { impl DefineSpend {
pub fn new(descriptor: LianaDescriptor, coins: &[Coin], timelock: u16) -> Self { pub fn new(
network: Network,
descriptor: LianaDescriptor,
coins: &[Coin],
timelock: u16,
) -> Self {
let balance_available = coins let balance_available = coins
.iter() .iter()
.filter_map(|coin| { .filter_map(|coin| {
@ -99,7 +109,7 @@ impl DefineSpend {
let coins: Vec<(Coin, bool)> = coins let coins: Vec<(Coin, bool)> = coins
.iter() .iter()
.filter_map(|c| { .filter_map(|c| {
if c.spend_info.is_none() { if c.spend_info.is_none() && !c.is_immature {
Some((c.clone(), false)) Some((c.clone(), false))
} else { } else {
None None
@ -109,7 +119,9 @@ impl DefineSpend {
Self { Self {
balance_available, balance_available,
network,
descriptor, descriptor,
curve: secp256k1::Secp256k1::verification_only(),
timelock, timelock,
generated: None, generated: None,
coins, coins,
@ -175,110 +187,128 @@ impl DefineSpend {
} }
} }
} }
fn auto_select_coins(&mut self, daemon: Arc<dyn Daemon + Sync + Send>) { /// redraft calculates the amount left to select and auto selects coins
// Set non-input values in the same way as for user selection. /// if the user did not select a coin manually
let mut outputs: HashMap<Address<address::NetworkUnchecked>, u64> = HashMap::new(); fn redraft(&mut self, daemon: Arc<dyn Daemon + Sync + Send>) {
for recipient in &self.recipients { if !self.form_values_are_valid() || self.recipients.is_empty() {
outputs.insert( return;
Address::from_str(&recipient.address.value).expect("Checked before"),
recipient.amount().expect("Checked before"),
);
} }
let feerate_vb = self.feerate.value.parse::<u64>().unwrap_or(0);
// Create a spend with empty inputs in order to use auto-selection.
match daemon.create_spend_tx(&[], &outputs, feerate_vb) {
Ok(spend) => {
self.warning = None;
let selected_coins: Vec<OutPoint> = spend
.psbt
.unsigned_tx
.input
.iter()
.map(|c| c.previous_output)
.collect();
// Mark coins as selected.
for (coin, selected) in &mut self.coins {
*selected = selected_coins.contains(&coin.outpoint);
}
// As coin selection was successful, we can assume there is nothing left to select.
self.amount_left_to_select = Some(Amount::from_sat(0));
}
Err(e) => {
if let DaemonError::CoinSelectionError = e {
// For coin selection error (insufficient funds), do not make any changes to
// selected coins on screen and just show user how much is left to select.
// User can then either:
// - modify recipient amounts and/or feerate and let coin selection run again, or
// - select coins manually.
self.amount_left_to_select();
} else {
self.warning = Some(e.into());
}
}
}
}
fn amount_left_to_select(&mut self) {
// We need the feerate in order to compute the required amount of BTC to
// select. Return early if we don't to not do unnecessary computation.
let feerate = match self.feerate.value.parse::<u64>() {
Ok(f) => f,
Err(_) => {
self.amount_left_to_select = None;
return;
}
};
// The coins to be included in this transaction. let destinations: Vec<(SpendOutputAddress, Amount)> = self
let selected_coins: Vec<_> = self .recipients
.coins
.iter() .iter()
.filter_map(|(c, selected)| if *selected { Some(c) } else { None }) .map(|recipient| {
(
SpendOutputAddress {
addr: Address::from_str(&recipient.address.value)
.expect("Checked before")
.assume_checked(),
info: None,
},
Amount::from_sat(recipient.amount().expect("Checked before")),
)
})
.collect(); .collect();
// A dummy representation of the transaction that will be computed, for let coins: Vec<CandidateCoin> = if self.is_user_coin_selection {
// the purpose of computing its size in order to anticipate the fees needed. self.coins
// NOTE: we make the conservative estimation a change output will always be
// needed.
let tx_template = bitcoin::Transaction {
version: 2,
lock_time: bitcoin::blockdata::locktime::absolute::LockTime::ZERO,
input: selected_coins
.iter() .iter()
.map(|_| bitcoin::TxIn::default()) .filter_map(|(c, selected)| {
.collect(), if *selected {
output: self Some(CandidateCoin {
.recipients amount: c.amount,
.iter() outpoint: c.outpoint,
.filter_map(|recipient| { deriv_index: c.derivation_index,
if recipient.valid() { is_change: c.is_change,
Some(bitcoin::TxOut { sequence: None,
script_pubkey: Address::from_str(&recipient.address.value) must_select: *selected,
.unwrap()
.payload
.script_pubkey(),
value: recipient.amount().unwrap(),
}) })
} else { } else {
None None
} }
}) })
.collect(), .collect()
} else {
// For automated coin selection, only confirmed coins are considered
self.coins
.iter()
.filter_map(|(c, _)| {
if c.block_height.is_some() {
Some(CandidateCoin {
amount: c.amount,
outpoint: c.outpoint,
deriv_index: c.derivation_index,
is_change: c.is_change,
sequence: None,
must_select: false,
})
} else {
None
}
})
.collect()
}; };
// nValue size + scriptPubKey CompactSize + OP_0 + PUSH32 + <wit program>
const CHANGE_TXO_SIZE: usize = 8 + 1 + 1 + 1 + 32;
let satisfaction_vsize = self.descriptor.max_sat_weight() / 4;
let transaction_size =
tx_template.vsize() + satisfaction_vsize * tx_template.input.len() + CHANGE_TXO_SIZE;
// Now the calculation of the amount left to be selected by the user is a simple let dummy_address = self
// substraction between the value needed by the transaction to be created and the .descriptor
// value that was selected already. .change_descriptor()
let selected_amount = selected_coins.iter().map(|c| c.amount.to_sat()).sum(); .derive(0.into(), &self.curve)
let output_sum: u64 = tx_template.output.iter().map(|o| o.value).sum(); .address(self.network);
let needed_amount: u64 = transaction_size as u64 * feerate + output_sum;
self.amount_left_to_select = Some(Amount::from_sat( let feerate_vb = self.feerate.value.parse::<u64>().expect("Checked before");
needed_amount.saturating_sub(selected_amount), // Create a spend with empty inputs in order to use auto-selection.
)); match create_spend(
&self.descriptor,
&self.curve,
&mut DaemonTxGetter(&daemon),
&destinations,
&coins,
SpendTxFees::Regular(feerate_vb),
SpendOutputAddress {
addr: dummy_address,
info: None,
},
) {
Ok(spend) => {
self.warning = None;
if !self.is_user_coin_selection {
let selected_coins: Vec<OutPoint> = spend
.psbt
.unsigned_tx
.input
.iter()
.map(|c| c.previous_output)
.collect();
// Mark coins as selected.
for (coin, selected) in &mut self.coins {
*selected = selected_coins.contains(&coin.outpoint);
}
}
// As coin selection was successful, we can assume there is nothing left to select.
self.amount_left_to_select = Some(Amount::from_sat(0));
}
// For coin selection error (insufficient funds), do not make any changes to
// selected coins on screen and just show user how much is left to select.
// User can then either:
// - modify recipient amounts and/or feerate and let coin selection run again, or
// - select coins manually.
Err(SpendCreationError::CoinSelection(amount)) => {
self.amount_left_to_select = Some(Amount::from_sat(amount.missing));
}
Err(e) => {
self.warning = Some(e.into());
}
}
}
}
pub struct DaemonTxGetter<'a>(&'a Arc<dyn Daemon + Sync + Send>);
impl<'a> TxGetter for DaemonTxGetter<'a> {
fn get_tx(&mut self, txid: &bitcoin::Txid) -> Option<bitcoin::Transaction> {
self.0
.list_txs(&[*txid])
.ok()
.and_then(|mut txs| txs.transactions.pop().map(|tx| tx.tx))
} }
} }
@ -317,14 +347,11 @@ impl Step for DefineSpend {
if let Ok(value) = s.parse::<u64>() { if let Ok(value) = s.parse::<u64>() {
self.feerate.value = s; self.feerate.value = s;
self.feerate.valid = value != 0; self.feerate.valid = value != 0;
self.amount_left_to_select();
} else if s.is_empty() { } else if s.is_empty() {
self.feerate.value = "".to_string(); self.feerate.value = "".to_string();
self.feerate.valid = true; self.feerate.valid = true;
self.amount_left_to_select = None;
} else { } else {
self.feerate.valid = false; self.feerate.valid = false;
self.amount_left_to_select = None;
} }
self.warning = None; self.warning = None;
} }
@ -368,7 +395,6 @@ impl Step for DefineSpend {
coin.1 = !coin.1; coin.1 = !coin.1;
// Once user edits selection, auto-selection can no longer be used. // Once user edits selection, auto-selection can no longer be used.
self.is_user_coin_selection = true; self.is_user_coin_selection = true;
self.amount_left_to_select();
} }
} }
_ => {} _ => {}
@ -378,12 +404,7 @@ impl Step for DefineSpend {
// - all form values have been added and validated // - all form values have been added and validated
// - not a self-send // - not a self-send
// - user has not yet selected coins manually // - user has not yet selected coins manually
if self.form_values_are_valid() self.redraft(daemon);
&& !self.recipients.is_empty()
&& !self.is_user_coin_selection
{
self.auto_select_coins(daemon);
}
self.check_valid(); self.check_valid();
} }
Message::Psbt(res) => match res { Message::Psbt(res) => match res {

View File

@ -41,6 +41,7 @@ impl From<&Error> for WarningMessage {
Error::Unexpected(_) => WarningMessage("Unknown error".to_string()), Error::Unexpected(_) => WarningMessage("Unknown error".to_string()),
Error::HardwareWallet(_) => WarningMessage("Hardware wallet error".to_string()), Error::HardwareWallet(_) => WarningMessage("Hardware wallet error".to_string()),
Error::Desc(e) => WarningMessage(format!("Descriptor analysis error: '{}'.", e)), Error::Desc(e) => WarningMessage(format!("Descriptor analysis error: '{}'.", e)),
Error::Spend(e) => WarningMessage(format!("Spend creation error: '{}'.", e)),
} }
} }
} }

View File

@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet};
use super::{model::*, Daemon, DaemonError}; use super::{model::*, Daemon, DaemonError};
use liana::{ use liana::{
commands::{CommandError, LabelItem}, commands::LabelItem,
config::Config, config::Config,
miniscript::bitcoin::{address, psbt::Psbt, Address, OutPoint, Txid}, miniscript::bitcoin::{address, psbt::Psbt, Address, OutPoint, Txid},
DaemonControl, DaemonHandle, DaemonControl, DaemonHandle,
@ -90,10 +90,7 @@ impl Daemon for EmbeddedDaemon {
) -> Result<CreateSpendResult, DaemonError> { ) -> Result<CreateSpendResult, DaemonError> {
self.control()? self.control()?
.create_spend(destinations, coins_outpoints, feerate_vb, None) .create_spend(destinations, coins_outpoints, feerate_vb, None)
.map_err(|e| match e { .map_err(|e| DaemonError::Unexpected(e.to_string()))
CommandError::CoinSelectionError(_) => DaemonError::CoinSelectionError,
e => DaemonError::Unexpected(e.to_string()),
})
} }
fn rbf_psbt( fn rbf_psbt(