Skip to content

Commit

Permalink
gui: auto-select coins for spend
Browse files Browse the repository at this point in the history
  • Loading branch information
jp1ac4 committed Nov 9, 2023
1 parent d5b7a3a commit 3ef2e9b
Show file tree
Hide file tree
Showing 7 changed files with 90 additions and 14 deletions.
8 changes: 7 additions & 1 deletion gui/Cargo.lock

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

2 changes: 1 addition & 1 deletion gui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ path = "src/main.rs"

[dependencies]
async-hwi = "0.0.12"
liana = { git = "https://github.com/wizardsardine/liana", branch = "master", default-features = false, features = ["nonblocking_shutdown"] }
liana = { git = "https://github.com/jp1ac4/liana", branch = "auto-coin-selection", default-features = false, features = ["nonblocking_shutdown"] }
liana_ui = { path = "ui" }
backtrace = "0.3"
base64 = "0.13"
Expand Down
1 change: 1 addition & 0 deletions gui/src/app/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ impl std::fmt::Display for Error {
DaemonError::Rpc(code, e) => {
write!(f, "[{:?}] {}", code, e)
}
DaemonError::CoinSelectionError => write!(f, "{}", e),
},
Self::Unexpected(e) => write!(f, "Unexpected error: {}", e),
Self::HardwareWallet(e) => write!(f, "{}", e),
Expand Down
80 changes: 70 additions & 10 deletions gui/src/app/state/spend/step.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use crate::{
app::{cache::Cache, error::Error, message::Message, state::psbt, view, wallet::Wallet},
daemon::{
model::{remaining_sequence, Coin, SpendTx},
Daemon,
Daemon, DaemonError,
},
};

Expand Down Expand Up @@ -67,6 +67,9 @@ pub trait Step {
pub struct DefineSpend {
balance_available: Amount,
recipients: Vec<Recipient>,
/// Will be `true` if coins for spend were manually selected by user.
/// Otherwise, will be `false` (including for self-send).
is_user_coin_selection: bool,
is_valid: bool,
is_duplicate: bool,

Expand Down Expand Up @@ -113,6 +116,7 @@ impl DefineSpend {
coins_labels: HashMap::new(),
batch_label: form::Value::default(),
recipients: vec![Recipient::default()],
is_user_coin_selection: false, // Start with auto-selection until user edits selection.
is_valid: false,
is_duplicate: false,
feerate: form::Value::default(),
Expand Down Expand Up @@ -151,25 +155,68 @@ impl DefineSpend {
self
}

fn check_valid(&mut self) {
self.is_valid = self.feerate.valid
fn form_values_are_valid(&self) -> bool {
self.feerate.valid
&& !self.feerate.value.is_empty()
&& (self.batch_label.valid || self.recipients.len() < 2);
&& (self.batch_label.valid || self.recipients.len() < 2)
// Recipients will be empty for self-send.
&& self.recipients.iter().all(|r| r.valid())
}

fn check_valid(&mut self) {
self.is_valid =
self.form_values_are_valid() && self.coins.iter().any(|(_, selected)| *selected);
self.is_duplicate = false;
if !self.coins.iter().any(|(_, selected)| *selected) {
self.is_valid = false;
}
for (i, recipient) in self.recipients.iter().enumerate() {
if !recipient.valid() {
self.is_valid = false;
}
if !self.is_duplicate && !recipient.address.value.is_empty() {
self.is_duplicate = self.recipients[..i]
.iter()
.any(|r| r.address.value == recipient.address.value);
}
}
}
fn auto_select_coins(&mut self, daemon: Arc<dyn Daemon + Sync + Send>) {
// Set non-input values in the same way as for user selection.
let mut outputs: HashMap<Address<address::NetworkUnchecked>, u64> = HashMap::new();
for recipient in &self.recipients {
outputs.insert(
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.
Expand Down Expand Up @@ -319,11 +366,24 @@ impl Step for DefineSpend {
view::CreateSpendMessage::SelectCoin(i) => {
if let Some(coin) = self.coins.get_mut(i) {
coin.1 = !coin.1;
// Once user edits selection, auto-selection can no longer be used.
self.is_user_coin_selection = true;
self.amount_left_to_select();
}
}
_ => {}
}

// Attempt to select coins automatically if:
// - all form values have been added and validated
// - not a self-send
// - user has not yet selected coins manually
if self.form_values_are_valid()
&& !self.recipients.is_empty()
&& !self.is_user_coin_selection
{
self.auto_select_coins(daemon);
}
self.check_valid();
}
Message::Psbt(res) => match res {
Expand Down
3 changes: 3 additions & 0 deletions gui/src/app/view/warning.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ impl From<&Error> for WarningMessage {
WarningMessage("Communication with Daemon failed".to_string())
}
DaemonError::DaemonStopped => WarningMessage("Daemon stopped".to_string()),
DaemonError::CoinSelectionError => {
WarningMessage("Error when selecting coins for spend".to_string())
}
},
Error::Unexpected(_) => WarningMessage("Unknown error".to_string()),
Error::HardwareWallet(_) => WarningMessage("Hardware wallet error".to_string()),
Expand Down
7 changes: 5 additions & 2 deletions gui/src/daemon/embedded.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet};

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

fn update_spend_tx(&self, psbt: &Psbt) -> Result<(), DaemonError> {
Expand Down
3 changes: 3 additions & 0 deletions gui/src/daemon/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ pub enum DaemonError {
Start(StartupError),
// Error if the client is not supported.
ClientNotSupported,
/// Error when selecting coins for spend.
CoinSelectionError,
}

impl std::fmt::Display for DaemonError {
Expand All @@ -42,6 +44,7 @@ impl std::fmt::Display for DaemonError {
Self::Unexpected(e) => write!(f, "Daemon unexpected error: {}", e),
Self::Start(e) => write!(f, "Daemon did not start: {}", e),
Self::ClientNotSupported => write!(f, "Daemon communication is not supported"),
Self::CoinSelectionError => write!(f, "Coin selection error"),
}
}
}
Expand Down

0 comments on commit 3ef2e9b

Please sign in to comment.