Merge #967: gui: add listcoins filters

1c99376860458f350c3289cd995716aca96c4d87 gui(app): cache unconfirmed & confirmed coins only (jp1ac4)
daf9f85cfd3ef77698773f9c86763f4bb5122b6a gui(spend): filter for unconfirmed & confirmed coins (jp1ac4)
982220da030d7f4f001374b54345c4703891a3ba gui(home): filter for unconfirmed & confirmed coins (jp1ac4)
85b053ff90d7eb679cd2c1e72a59249b3796400f gui(recovery): filter for unconfirmed & confirmed coins (jp1ac4)
c9fcfae9d46b81a56b731d95a51fa1e3ea812287 gui(recovery): filter coins by psbt outpoints (jp1ac4)
4509bd6baf380c59363a2042fad28796bd0b09ab gui(coins): filter for confirmed & unconfirmed (jp1ac4)
685f83bd0eef46d1448cdc2caf4eef2a7fa09b71 gui(transactions): remove unused list_coins (jp1ac4)
21f87047ac013c22bd405de584d655ad9cdb76f8 gui(transactions): filter coins by tx outpoints (jp1ac4)
ddd1e84700642abbe43d2086de66bcf6d43741fa gui(psbt): get conflicting txs from filtered coins (jp1ac4)
4641d9eb7bd5fd2f8b8ed053601ff296965ea552 gui(daemon): filter coins using spend txs prev outpoints (jp1ac4)
aa578ba1fd846e28ed4566be9069e35a5c22dc43 gui(daemon): filter coins for pending txs (jp1ac4)
46121590c36947d8eac2be4e9989e0b4e8615235 gui(daemon): filter outpoints from txs inputs & outputs (jp1ac4)
f3fdb968275db3987d7afaee3b1ba84e0020f8a9 gui(daemon): extract common function for historytxs (jp1ac4)
6c7ca2ccaebd8acd6629c7faf8e418cb50128677 gui(daemon): add optional filter to `list_coins` (jp1ac4)

Pull request description:

  This is to resolve https://github.com/wizardsardine/liana/issues/677.

  As well as adding the filters to the daemon interface, I've applied filters in separate commits to different sections of the GUI.

  This PR builds on changes from https://github.com/wizardsardine/liana/pull/958 and https://github.com/wizardsardine/liana/pull/965. The latter is required when filtering for pending transactions so that a coin whose spending txid changes (e.g. due to RBF) remains as spending.

ACKs for top commit:
  edouardparis:
    ACK 1c99376860458f350c3289cd995716aca96c4d87

Tree-SHA512: c8b9c68a8344df1dbb04b22e315234dec7ae0c18bfc697f88296d3a4b5e7276a48005db6caf1a0b89a23e174f72118ad10945118be8cd96fbee20ba79c12d721
This commit is contained in:
edouardparis 2024-04-19 10:57:30 +02:00
commit f9ea8ec0b6
No known key found for this signature in database
GPG Key ID: E65F7A089C20DC8F
11 changed files with 159 additions and 136 deletions

View File

@ -18,7 +18,7 @@ use std::time::Duration;
use iced::{clipboard, time, Command, Subscription};
use tracing::{error, info, warn};
pub use liana::{config::Config as DaemonConfig, miniscript::bitcoin};
pub use liana::{commands::CoinStatus, config::Config as DaemonConfig, miniscript::bitcoin};
use liana_ui::widget::Element;
pub use config::Config;
@ -239,8 +239,8 @@ impl App {
daemon.is_alive()?;
let info = daemon.get_info()?;
// todo: filter coins to only have current coins.
let coins = daemon.list_coins()?;
let coins = daemon
.list_coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[])?;
Ok(Cache {
datadir_path,
coins: coins.coins,

View File

@ -4,6 +4,7 @@ use std::{cmp::Ordering, collections::HashSet};
use iced::Command;
use liana::commands::CoinStatus;
use liana_ui::widget::Element;
use crate::{
@ -162,7 +163,7 @@ impl State for CoinsPanel {
Command::perform(
async move {
daemon1
.list_coins()
.list_coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[])
.map(|res| res.coins)
.map_err(|e| e.into())
},
@ -171,7 +172,7 @@ impl State for CoinsPanel {
Command::perform(
async move {
let coins = daemon2
.list_coins()
.list_coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[])
.map(|res| res.coins)
.map_err(Error::from)?;
let mut targets = HashSet::<LabelItem>::new();

View File

@ -13,7 +13,10 @@ use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use iced::{Command, Subscription};
use liana::miniscript::bitcoin::{Amount, OutPoint};
use liana::{
commands::CoinStatus,
miniscript::bitcoin::{Amount, OutPoint},
};
use liana_ui::widget::*;
use super::{cache::Cache, error::Error, menu::Menu, message::Message, view, wallet::Wallet};
@ -295,7 +298,7 @@ impl State for Home {
Command::perform(
async move {
daemon2
.list_coins()
.list_coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[])
.map(|res| res.coins)
.map_err(|e| e.into())
},

View File

@ -7,6 +7,7 @@ use iced::Subscription;
use iced::Command;
use liana::{
commands::CoinStatus,
descriptors::LianaPolicy,
miniscript::bitcoin::{bip32::Fingerprint, psbt::Psbt, Network, Txid},
};
@ -167,23 +168,15 @@ impl PsbtState {
return cmd;
}
Message::View(view::Message::Spend(view::SpendTxMessage::Broadcast)) => {
let outpoints: HashSet<_> = self.tx.coins.keys().cloned().collect();
let outpoints: Vec<_> = self.tx.coins.keys().cloned().collect();
return Command::perform(
async move {
daemon
// TODO: filter for the outpoints in `tx.coins` when this is possible:
// https://github.com/wizardsardine/liana/issues/677
.list_coins()
.list_coins(&[CoinStatus::Spending], &outpoints)
.map(|res| {
res.coins
.iter()
.filter_map(|c| {
if outpoints.contains(&c.outpoint) {
c.spend_info.map(|info| info.txid)
} else {
None
}
})
.filter_map(|c| c.spend_info.map(|info| info.txid))
.collect()
})
.map_err(|e| e.into())

View File

@ -4,9 +4,12 @@ use std::sync::Arc;
use iced::Command;
use liana::miniscript::bitcoin::{
bip32::{DerivationPath, Fingerprint},
secp256k1,
use liana::{
commands::CoinStatus,
miniscript::bitcoin::{
bip32::{DerivationPath, Fingerprint},
secp256k1,
},
};
use liana_ui::{component::form, widget::Element};
@ -155,16 +158,13 @@ impl State for RecoveryPanel {
return Command::perform(
async move {
let psbt = daemon.create_recovery(address, feerate_vb, sequence)?;
let coins = daemon.list_coins().map(|res| res.coins)?;
let coins = coins
.into_iter()
.filter(|coin| {
psbt.unsigned_tx
.input
.iter()
.any(|input| input.previous_output == coin.outpoint)
})
let outpoints: Vec<_> = psbt
.unsigned_tx
.input
.iter()
.map(|txin| txin.previous_output)
.collect();
let coins = daemon.list_coins(&[], &outpoints).map(|res| res.coins)?;
Ok(SpendTx::new(
None,
psbt,
@ -207,7 +207,7 @@ impl State for RecoveryPanel {
Command::perform(
async move {
daemon
.list_coins()
.list_coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[])
.map(|res| res.coins)
.map_err(|e| e.into())
},

View File

@ -5,7 +5,10 @@ use std::sync::Arc;
use iced::Command;
use liana::miniscript::bitcoin::{Network, OutPoint};
use liana::{
commands::CoinStatus,
miniscript::bitcoin::{Network, OutPoint},
};
use liana_ui::widget::Element;
use super::{redirect, State};
@ -123,7 +126,7 @@ impl State for CreateSpendPanel {
Command::perform(
async move {
daemon1
.list_coins()
.list_coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[])
.map(|res| res.coins)
.map_err(|e| e.into())
},
@ -132,7 +135,7 @@ impl State for CreateSpendPanel {
Command::perform(
async move {
let coins = daemon
.list_coins()
.list_coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[])
.map(|res| res.coins)
.map_err(Error::from)?;
let mut targets = HashSet::<LabelItem>::new();

View File

@ -7,6 +7,7 @@ use std::{
use iced::Command;
use liana::{
commands::CoinStatus,
miniscript::bitcoin::{OutPoint, Txid},
spend::{SpendCreationError, MAX_FEERATE},
};
@ -146,23 +147,23 @@ impl State for TransactionsPanel {
if let Some(tx) = &self.selected_tx {
if tx.fee_amount.is_some() {
let tx = tx.clone();
let txid = tx.tx.txid();
let outpoints: Vec<_> = (0..tx.tx.output.len())
.map(|vout| {
OutPoint::new(
tx.tx.txid(),
vout.try_into()
.expect("number of transaction outputs must fit in u32"),
)
})
.collect();
return Command::perform(
async move {
daemon
// TODO: filter for spending coins when this is possible:
// https://github.com/wizardsardine/liana/issues/677
.list_coins()
.list_coins(&[CoinStatus::Spending], &outpoints)
.map(|res| {
res.coins
.iter()
.filter_map(|c| {
if c.outpoint.txid == txid {
c.spend_info.map(|info| info.txid)
} else {
None
}
})
.filter_map(|c| c.spend_info.map(|info| info.txid))
.collect()
})
.map_err(|e| e.into())
@ -248,7 +249,6 @@ impl State for TransactionsPanel {
self.selected_tx = None;
let daemon1 = daemon.clone();
let daemon2 = daemon.clone();
let daemon3 = daemon.clone();
let now: u32 = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
@ -257,7 +257,7 @@ impl State for TransactionsPanel {
.unwrap();
Command::batch(vec![
Command::perform(
async move { daemon3.list_pending_txs().map_err(|e| e.into()) },
async move { daemon2.list_pending_txs().map_err(|e| e.into()) },
Message::PendingTransactions,
),
Command::perform(
@ -268,15 +268,6 @@ impl State for TransactionsPanel {
},
Message::HistoryTransactions,
),
Command::perform(
async move {
daemon2
.list_coins()
.map(|res| res.coins)
.map_err(|e| e.into())
},
Message::Coins,
),
])
}
}

View File

@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet};
use std::fmt::Debug;
use std::iter::FromIterator;
use liana::commands::CreateRecoveryResult;
use liana::commands::{CoinStatus, CreateRecoveryResult};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::json;
@ -77,8 +77,18 @@ impl<C: Client + Debug> Daemon for Lianad<C> {
self.call("getnewaddress", Option::<Request>::None)
}
fn list_coins(&self) -> Result<ListCoinsResult, DaemonError> {
self.call("listcoins", Option::<Request>::None)
fn list_coins(
&self,
statuses: &[CoinStatus],
outpoints: &[OutPoint],
) -> Result<ListCoinsResult, DaemonError> {
self.call(
"listcoins",
Some(vec![
json!(statuses.iter().map(|s| s.to_arg()).collect::<Vec<&str>>()),
json!(outpoints),
]),
)
}
fn list_spend_txs(&self) -> Result<ListSpendResult, DaemonError> {

View File

@ -3,7 +3,7 @@ use std::sync::Mutex;
use super::{model::*, Daemon, DaemonError};
use liana::{
commands::LabelItem,
commands::{CoinStatus, LabelItem},
config::Config,
miniscript::bitcoin::{address, psbt::Psbt, Address, OutPoint, Txid},
DaemonControl, DaemonHandle,
@ -87,8 +87,12 @@ impl Daemon for EmbeddedDaemon {
self.command(|daemon| Ok(daemon.get_new_address()))
}
fn list_coins(&self) -> Result<ListCoinsResult, DaemonError> {
self.command(|daemon| Ok(daemon.list_coins(&[], &[])))
fn list_coins(
&self,
statuses: &[CoinStatus],
outpoints: &[OutPoint],
) -> Result<ListCoinsResult, DaemonError> {
self.command(|daemon| Ok(daemon.list_coins(statuses, outpoints)))
}
fn list_spend_txs(&self) -> Result<ListSpendResult, DaemonError> {

View File

@ -3,12 +3,13 @@ pub mod embedded;
pub mod model;
use std::collections::{HashMap, HashSet};
use std::convert::TryInto;
use std::fmt::Debug;
use std::io::ErrorKind;
use std::iter::FromIterator;
use liana::{
commands::LabelItem,
commands::{CoinStatus, LabelItem, TransactionInfo},
config::Config,
miniscript::bitcoin::{address, psbt::Psbt, secp256k1, Address, OutPoint, Txid},
StartupError,
@ -56,7 +57,11 @@ pub trait Daemon: Debug {
fn stop(&self) -> Result<(), DaemonError>;
fn get_info(&self) -> Result<model::GetInfoResult, DaemonError>;
fn get_new_address(&self) -> Result<model::GetAddressResult, DaemonError>;
fn list_coins(&self) -> Result<model::ListCoinsResult, DaemonError>;
fn list_coins(
&self,
statuses: &[CoinStatus],
outpoints: &[OutPoint],
) -> Result<model::ListCoinsResult, DaemonError>;
fn list_spend_txs(&self) -> Result<model::ListSpendResult, DaemonError>;
fn create_spend_tx(
&self,
@ -102,15 +107,26 @@ pub trait Daemon: Debug {
txids: Option<&[Txid]>,
) -> Result<Vec<model::SpendTx>, DaemonError> {
let info = self.get_info()?;
let coins = self.list_coins()?.coins;
let mut spend_txs = Vec::new();
let curve = secp256k1::Secp256k1::verification_only();
for tx in self.list_spend_txs()?.spend_txs {
if let Some(txids) = txids {
if !txids.contains(&tx.psbt.unsigned_tx.txid()) {
continue;
}
}
// TODO: Use filters in `list_spend_txs` command.
let mut txs = self.list_spend_txs()?.spend_txs;
if let Some(txids) = txids {
txs.retain(|tx| txids.contains(&tx.psbt.unsigned_tx.txid()));
}
let outpoints: Vec<_> = txs
.iter()
.flat_map(|tx| {
tx.psbt
.unsigned_tx
.input
.iter()
.map(|txin| txin.previous_output)
.collect::<Vec<_>>()
})
.collect();
let coins = self.list_coins(&[], &outpoints)?.coins;
for tx in txs {
let coins = coins
.iter()
.filter(|coin| {
@ -145,87 +161,86 @@ pub trait Daemon: Debug {
Ok(spend_txs)
}
fn txs_to_historytxs(
&self,
txs: Vec<TransactionInfo>,
) -> Result<Vec<model::HistoryTransaction>, DaemonError> {
let info = self.get_info()?;
let outpoints: Vec<_> = txs
.iter()
.flat_map(|tx| {
(0..tx.tx.output.len())
.map(|vout| {
OutPoint::new(
tx.tx.txid(),
vout.try_into()
.expect("number of transaction outputs must fit in u32"),
)
})
.chain(tx.tx.input.iter().map(|txin| txin.previous_output))
.collect::<Vec<_>>()
})
.collect::<HashSet<_>>() // remove duplicates
.iter()
.cloned()
.collect();
let coins = self.list_coins(&[], &outpoints)?.coins;
let mut txs = txs
.into_iter()
.map(|tx| {
let mut tx_coins = Vec::new();
let mut change_indexes = Vec::new();
for coin in &coins {
if coin.outpoint.txid == tx.tx.txid() {
change_indexes.push(coin.outpoint.vout as usize)
} else if tx
.tx
.input
.iter()
.any(|input| input.previous_output == coin.outpoint)
{
tx_coins.push(coin.clone());
}
}
model::HistoryTransaction::new(
tx.tx,
tx.height,
tx.time,
tx_coins,
change_indexes,
info.network,
)
})
.collect();
load_labels(self, &mut txs)?;
Ok(txs)
}
fn list_history_txs(
&self,
start: u32,
end: u32,
limit: u64,
) -> Result<Vec<model::HistoryTransaction>, DaemonError> {
let info = self.get_info()?;
let coins = self.list_coins()?.coins;
let txs = self.list_confirmed_txs(start, end, limit)?.transactions;
let mut txs = txs
.into_iter()
.map(|tx| {
let mut tx_coins = Vec::new();
let mut change_indexes = Vec::new();
for coin in &coins {
if coin.outpoint.txid == tx.tx.txid() {
change_indexes.push(coin.outpoint.vout as usize)
} else if tx
.tx
.input
.iter()
.any(|input| input.previous_output == coin.outpoint)
{
tx_coins.push(coin.clone());
}
}
model::HistoryTransaction::new(
tx.tx,
tx.height,
tx.time,
tx_coins,
change_indexes,
info.network,
)
})
.collect();
load_labels(self, &mut txs)?;
Ok(txs)
self.txs_to_historytxs(txs)
}
fn get_history_txs(
&self,
txids: &[Txid],
) -> Result<Vec<model::HistoryTransaction>, DaemonError> {
let info = self.get_info()?;
let coins = self.list_coins()?.coins;
let txs = self.list_txs(txids)?.transactions;
let mut txs = txs
.into_iter()
.map(|tx| {
let mut tx_coins = Vec::new();
let mut change_indexes = Vec::new();
for coin in &coins {
if coin.outpoint.txid == tx.tx.txid() {
change_indexes.push(coin.outpoint.vout as usize)
} else if tx
.tx
.input
.iter()
.any(|input| input.previous_output == coin.outpoint)
{
tx_coins.push(coin.clone());
}
}
model::HistoryTransaction::new(
tx.tx,
tx.height,
tx.time,
tx_coins,
change_indexes,
info.network,
)
})
.collect();
load_labels(self, &mut txs)?;
Ok(txs)
self.txs_to_historytxs(txs)
}
fn list_pending_txs(&self) -> Result<Vec<model::HistoryTransaction>, DaemonError> {
let info = self.get_info()?;
let coins = self.list_coins()?.coins;
// We want coins that are inputs to and/or outputs of a pending tx,
// which can only be unconfirmed and spending coins.
let coins = self
.list_coins(&[CoinStatus::Unconfirmed, CoinStatus::Spending], &[])?
.coins;
let mut txids: Vec<Txid> = Vec::new();
for coin in &coins {
if coin.block_height.is_none() && !txids.contains(&coin.outpoint.txid) {
@ -233,7 +248,7 @@ pub trait Daemon: Debug {
}
if let Some(spend) = coin.spend_info {
if spend.height.is_none() && !txids.contains(&spend.txid) {
if !txids.contains(&spend.txid) {
txids.push(spend.txid);
}
}

View File

@ -9,6 +9,7 @@ use iced::{Alignment, Command, Length, Subscription};
use tracing::{debug, info, warn};
use liana::{
commands::CoinStatus,
config::{Config, ConfigError},
miniscript::bitcoin,
StartupError,
@ -370,7 +371,9 @@ pub async fn load_application(
let wallet =
Wallet::new(info.descriptors.main).load_settings(&gui_config, &datadir_path, network)?;
let coins = daemon.list_coins().map(|res| res.coins)?;
let coins = daemon
.list_coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[])
.map(|res| res.coins)?;
let cache = Cache {
datadir_path,