From 9db4541952561de8d50bdee35fa7ec737c3e65e8 Mon Sep 17 00:00:00 2001 From: edouard Date: Thu, 17 Aug 2023 13:50:09 +0200 Subject: [PATCH 1/6] Add labels support to gui --- gui/src/app/message.rs | 3 + gui/src/app/mod.rs | 2 + gui/src/app/state/coins.rs | 115 +++++-- gui/src/app/state/label.rs | 88 +++++ gui/src/app/state/mod.rs | 77 ++++- gui/src/app/state/psbt.rs | 37 ++- gui/src/app/state/recovery.rs | 13 +- gui/src/app/state/spend/mod.rs | 55 +++- gui/src/app/state/spend/step.rs | 263 ++++++++++----- gui/src/app/state/transactions.rs | 44 ++- gui/src/app/view/coins.rs | 83 ++++- gui/src/app/view/home.rs | 59 +++- gui/src/app/view/label.rs | 71 ++++ gui/src/app/view/message.rs | 9 + gui/src/app/view/mod.rs | 1 + gui/src/app/view/psbt.rs | 525 ++++++++++++++++++++++-------- gui/src/app/view/psbts.rs | 14 +- gui/src/app/view/receive.rs | 79 +++-- gui/src/app/view/spend/mod.rs | 62 +++- gui/src/app/view/transactions.rs | 72 ++-- gui/src/daemon/client/mod.rs | 20 +- gui/src/daemon/embedded.rs | 15 +- gui/src/daemon/mod.rs | 63 +++- gui/src/daemon/model.rs | 107 +++++- gui/ui/src/component/badge.rs | 13 + gui/ui/src/component/event.rs | 86 +++-- 26 files changed, 1585 insertions(+), 391 deletions(-) create mode 100644 gui/src/app/state/label.rs create mode 100644 gui/src/app/view/label.rs diff --git a/gui/src/app/message.rs b/gui/src/app/message.rs index 23a1352f..44bbd27e 100644 --- a/gui/src/app/message.rs +++ b/gui/src/app/message.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::Arc; use liana::{ @@ -22,6 +23,7 @@ pub enum Message { Info(Result), ReceiveAddress(Result), Coins(Result, Error>), + Labels(Result, Error>), SpendTxs(Result, Error>), Psbt(Result), Recovery(Result), @@ -33,4 +35,5 @@ pub enum Message { ConnectedHardwareWallets(Vec), HistoryTransactions(Result, Error>), PendingTransactions(Result, Error>), + LabelsUpdated(Result, Error>), } diff --git a/gui/src/app/mod.rs b/gui/src/app/mod.rs index 25c9c86d..78f9a62d 100644 --- a/gui/src/app/mod.rs +++ b/gui/src/app/mod.rs @@ -97,6 +97,7 @@ impl App { self.wallet.clone(), &self.cache.coins, self.cache.blockheight as u32, + self.cache.network, ) .into(), menu::Menu::RefreshCoins(preselected) => CreateSpendPanel::new_self_send( @@ -104,6 +105,7 @@ impl App { &self.cache.coins, self.cache.blockheight as u32, preselected, + self.cache.network, ) .into(), }; diff --git a/gui/src/app/state/coins.rs b/gui/src/app/state/coins.rs index e01d4f09..3af65bb5 100644 --- a/gui/src/app/state/coins.rs +++ b/gui/src/app/state/coins.rs @@ -1,18 +1,48 @@ -use std::cmp::Ordering; +use std::collections::HashMap; use std::sync::Arc; +use std::{cmp::Ordering, collections::HashSet}; use iced::Command; use liana_ui::widget::Element; use crate::{ - app::{cache::Cache, error::Error, menu::Menu, message::Message, state::State, view}, - daemon::{model::Coin, Daemon}, + app::{ + cache::Cache, + error::Error, + menu::Menu, + message::Message, + state::{label::LabelsEdited, State}, + view, + }, + daemon::{ + model::{Coin, LabelItem, Labelled}, + Daemon, + }, }; +#[derive(Debug, Default)] +pub struct Coins { + list: Vec, + labels: HashMap, +} + +impl Labelled for Coins { + fn labelled(&self) -> Vec { + self.list + .iter() + .map(|a| LabelItem::OutPoint(a.outpoint)) + .collect() + } + fn labels(&mut self) -> &mut HashMap { + &mut self.labels + } +} + pub struct CoinsPanel { - coins: Vec, + coins: Coins, selected: Vec, + labels_edited: LabelsEdited, warning: Option, /// timelock value to pass for the heir to consume a coin. timelock: u16, @@ -21,7 +51,8 @@ pub struct CoinsPanel { impl CoinsPanel { pub fn new(coins: &[Coin], timelock: u16) -> Self { let mut panel = Self { - coins: Vec::new(), + labels_edited: LabelsEdited::default(), + coins: Coins::default(), selected: Vec::new(), warning: None, timelock, @@ -31,18 +62,20 @@ impl CoinsPanel { } fn update_coins(&mut self, coins: &[Coin]) { - self.coins = coins + self.coins.list = coins .iter() .filter_map(|coin| { if coin.spend_info.is_none() { - Some(coin.clone()) + Some(coin) } else { None } }) + .cloned() .collect(); self.coins + .list .sort_by(|a, b| match (a.block_height, b.block_height) { (Some(a_height), Some(b_height)) => { if a_height == b_height { @@ -64,13 +97,20 @@ impl State for CoinsPanel { &Menu::Coins, cache, self.warning.as_ref(), - view::coins::coins_view(cache, &self.coins, self.timelock, &self.selected), + view::coins::coins_view( + cache, + &self.coins.list, + self.timelock, + &self.selected, + &self.coins.labels, + self.labels_edited.cache(), + ), ) } fn update( &mut self, - _daemon: Arc, + daemon: Arc, _cache: &Cache, message: Message, ) -> Command { @@ -83,6 +123,24 @@ impl State for CoinsPanel { self.update_coins(&coins); } }, + Message::Labels(res) => match res { + Err(e) => self.warning = Some(e), + Ok(labels) => { + self.coins.labels = labels; + } + }, + Message::View(view::Message::Label(_, _)) | Message::LabelsUpdated(_) => { + match self.labels_edited.update( + daemon, + message, + std::iter::once(&mut self.coins).map(|a| a as &mut dyn Labelled), + ) { + Ok(cmd) => return cmd, + Err(e) => { + self.warning = Some(e); + } + } + } Message::View(view::Message::Select(i)) => { if let Some(position) = self.selected.iter().position(|j| *j == i) { self.selected.remove(position); @@ -96,16 +154,34 @@ impl State for CoinsPanel { } fn load(&self, daemon: Arc) -> Command { - let daemon = daemon.clone(); - Command::perform( - async move { - daemon - .list_coins() - .map(|res| res.coins) - .map_err(|e| e.into()) - }, - Message::Coins, - ) + let daemon1 = daemon.clone(); + let daemon2 = daemon.clone(); + Command::batch(vec![ + Command::perform( + async move { + daemon1 + .list_coins() + .map(|res| res.coins) + .map_err(|e| e.into()) + }, + Message::Coins, + ), + Command::perform( + async move { + let coins = daemon2 + .list_coins() + .map(|res| res.coins) + .map_err(|e| Error::from(e))?; + let mut targets = HashSet::::new(); + for coin in coins { + targets.insert(LabelItem::OutPoint(coin.outpoint)); + targets.insert(LabelItem::Address(coin.address)); + } + daemon2.get_labels(&targets).map_err(|e| e.into()) + }, + Message::Labels, + ), + ]) } } @@ -172,6 +248,7 @@ mod tests { assert_eq!( panel .coins + .list .iter() .map(|c| c.outpoint) .collect::>(), diff --git a/gui/src/app/state/label.rs b/gui/src/app/state/label.rs new file mode 100644 index 00000000..a8d435f0 --- /dev/null +++ b/gui/src/app/state/label.rs @@ -0,0 +1,88 @@ +use liana::miniscript::bitcoin; +use std::str::FromStr; +use std::{collections::HashMap, iter::IntoIterator, sync::Arc}; + +use crate::{ + app::{error::Error, message::Message, view}, + daemon::{ + model::{LabelItem, Labelled}, + Daemon, + }, +}; +use iced::Command; +use liana_ui::component::form; + +#[derive(Default)] +pub struct LabelsEdited(HashMap>); + +impl LabelsEdited { + pub fn cache(&self) -> &HashMap> { + &self.0 + } + pub fn update<'a, T: IntoIterator>( + &mut self, + daemon: Arc, + message: Message, + targets: T, + ) -> Result, Error> { + match message { + Message::View(view::Message::Label(labelled, msg)) => match msg { + view::LabelMessage::Edited(value) => { + let valid = value.len() <= 100; + if let Some(label) = self.0.get_mut(&labelled) { + label.valid = valid; + label.value = value; + } else { + self.0.insert(labelled, form::Value { valid, value }); + } + } + view::LabelMessage::Cancel => { + self.0.remove(&labelled); + } + view::LabelMessage::Confirm => { + if let Some(label) = self.0.get(&labelled).cloned() { + return Ok(Command::perform( + async move { + if let Some(item) = label_item_from_str(&labelled) { + daemon.update_labels(&HashMap::from([( + item, + label.value.clone(), + )]))?; + } + Ok(HashMap::from([(labelled, label.value)])) + }, + Message::LabelsUpdated, + )); + } + } + }, + Message::LabelsUpdated(res) => match res { + Ok(new_labels) => { + for target in targets { + target.load_labels(&new_labels); + } + for (labelled, _) in new_labels { + self.0.remove(&labelled); + } + } + Err(e) => { + return Err(e); + } + }, + _ => {} + }; + Ok(Command::none()) + } +} + +pub fn label_item_from_str(s: &str) -> Option { + if let Ok(addr) = bitcoin::Address::from_str(s) { + Some(LabelItem::Address(addr.assume_checked())) + } else if let Ok(txid) = bitcoin::Txid::from_str(s) { + Some(LabelItem::Txid(txid)) + } else if let Ok(outpoint) = bitcoin::OutPoint::from_str(s) { + Some(LabelItem::OutPoint(outpoint)) + } else { + None + } +} diff --git a/gui/src/app/state/mod.rs b/gui/src/app/state/mod.rs index 53b5d360..9c4bcfd1 100644 --- a/gui/src/app/state/mod.rs +++ b/gui/src/app/state/mod.rs @@ -1,4 +1,5 @@ mod coins; +mod label; mod psbt; mod psbts; mod recovery; @@ -6,6 +7,7 @@ mod settings; mod spend; mod transactions; +use std::collections::HashMap; use std::convert::TryInto; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -17,10 +19,11 @@ use liana_ui::widget::*; use super::{cache::Cache, error::Error, menu::Menu, message::Message, view, wallet::Wallet}; use crate::daemon::{ - model::{remaining_sequence, Coin, HistoryTransaction}, + model::{remaining_sequence, Coin, HistoryTransaction, LabelItem, Labelled}, Daemon, }; pub use coins::CoinsPanel; +use label::LabelsEdited; pub use psbts::PsbtsPanel; pub use recovery::RecoveryPanel; pub use settings::SettingsState; @@ -54,6 +57,7 @@ pub struct Home { pending_events: Vec, events: Vec, selected_event: Option<(usize, usize)>, + labels_edited: LabelsEdited, warning: Option, } @@ -80,6 +84,7 @@ impl Home { selected_event: None, events: Vec::new(), pending_events: Vec::new(), + labels_edited: LabelsEdited::default(), warning: None, } } @@ -93,7 +98,13 @@ impl State for Home { } else { &self.events[i - self.pending_events.len()] }; - view::home::payment_view(cache, event, output_index, self.warning.as_ref()) + view::home::payment_view( + cache, + event, + output_index, + &self.labels_edited.cache(), + self.warning.as_ref(), + ) } else { view::dashboard( &Menu::Home, @@ -174,6 +185,23 @@ impl State for Home { } } }, + Message::View(view::Message::Label(_, _)) | Message::LabelsUpdated(_) => { + match self.labels_edited.update( + daemon, + message, + self.pending_events + .iter_mut() + .map(|tx| tx as &mut dyn Labelled) + .chain(self.events.iter_mut().map(|tx| tx as &mut dyn Labelled)), + ) { + Ok(cmd) => { + return cmd; + } + Err(e) => { + self.warning = Some(e); + } + }; + } Message::View(view::Message::Close) => { self.selected_event = None; } @@ -265,9 +293,28 @@ impl From for Box { } } +#[derive(Debug, Default)] +pub struct Addresses { + list: Vec
, + labels: HashMap, +} + +impl Labelled for Addresses { + fn labelled(&self) -> Vec { + self.list + .iter() + .map(|a| LabelItem::Address(a.clone())) + .collect() + } + fn labels(&mut self) -> &mut HashMap { + &mut self.labels + } +} + #[derive(Default)] pub struct ReceivePanel { - addresses: Vec
, + addresses: Addresses, + labels_edited: LabelsEdited, qr_code: Option, warning: Option, } @@ -278,7 +325,12 @@ impl State for ReceivePanel { &Menu::Receive, cache, self.warning.as_ref(), - view::receive::receive(&self.addresses, self.qr_code.as_ref()), + view::receive::receive( + &self.addresses.list, + self.qr_code.as_ref(), + &self.addresses.labels, + &self.labels_edited.cache(), + ), ) } fn update( @@ -288,12 +340,25 @@ impl State for ReceivePanel { message: Message, ) -> Command { match message { + Message::View(view::Message::Label(_, _)) | Message::LabelsUpdated(_) => { + match self.labels_edited.update( + daemon, + message, + std::iter::once(&mut self.addresses).map(|a| a as &mut dyn Labelled), + ) { + Ok(cmd) => cmd, + Err(e) => { + self.warning = Some(e); + Command::none() + } + } + } Message::ReceiveAddress(res) => { match res { Ok(address) => { self.warning = None; self.qr_code = Some(qr_code::State::new(address.to_qr_uri()).unwrap()); - self.addresses.push(address); + self.addresses.list.push(address); } Err(e) => self.warning = Some(e), } @@ -363,6 +428,6 @@ mod tests { let sandbox = sandbox.load(client, &Cache::default()).await; let panel = sandbox.state(); - assert_eq!(panel.addresses, vec![addr]); + assert_eq!(panel.addresses.list, vec![addr]); } } diff --git a/gui/src/app/state/psbt.rs b/gui/src/app/state/psbt.rs index c927ec38..ad76079e 100644 --- a/gui/src/app/state/psbt.rs +++ b/gui/src/app/state/psbt.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use iced::Command; @@ -17,11 +17,12 @@ use crate::{ cache::Cache, error::Error, message::Message, + state::label::{label_item_from_str, LabelsEdited}, view, wallet::{Wallet, WalletError}, }, daemon::{ - model::{SpendStatus, SpendTx}, + model::{LabelItem, Labelled, SpendStatus, SpendTx}, Daemon, }, hw::{list_hardware_wallets, HardwareWallet}, @@ -50,6 +51,8 @@ pub struct PsbtState { pub desc_policy: LianaPolicy, pub tx: SpendTx, pub saved: bool, + pub warning: Option, + pub labels_edited: LabelsEdited, pub action: Option>, } @@ -58,6 +61,8 @@ impl PsbtState { Self { desc_policy: wallet.main_descriptor.policy(), wallet, + labels_edited: LabelsEdited::default(), + warning: None, action: None, tx, saved, @@ -110,6 +115,20 @@ impl PsbtState { } } }, + Message::View(view::Message::Label(_, _)) | Message::LabelsUpdated(_) => { + match self.labels_edited.update( + daemon, + message, + std::iter::once(&mut self.tx).map(|tx| tx as &mut dyn Labelled), + ) { + Ok(cmd) => { + return cmd; + } + Err(e) => { + self.warning = Some(e); + } + }; + } Message::Updated(Ok(_)) => { self.saved = true; if let Some(action) = self.action.as_mut() { @@ -132,7 +151,9 @@ impl PsbtState { self.saved, &self.desc_policy, &self.wallet.keys_aliases, + &self.labels_edited.cache(), cache.network, + self.warning.as_ref(), ); if let Some(action) = &self.action { modal::Modal::new(content, action.view()) @@ -161,8 +182,18 @@ impl Action for SaveAction { Message::View(view::Message::Spend(view::SpendTxMessage::Confirm)) => { let daemon = daemon.clone(); let psbt = tx.psbt.clone(); + let mut labels = HashMap::::new(); + for (item, label) in tx.labels() { + labels.insert( + label_item_from_str(item).expect("Must be a LabelItem"), + label.clone(), + ); + } return Command::perform( - async move { daemon.update_spend_tx(&psbt).map_err(|e| e.into()) }, + async move { + daemon.update_spend_tx(&psbt)?; + daemon.update_labels(&labels).map_err(|e| e.into()) + }, Message::Updated, ); } diff --git a/gui/src/app/state/recovery.rs b/gui/src/app/state/recovery.rs index dbe904e9..a8113118 100644 --- a/gui/src/app/state/recovery.rs +++ b/gui/src/app/state/recovery.rs @@ -140,22 +140,29 @@ impl State for RecoveryPanel { .recovery_paths .get(self.selected_path.expect("A path must be selected")) .map(|p| p.sequence); + let network = cache.network; 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 - .iter() + .into_iter() .filter(|coin| { psbt.unsigned_tx .input .iter() .any(|input| input.previous_output == coin.outpoint) }) - .cloned() .collect(); let sigs = desc.partial_spend_info(&psbt).unwrap(); - Ok(SpendTx::new(None, psbt, coins, sigs, desc.max_sat_vbytes())) + Ok(SpendTx::new( + None, + psbt, + coins, + sigs, + desc.max_sat_vbytes(), + network, + )) }, Message::Recovery, ); diff --git a/gui/src/app/state/spend/mod.rs b/gui/src/app/state/spend/mod.rs index 67587efd..59697694 100644 --- a/gui/src/app/state/spend/mod.rs +++ b/gui/src/app/state/spend/mod.rs @@ -1,15 +1,20 @@ mod step; + +use std::collections::HashSet; use std::sync::Arc; use iced::Command; -use liana::miniscript::bitcoin::OutPoint; +use liana::miniscript::bitcoin::{Network, OutPoint}; use liana_ui::widget::Element; use super::{redirect, State}; use crate::{ - app::{cache::Cache, menu::Menu, message::Message, view, wallet::Wallet}, - daemon::{model::Coin, Daemon}, + app::{cache::Cache, error::Error, menu::Menu, message::Message, view, wallet::Wallet}, + daemon::{ + model::{Coin, LabelItem}, + Daemon, + }, }; pub struct CreateSpendPanel { @@ -19,11 +24,11 @@ pub struct CreateSpendPanel { } impl CreateSpendPanel { - pub fn new(wallet: Arc, coins: &[Coin], blockheight: u32) -> Self { + pub fn new(wallet: Arc, coins: &[Coin], blockheight: u32, network: Network) -> Self { let descriptor = wallet.main_descriptor.clone(); let timelock = descriptor.first_timelock_value(); Self { - draft: step::TransactionDraft::default(), + draft: step::TransactionDraft::new(network), current: 0, steps: vec![ Box::new( @@ -40,11 +45,12 @@ impl CreateSpendPanel { coins: &[Coin], blockheight: u32, preselected_coins: &[OutPoint], + network: Network, ) -> Self { let descriptor = wallet.main_descriptor.clone(); let timelock = descriptor.first_timelock_value(); Self { - draft: step::TransactionDraft::default(), + draft: step::TransactionDraft::new(network), current: 0, steps: vec![ Box::new( @@ -99,16 +105,33 @@ impl State for CreateSpendPanel { } fn load(&self, daemon: Arc) -> Command { - let daemon = daemon.clone(); - Command::perform( - async move { - daemon - .list_coins() - .map(|res| res.coins) - .map_err(|e| e.into()) - }, - Message::Coins, - ) + let daemon1 = daemon.clone(); + let daemon2 = daemon.clone(); + Command::batch(vec![ + Command::perform( + async move { + daemon1 + .list_coins() + .map(|res| res.coins) + .map_err(|e| e.into()) + }, + Message::Coins, + ), + Command::perform( + async move { + let coins = daemon + .list_coins() + .map(|res| res.coins) + .map_err(|e| Error::from(e))?; + let mut targets = HashSet::::new(); + for coin in coins { + targets.insert(LabelItem::OutPoint(coin.outpoint)); + } + daemon2.get_labels(&targets).map_err(|e| e.into()) + }, + Message::Labels, + ), + ]) } } diff --git a/gui/src/app/state/spend/step.rs b/gui/src/app/state/spend/step.rs index dcbe53e1..9038e1bd 100644 --- a/gui/src/app/state/spend/step.rs +++ b/gui/src/app/state/spend/step.rs @@ -26,10 +26,25 @@ use crate::{ /// See: https://github.com/wizardsardine/liana/blob/master/src/commands/mod.rs#L32 const DUST_OUTPUT_SATS: u64 = 5_000; -#[derive(Default, Clone)] +#[derive(Clone)] pub struct TransactionDraft { + network: Network, inputs: Vec, generated: Option, + batch_label: Option, + labels: HashMap, +} + +impl TransactionDraft { + pub fn new(network: Network) -> Self { + Self { + network, + inputs: Vec::new(), + generated: None, + batch_label: None, + labels: HashMap::new(), + } + } } pub trait Step { @@ -53,6 +68,8 @@ pub struct DefineSpend { descriptor: LianaDescriptor, timelock: u16, coins: Vec<(Coin, bool)>, + coins_labels: HashMap, + batch_label: form::Value, amount_left_to_select: Option, feerate: form::Value, generated: Option, @@ -88,6 +105,8 @@ impl DefineSpend { timelock, generated: None, coins, + coins_labels: HashMap::new(), + batch_label: form::Value::default(), recipients: vec![Recipient::default()], is_valid: false, is_duplicate: false, @@ -128,7 +147,9 @@ impl DefineSpend { } fn check_valid(&mut self) { - self.is_valid = self.feerate.valid && !self.feerate.value.is_empty(); + self.is_valid = self.feerate.valid + && !self.feerate.value.is_empty() + && (self.batch_label.valid || self.recipients.len() < 2); self.is_duplicate = false; if !self.coins.iter().any(|(_, selected)| *selected) { self.is_valid = false; @@ -216,94 +237,145 @@ impl Step for DefineSpend { cache: &Cache, message: Message, ) -> Command { - if let Message::View(view::Message::CreateSpend(msg)) = message { - match msg { - view::CreateSpendMessage::AddRecipient => { - self.recipients.push(Recipient::default()); - } - view::CreateSpendMessage::DeleteRecipient(i) => { - self.recipients.remove(i); - } - view::CreateSpendMessage::RecipientEdited(i, _, _) => { - self.recipients - .get_mut(i) - .unwrap() - .update(cache.network, msg); - } - - view::CreateSpendMessage::FeerateEdited(s) => { - if let Ok(value) = s.parse::() { - self.feerate.value = s; - self.feerate.valid = value != 0; - self.amount_left_to_select(); - } else if s.is_empty() { - self.feerate.value = "".to_string(); - self.feerate.valid = true; - self.amount_left_to_select = None; - } else { - self.feerate.valid = false; - self.amount_left_to_select = None; + match message { + Message::View(view::Message::CreateSpend(msg)) => { + match msg { + view::CreateSpendMessage::BatchLabelEdited(label) => { + self.batch_label.valid = label.len() <= 100; + self.batch_label.value = label; } - self.warning = None; - } - view::CreateSpendMessage::Generate => { - let inputs: Vec = self - .coins - .iter() - .filter_map( - |(coin, selected)| if *selected { Some(coin.outpoint) } else { None }, - ) - .collect(); - let mut outputs: HashMap, u64> = - HashMap::new(); - for recipient in &self.recipients { - outputs.insert( - Address::from_str(&recipient.address.value).expect("Checked before"), - recipient.amount().expect("Checked before"), + view::CreateSpendMessage::AddRecipient => { + self.recipients.push(Recipient::default()); + } + view::CreateSpendMessage::DeleteRecipient(i) => { + self.recipients.remove(i); + if self.recipients.len() < 2 { + self.batch_label.valid = true; + self.batch_label.value = "".to_string(); + } + } + view::CreateSpendMessage::RecipientEdited(i, _, _) => { + self.recipients + .get_mut(i) + .unwrap() + .update(cache.network, msg); + } + + view::CreateSpendMessage::FeerateEdited(s) => { + if let Ok(value) = s.parse::() { + self.feerate.value = s; + self.feerate.valid = value != 0; + self.amount_left_to_select(); + } else if s.is_empty() { + self.feerate.value = "".to_string(); + self.feerate.valid = true; + self.amount_left_to_select = None; + } else { + self.feerate.valid = false; + self.amount_left_to_select = None; + } + self.warning = None; + } + view::CreateSpendMessage::Generate => { + let inputs: Vec = self + .coins + .iter() + .filter_map( + |(coin, selected)| { + if *selected { + Some(coin.outpoint) + } else { + None + } + }, + ) + .collect(); + let mut outputs: HashMap, 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::().unwrap_or(0); + self.warning = None; + return Command::perform( + async move { + daemon + .create_spend_tx(&inputs, &outputs, feerate_vb) + .map(|res| res.psbt) + .map_err(|e| e.into()) + }, + Message::Psbt, ); } - let feerate_vb = self.feerate.value.parse::().unwrap_or(0); - self.warning = None; - return Command::perform( - async move { - daemon - .create_spend_tx(&inputs, &outputs, feerate_vb) - .map(|res| res.psbt) - .map_err(|e| e.into()) - }, - Message::Psbt, - ); - } - view::CreateSpendMessage::SelectCoin(i) => { - if let Some(coin) = self.coins.get_mut(i) { - coin.1 = !coin.1; - self.amount_left_to_select(); + view::CreateSpendMessage::SelectCoin(i) => { + if let Some(coin) = self.coins.get_mut(i) { + coin.1 = !coin.1; + self.amount_left_to_select(); + } } + _ => {} } - _ => {} + self.check_valid(); } - self.check_valid(); - Command::none() - } else { - if let Message::Psbt(res) = message { - match res { - Ok(psbt) => { - self.generated = Some(psbt); - return Command::perform(async {}, |_| Message::View(view::Message::Next)); - } - Err(e) => self.warning = Some(e), + Message::Psbt(res) => match res { + Ok(psbt) => { + self.generated = Some(psbt); + return Command::perform(async {}, |_| Message::View(view::Message::Next)); } - } - Command::none() - } + Err(e) => self.warning = Some(e), + }, + Message::Labels(res) => match res { + Ok(labels) => { + self.coins_labels = labels; + } + Err(e) => self.warning = Some(e), + }, + _ => {} + }; + Command::none() } fn apply(&self, draft: &mut TransactionDraft) { draft.inputs = self .coins .iter() - .filter_map(|(coin, selected)| if *selected { Some(coin.clone()) } else { None }) + .filter_map(|(coin, selected)| if *selected { Some(coin) } else { None }) + .cloned() .collect(); + if let Some(psbt) = &self.generated { + draft.labels = self.coins_labels.clone(); + for (i, output) in psbt.unsigned_tx.output.iter().enumerate() { + if let Some(label) = self + .recipients + .iter() + .find(|recipient| { + !recipient.label.value.is_empty() + && Address::from_str(&recipient.address.value) + .unwrap() + .payload + .matches_script_pubkey(&output.script_pubkey) + && output.value == recipient.amount().unwrap() + }) + .map(|recipient| recipient.label.value.to_string()) + { + draft.labels.insert( + OutPoint { + txid: psbt.unsigned_tx.txid(), + vout: i as u32, + } + .to_string(), + label, + ); + } + } + } + if self.recipients.len() > 1 { + draft.batch_label = Some(self.batch_label.value.clone()); + } draft.generated = self.generated.clone(); } @@ -326,6 +398,8 @@ impl Step for DefineSpend { self.is_duplicate, self.timelock, &self.coins, + &self.coins_labels, + &self.batch_label, self.amount_left_to_select.as_ref(), &self.feerate, self.warning.as_ref(), @@ -335,6 +409,7 @@ impl Step for DefineSpend { #[derive(Default)] struct Recipient { + label: form::Value, address: form::Value, amount: form::Value, } @@ -372,6 +447,7 @@ impl Recipient { && self.address.valid && !self.amount.value.is_empty() && self.amount.valid + && self.label.valid } fn update(&mut self, network: Network, message: view::CreateSpendMessage) { @@ -399,12 +475,16 @@ impl Recipient { self.amount.valid = true; } } + view::CreateSpendMessage::RecipientEdited(_, "label", label) => { + self.label.valid = label.len() <= 100; + self.label.value = label; + } _ => {} }; } fn view(&self, i: usize) -> Element { - view::spend::recipient_view(i, &self.address, &self.amount) + view::spend::recipient_view(i, &self.address, &self.amount, &self.label) } } @@ -430,17 +510,22 @@ impl Step for SaveSpend { .main_descriptor .partial_spend_info(&psbt) .unwrap(); - self.spend = Some(psbt::PsbtState::new( - self.wallet.clone(), - SpendTx::new( - None, - psbt, - draft.inputs.clone(), - sigs, - self.wallet.main_descriptor.max_sat_vbytes(), - ), - false, - )); + + let mut tx = SpendTx::new( + None, + psbt, + draft.inputs.clone(), + sigs, + self.wallet.main_descriptor.max_sat_vbytes(), + draft.network, + ); + tx.labels = draft.labels.clone(); + if let Some(label) = &draft.batch_label { + tx.labels + .insert(tx.psbt.unsigned_tx.txid().to_string(), label.clone()); + } + + self.spend = Some(psbt::PsbtState::new(self.wallet.clone(), tx, false)); } fn update( @@ -464,7 +549,9 @@ impl Step for SaveSpend { spend.saved, &spend.desc_policy, &spend.wallet.keys_aliases, + &spend.labels_edited.cache(), cache.network, + spend.warning.as_ref(), ); if let Some(action) = &spend.action { modal::Modal::new(content, action.view()) diff --git a/gui/src/app/state/transactions.rs b/gui/src/app/state/transactions.rs index c6104379..076636df 100644 --- a/gui/src/app/state/transactions.rs +++ b/gui/src/app/state/transactions.rs @@ -1,18 +1,30 @@ -use std::convert::TryInto; -use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::{ + convert::TryInto, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; use iced::Command; use liana_ui::widget::*; -use crate::app::{cache::Cache, error::Error, message::Message, view, State}; +use crate::app::{ + cache::Cache, + error::Error, + message::Message, + state::{label::LabelsEdited, State}, + view, +}; -use crate::daemon::{model::HistoryTransaction, Daemon}; +use crate::daemon::{ + model::{HistoryTransaction, Labelled}, + Daemon, +}; #[derive(Default)] pub struct TransactionsPanel { pending_txs: Vec, txs: Vec, + labels_edited: LabelsEdited, selected_tx: Option, warning: Option, } @@ -23,6 +35,7 @@ impl TransactionsPanel { selected_tx: None, txs: Vec::new(), pending_txs: Vec::new(), + labels_edited: LabelsEdited::default(), warning: None, } } @@ -36,7 +49,12 @@ impl State for TransactionsPanel { } else { &self.txs[i - self.pending_txs.len()] }; - view::transactions::tx_view(cache, tx, self.warning.as_ref()) + view::transactions::tx_view( + cache, + tx, + &self.labels_edited.cache(), + self.warning.as_ref(), + ) } else { view::transactions::transactions_view( cache, @@ -82,6 +100,20 @@ impl State for TransactionsPanel { Message::View(view::Message::Select(i)) => { self.selected_tx = Some(i); } + Message::View(view::Message::Label(_, _)) | Message::LabelsUpdated(_) => { + match self.labels_edited.update( + daemon, + message, + self.txs.iter_mut().map(|tx| tx as &mut dyn Labelled), + ) { + Ok(cmd) => { + return cmd; + } + Err(e) => { + self.warning = Some(e); + } + }; + } Message::View(view::Message::Next) => { if let Some(last) = self.txs.last() { let daemon = daemon.clone(); diff --git a/gui/src/app/view/coins.rs b/gui/src/app/view/coins.rs index 484e4d9e..8e786e0c 100644 --- a/gui/src/app/view/coins.rs +++ b/gui/src/app/view/coins.rs @@ -1,15 +1,21 @@ +use std::collections::HashMap; + use iced::{widget::Space, Alignment, Length}; use liana_ui::{ color, - component::{amount::*, badge, button, text::*}, + component::{amount::*, badge, button, form, text::*}, icon, theme, util::Collection, widget::*, }; use crate::{ - app::{cache::Cache, menu::Menu, view::message::Message}, + app::{ + cache::Cache, + menu::Menu, + view::{label, message::Message}, + }, daemon::model::{remaining_sequence, Coin}, }; @@ -18,6 +24,8 @@ pub fn coins_view<'a>( coins: &'a [Coin], timelock: u16, selected: &[usize], + labels: &'a HashMap, + labels_editing: &'a HashMap>, ) -> Element<'a, Message> { Column::new() .push(Container::new(h3("Coins")).width(Length::Fill)) @@ -33,6 +41,8 @@ pub fn coins_view<'a>( cache.blockheight as u32, i, selected.contains(&i), + labels, + labels_editing, )) }, )), @@ -43,13 +53,17 @@ pub fn coins_view<'a>( } #[allow(clippy::collapsible_else_if)] -fn coin_list_view( - coin: &Coin, +fn coin_list_view<'a>( + coin: &'a Coin, timelock: u16, blockheight: u32, index: usize, collapsed: bool, -) -> Container { + labels: &'a HashMap, + labels_editing: &'a HashMap>, +) -> Container<'a, Message> { + let outpoint = coin.outpoint.to_string(); + let address = coin.address.to_string(); Container::new( Column::new() .push( @@ -58,6 +72,17 @@ fn coin_list_view( .push( Row::new() .push(badge::coin()) + .push(if !collapsed { + if let Some(label) = labels.get(&outpoint) { + Container::new(p1_bold(label)).width(Length::Fill) + } else { + Container::new(Space::with_width(Length::Fill)) + .width(Length::Fill) + } + } else { + Container::new(Space::with_width(Length::Fill)) + .width(Length::Fill) + }) .push(if coin.spend_info.is_some() { badge::spent() } else if coin.block_height.is_none() { @@ -83,6 +108,18 @@ fn coin_list_view( Column::new() .padding(10) .spacing(5) + .push( + Container::new(if let Some(label) = labels_editing.get(&outpoint) { + label::label_editing(outpoint.clone(), label, P1_SIZE) + } else { + label::label_editable( + outpoint.clone(), + labels.get(&outpoint), + P1_SIZE, + ) + }) + .width(Length::Fill), + ) .push_maybe(if coin.spend_info.is_none() { if let Some(b) = coin.block_height { if blockheight > b as u32 + timelock as u32 { @@ -104,6 +141,42 @@ fn coin_list_view( }) .push( Column::new() + .push( + Row::new() + .align_items(Alignment::Center) + .push(p2_regular("Address:").bold().style(color::GREY_2)) + .push( + Row::new() + .align_items(Alignment::Center) + .push( + p2_regular(address.clone()) + .style(color::GREY_2), + ) + .push( + Button::new(icon::clipboard_icon()) + .on_press(Message::Clipboard( + address.clone(), + )) + .style(theme::Button::TransparentBorder), + ), + ) + .spacing(5), + ) + .push( + Row::new() + .align_items(Alignment::Center) + .push( + p2_regular("Address label:") + .bold() + .style(color::GREY_2), + ) + .push(if let Some(label) = labels.get(&address) { + p2_regular(label).style(color::GREY_2) + } else { + p2_regular("No label").style(color::GREY_2) + }) + .spacing(5), + ) .push( Row::new() .align_items(Alignment::Center) diff --git a/gui/src/app/view/home.rs b/gui/src/app/view/home.rs index 0a1a28cd..498563e3 100644 --- a/gui/src/app/view/home.rs +++ b/gui/src/app/view/home.rs @@ -1,11 +1,12 @@ use chrono::NaiveDateTime; +use std::collections::HashMap; -use iced::{alignment, Alignment, Length}; +use iced::{alignment, widget::Space, Alignment, Length}; use liana::miniscript::bitcoin; use liana_ui::{ color, - component::{amount::*, button, card, event, text::*}, + component::{amount::*, button, card, event, form, text::*}, icon, theme, util::Collection, widget::*, @@ -16,7 +17,7 @@ use crate::{ cache::Cache, error::Error, menu::Menu, - view::{coins, dashboard, message::Message}, + view::{coins, dashboard, label, message::Message}, }, daemon::model::HistoryTransaction, }; @@ -28,8 +29,8 @@ pub fn home_view<'a>( unconfirmed_balance: &'a bitcoin::Amount, remaining_sequence: &Option, expiring_coins: &Vec, - pending_events: &[HistoryTransaction], - events: &Vec, + pending_events: &'a [HistoryTransaction], + events: &'a Vec, ) -> Element<'a, Message> { Column::new() .push(h3("Balance")) @@ -145,21 +146,40 @@ pub fn home_view<'a>( .into() } -fn event_list_view<'a>(i: usize, event: &HistoryTransaction) -> Column<'a, Message> { +fn event_list_view<'a>(i: usize, event: &'a HistoryTransaction) -> Column<'a, Message> { event.tx.output.iter().enumerate().fold( Column::new().spacing(10), |col, (output_index, output)| { + let label = if let Some(label) = event.labels.get( + &bitcoin::OutPoint { + txid: event.tx.txid(), + vout: output_index as u32, + } + .to_string(), + ) { + Some(p1_bold(label)) + } else if let Some(label) = event.labels.get( + &bitcoin::Address::from_script(&output.script_pubkey, event.network) + .unwrap() + .to_string(), + ) { + Some(p1_bold(format!("address label: {}", label)).style(color::GREY_3)) + } else { + None + }; if event.is_external() { if !event.change_indexes.contains(&output_index) { col } else if let Some(t) = event.time { col.push(event::confirmed_incoming_event( + label, NaiveDateTime::from_timestamp_opt(t as i64, 0).unwrap(), &Amount::from_sat(output.value), Message::SelectSub(i, output_index), )) } else { col.push(event::unconfirmed_incoming_event( + label, &Amount::from_sat(output.value), Message::SelectSub(i, output_index), )) @@ -168,12 +188,14 @@ fn event_list_view<'a>(i: usize, event: &HistoryTransaction) -> Column<'a, Messa col } else if let Some(t) = event.time { col.push(event::confirmed_outgoing_event( + label, NaiveDateTime::from_timestamp_opt(t as i64, 0).unwrap(), &Amount::from_sat(output.value), Message::SelectSub(i, output_index), )) } else { col.push(event::unconfirmed_outgoing_event( + label, &Amount::from_sat(output.value), Message::SelectSub(i, output_index), )) @@ -186,8 +208,15 @@ pub fn payment_view<'a>( cache: &'a Cache, tx: &'a HistoryTransaction, output_index: usize, + labels_editing: &'a HashMap>, warning: Option<&'a Error>, ) -> Element<'a, Message> { + let txid = tx.tx.txid().to_string(); + let outpoint = bitcoin::OutPoint { + txid: tx.tx.txid(), + vout: output_index as u32, + } + .to_string(); dashboard( &Menu::Home, cache, @@ -200,11 +229,22 @@ pub fn payment_view<'a>( } else { Container::new(h3("Outgoing payment")).width(Length::Fill) }) + .push(if let Some(label) = labels_editing.get(&outpoint) { + label::label_editing(outpoint.clone(), label, H3_SIZE) + } else { + label::label_editable(outpoint.clone(), tx.labels.get(&outpoint), H1_SIZE) + }) .push(Container::new(amount_with_size( &Amount::from_sat(tx.tx.output[output_index].value), H1_SIZE, ))) + .push(Space::with_height(H3_SIZE)) .push(Container::new(h3("Transaction")).width(Length::Fill)) + .push(if let Some(label) = labels_editing.get(&txid) { + label::label_editing(txid.clone(), label, H3_SIZE) + } else { + label::label_editable(txid.clone(), tx.labels.get(&txid), H3_SIZE) + }) .push_maybe(tx.fee_amount.map(|fee_amount| { Row::new() .align_items(Alignment::Center) @@ -259,11 +299,8 @@ pub fn payment_view<'a>( } else { Some(tx.change_indexes.clone()) }, - if tx.is_external() { - Some(tx.change_indexes.clone()) - } else { - None - }, + &tx.labels, + labels_editing, )) .spacing(20), ) diff --git a/gui/src/app/view/label.rs b/gui/src/app/view/label.rs new file mode 100644 index 00000000..bd2cfbfe --- /dev/null +++ b/gui/src/app/view/label.rs @@ -0,0 +1,71 @@ +use iced::{widget::row, Alignment}; + +use liana_ui::{ + color, + component::{button, form}, + font, icon, + widget::*, +}; + +use crate::app::view; + +pub fn label_editable<'a>( + labelled: String, + label: Option<&'a String>, + size: u16, +) -> Element<'a, view::Message> { + if let Some(label) = label { + if !label.is_empty() { + return Container::new( + row!( + iced::widget::Text::new(label).size(size).font(font::BOLD), + button::primary(Some(icon::pencil_icon()), "Edit").on_press( + view::Message::Label( + labelled, + view::message::LabelMessage::Edited(label.to_string()) + ) + ) + ) + .spacing(5) + .align_items(Alignment::Center), + ) + .into(); + } + } + Container::new( + row!( + iced::widget::Text::new("Add Label") + .size(size) + .font(font::BOLD) + .style(color::GREY_3), + button::primary(Some(icon::pencil_icon()), "Edit").on_press(view::Message::Label( + labelled, + view::message::LabelMessage::Edited(String::default()) + )) + ) + .spacing(5) + .align_items(Alignment::Center), + ) + .into() +} + +pub fn label_editing( + labelled: String, + label: &form::Value, + size: u16, +) -> Element { + let e: Element = Container::new( + row!( + form::Form::new("Label", label, view::LabelMessage::Edited) + .warning("Invalid label length, cannot be superior to 100") + .size(size) + .padding(10), + button::primary(None, "Save").on_press(view::message::LabelMessage::Confirm), + button::primary(None, "Cancel").on_press(view::message::LabelMessage::Cancel) + ) + .spacing(5) + .align_items(Alignment::Center), + ) + .into(); + e.map(move |msg| view::Message::Label(labelled.clone(), msg)) +} diff --git a/gui/src/app/view/message.rs b/gui/src/app/view/message.rs index a5eeaffa..3d634eca 100644 --- a/gui/src/app/view/message.rs +++ b/gui/src/app/view/message.rs @@ -9,6 +9,7 @@ pub enum Message { Close, Select(usize), SelectSub(usize, usize), + Label(String, LabelMessage), Settings(SettingsMessage), CreateSpend(CreateSpendMessage), ImportSpend(ImportSpendMessage), @@ -18,9 +19,17 @@ pub enum Message { SelectHardwareWallet(usize), } +#[derive(Debug, Clone)] +pub enum LabelMessage { + Edited(String), + Cancel, + Confirm, +} + #[derive(Debug, Clone)] pub enum CreateSpendMessage { AddRecipient, + BatchLabelEdited(String), DeleteRecipient(usize), SelectCoin(usize), RecipientEdited(usize, &'static str, String), diff --git a/gui/src/app/view/mod.rs b/gui/src/app/view/mod.rs index 8d606e24..73887fa4 100644 --- a/gui/src/app/view/mod.rs +++ b/gui/src/app/view/mod.rs @@ -1,3 +1,4 @@ +mod label; mod message; mod warning; diff --git a/gui/src/app/view/psbt.rs b/gui/src/app/view/psbt.rs index 587d17e8..17f5b5ed 100644 --- a/gui/src/app/view/psbt.rs +++ b/gui/src/app/view/psbt.rs @@ -9,14 +9,19 @@ use liana::{ descriptors::{LianaPolicy, PathInfo, PathSpendInfo}, miniscript::bitcoin::{ bip32::{DerivationPath, Fingerprint}, - Address, Amount, Network, Transaction, + blockdata::transaction::TxOut, + Address, Amount, Network, OutPoint, Transaction, Txid, }, }; use liana_ui::{ color, component::{ - amount::*, badge, button, card, collapse::Collapse, form, hw, separation, text::*, + amount::*, + badge, button, card, + collapse::Collapse, + form, hw, separation, + text::{self, *}, }, icon, theme, util::Collection, @@ -28,7 +33,7 @@ use crate::{ cache::Cache, error::Error, menu::Menu, - view::{dashboard, hw::hw_list_view, message::*, warning::warn}, + view::{dashboard, hw::hw_list_view, label, message::*, warning::warn}, }, daemon::model::{Coin, SpendStatus, SpendTx}, hw::HardwareWallet, @@ -40,12 +45,14 @@ pub fn psbt_view<'a>( saved: bool, desc_info: &'a LianaPolicy, key_aliases: &'a HashMap, + labels_editing: &'a HashMap>, network: Network, + warning: Option<&Error>, ) -> Element<'a, Message> { dashboard( &Menu::PSBTs, cache, - None, + warning, Column::new() .spacing(20) .push( @@ -65,14 +72,15 @@ pub fn psbt_view<'a>( _ => None, }), ) - .push(spend_header(tx)) + .push(spend_header(tx, labels_editing)) .push(spend_overview_view(tx, desc_info, key_aliases)) .push(inputs_and_outputs_view( &tx.coins, &tx.psbt.unsigned_tx, network, Some(tx.change_indexes.clone()), - None, + &tx.labels, + labels_editing, )) .push(if saved { Row::new() @@ -182,9 +190,18 @@ pub fn delete_action<'a>(warning: Option<&Error>, deleted: bool) -> Element<'a, } } -pub fn spend_header<'a>(tx: &SpendTx) -> Element<'a, Message> { +pub fn spend_header<'a>( + tx: &'a SpendTx, + labels_editing: &'a HashMap>, +) -> Element<'a, Message> { + let txid = tx.psbt.unsigned_tx.txid().to_string(); Column::new() .spacing(20) + .push(if let Some(label) = labels_editing.get(&txid) { + label::label_editing(txid.clone(), label, H3_SIZE) + } else { + label::label_editable(txid.clone(), tx.labels.get(&txid), H1_SIZE) + }) .push( Column::new() .push(if tx.is_self_send() { @@ -504,8 +521,10 @@ pub fn inputs_and_outputs_view<'a>( tx: &'a Transaction, network: Network, change_indexes: Option>, - receive_indexes: Option>, + labels: &'a HashMap, + labels_editing: &'a HashMap>, ) -> Element<'a, Message> { + let change_indexes_copy = change_indexes.clone(); Column::new() .spacing(20) .push_maybe(if !coins.is_empty() { @@ -551,29 +570,9 @@ pub fn inputs_and_outputs_view<'a>( coins .iter() .fold( - Column::new().padding(20), + Column::new().spacing(10).padding(20), |col: Column<'a, Message>, coin| { - col.push( - Row::new() - .align_items(Alignment::Center) - .width(Length::Fill) - .push( - Row::new() - .width(Length::Fill) - .align_items(Alignment::Center) - .push(p2_regular(coin.outpoint.to_string()).style(color::GREY_3)) - .push( - Button::new(icon::clipboard_icon().style(color::GREY_3)) - .on_press(Message::Clipboard( - coin.outpoint.to_string(), - )) - .style( - theme::Button::TransparentBorder, - ), - ), - ) - .push(amount(&coin.amount)), - ) + col.push(input_view(coin, labels, labels_editing)) }, ) .into() @@ -584,107 +583,373 @@ pub fn inputs_and_outputs_view<'a>( } else { None }) - .push( - Container::new(Collapse::new( - move || { - Button::new( - Row::new() - .align_items(Alignment::Center) - .push( - h4_bold(format!( - "{} recipient{}", - tx.output.len(), - if tx.output.len() == 1 { "" } else { "s" } - )) - .width(Length::Fill), - ) - .push(icon::collapse_icon()), - ) - .padding(20) - .width(Length::Fill) - .style(theme::Button::TransparentBorder) - }, - move || { - Button::new( - Row::new() - .align_items(Alignment::Center) - .push( - h4_bold(format!( - "{} recipient{}", - tx.output.len(), - if tx.output.len() == 1 { "" } else { "s" } - )) - .width(Length::Fill), - ) - .push(icon::collapsed_icon()), - ) - .padding(20) - .width(Length::Fill) - .style(theme::Button::TransparentBorder) - }, - move || { - tx.output - .iter() - .enumerate() - .fold( - Column::new().padding(20), - |col: Column<'a, Message>, (i, output)| { - let addr = - Address::from_script(&output.script_pubkey, network).unwrap(); - col.push( - Column::new() - .width(Length::Fill) - .spacing(5) - .push( - Row::new() - .align_items(Alignment::Center) - .width(Length::Fill) - .push( - Row::new() - .align_items(Alignment::Center) - .width(Length::Fill) - .push(p2_regular(addr.to_string()).style(color::GREY_3)) - .push( - Button::new(icon::clipboard_icon().style(color::GREY_3)) - .on_press(Message::Clipboard( - addr.to_string(), - )) - .style( - theme::Button::TransparentBorder, - ), - ), - ) - .push(amount(&Amount::from_sat(output.value))), - ) - .push_maybe(if let Some(indexes) = change_indexes.as_ref() { - if indexes.contains(&i) { - Some(Container::new(text("Change")).padding(5).style( - theme::Container::Pill(theme::Pill::Success), - )) - } else { - None - } - } else { - None - }) - .push_maybe(if let Some(indexes) = receive_indexes.as_ref() { - if indexes.contains(&i) { - Some(Container::new(text("Deposit")).padding(5).style( - theme::Container::Pill(theme::Pill::Success), - )) - } else { - None - } - } else { - None - }), - ) - }, + .push({ + let count = tx + .output + .iter() + .enumerate() + .filter(|(i, _)| { + if let Some(indexes) = change_indexes_copy.as_ref() { + !indexes.contains(&i) + } else { + true + } + }) + .count(); + if count > 0 { + Container::new(Collapse::new( + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push( + h4_bold(format!( + "{} payment{}", + count, + if count == 1 { "" } else { "s" } + )) + .width(Length::Fill), + ) + .push(icon::collapse_icon()), ) - .into() - }, - )) - .style(theme::Container::Card(theme::Card::Simple)), + .padding(20) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push( + h4_bold(format!( + "{} payment{}", + count, + if count == 1 { "" } else { "s" } + )) + .width(Length::Fill), + ) + .push(icon::collapsed_icon()), + ) + .padding(20) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + tx.output + .iter() + .enumerate() + .filter(|(i, _)| { + if let Some(indexes) = change_indexes_copy.as_ref() { + !indexes.contains(&i) + } else { + true + } + }) + .fold( + Column::new().padding(20), + |col: Column<'a, Message>, (i, output)| { + col.spacing(10).push(payment_view( + i, + tx.txid(), + output, + network, + labels, + labels_editing, + )) + }, + ) + .into() + }, + )) + .style(theme::Container::Card(theme::Card::Simple)) + } else { + Container::new(h4_bold("0 payment")) + .padding(20) + .width(Length::Fill) + .style(theme::Container::Card(theme::Card::Simple)) + } + }) + .push_maybe( + if change_indexes + .as_ref() + .map(|indexes| !indexes.is_empty()) + .unwrap_or(false) + { + Some( + Container::new(Collapse::new( + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push(h4_bold("Change").width(Length::Fill)) + .push(icon::collapse_icon()), + ) + .padding(20) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push(h4_bold("Change").width(Length::Fill)) + .push(icon::collapsed_icon()), + ) + .padding(20) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + tx.output + .iter() + .enumerate() + .filter(|(i, _)| change_indexes.as_ref().unwrap().contains(&i)) + .fold( + Column::new().padding(20), + |col: Column<'a, Message>, (i, output)| { + col.spacing(10).push(change_view( + i, + tx.txid(), + output, + network, + labels, + labels_editing, + )) + }, + ) + .into() + }, + )) + .style(theme::Container::Card(theme::Card::Simple)), + ) + } else { + None + }, + ) + .into() +} + +fn input_view<'a>( + coin: &'a Coin, + labels: &'a HashMap, + labels_editing: &'a HashMap>, +) -> Element<'a, Message> { + let outpoint = coin.outpoint.to_string(); + let addr = coin.address.to_string(); + Column::new() + .width(Length::Fill) + .push( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push( + Container::new(if let Some(label) = labels_editing.get(&outpoint) { + label::label_editing(outpoint.clone(), label, text::P1_SIZE) + } else { + label::label_editable( + outpoint.clone(), + labels.get(&outpoint), + text::P1_SIZE, + ) + }) + .width(Length::Fill), + ) + .push(amount(&coin.amount)), + ) + .push( + Column::new() + .push( + Row::new() + .align_items(Alignment::Center) + .spacing(5) + .push(p1_bold("Outpoint:").style(color::GREY_3)) + .push(p2_regular(outpoint.clone()).style(color::GREY_3)) + .push( + Button::new(icon::clipboard_icon().style(color::GREY_3)) + .on_press(Message::Clipboard(coin.outpoint.to_string())) + .style(theme::Button::TransparentBorder), + ), + ) + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .spacing(5) + .push(p1_bold("Address:").style(color::GREY_3)) + .push(p2_regular(addr.clone()).style(color::GREY_3)) + .push( + Button::new(icon::clipboard_icon().style(color::GREY_3)) + .on_press(Message::Clipboard(addr.clone())) + .style(theme::Button::TransparentBorder), + ), + ), + ) + .push_maybe(labels.get(&addr).map(|label| { + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .spacing(5) + .push(p1_bold("Address label:").style(color::GREY_3)) + .push(p2_regular(label).style(color::GREY_3)), + ) + })), + ) + .spacing(5) + .into() +} + +fn payment_view<'a>( + i: usize, + txid: Txid, + output: &'a TxOut, + network: Network, + labels: &'a HashMap, + labels_editing: &'a HashMap>, +) -> Element<'a, Message> { + let addr = Address::from_script(&output.script_pubkey, network) + .unwrap() + .to_string(); + let outpoint = OutPoint { + txid, + vout: i as u32, + } + .to_string(); + Column::new() + .width(Length::Fill) + .spacing(5) + .push( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push( + Container::new(if let Some(label) = labels_editing.get(&outpoint) { + label::label_editing(outpoint.clone(), label, text::P1_SIZE) + } else { + label::label_editable( + outpoint.clone(), + labels.get(&outpoint), + text::P1_SIZE, + ) + }) + .width(Length::Fill), + ) + .push(amount(&Amount::from_sat(output.value))), + ) + .push( + Column::new() + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .spacing(5) + .push(p1_bold("Address:").style(color::GREY_3)) + .push(p2_regular(addr.clone()).style(color::GREY_3)) + .push( + Button::new(icon::clipboard_icon().style(color::GREY_3)) + .on_press(Message::Clipboard(addr.clone())) + .style(theme::Button::TransparentBorder), + ), + ), + ) + .push_maybe(labels.get(&addr).map(|label| { + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .spacing(5) + .push(p1_bold("Address label:").style(color::GREY_3)) + .push(p2_regular(label).style(color::GREY_3)), + ) + })), + ) + .into() +} + +fn change_view<'a>( + i: usize, + txid: Txid, + output: &'a TxOut, + network: Network, + labels: &'a HashMap, + labels_editing: &'a HashMap>, +) -> Element<'a, Message> { + let addr = Address::from_script(&output.script_pubkey, network) + .unwrap() + .to_string(); + let outpoint = OutPoint { + txid, + vout: i as u32, + } + .to_string(); + Column::new() + .width(Length::Fill) + .spacing(5) + .push( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push( + Container::new(if let Some(label) = labels_editing.get(&outpoint) { + label::label_editing(outpoint.clone(), label, text::P1_SIZE) + } else { + label::label_editable( + outpoint.clone(), + labels.get(&outpoint), + text::P1_SIZE, + ) + }) + .width(Length::Fill), + ) + .push(amount(&Amount::from_sat(output.value))), + ) + .push( + Column::new() + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .spacing(5) + .push(p1_bold("Address:").style(color::GREY_3)) + .push(p2_regular(addr.clone()).style(color::GREY_3)) + .push( + Button::new(icon::clipboard_icon().style(color::GREY_3)) + .on_press(Message::Clipboard(addr.clone())) + .style(theme::Button::TransparentBorder), + ), + ), + ) + .push_maybe(labels.get(&addr).map(|label| { + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .spacing(5) + .push(p1_bold("Address label:").style(color::GREY_3)) + .push(p2_regular(label).style(color::GREY_3)), + ) + })), ) .into() } diff --git a/gui/src/app/view/psbts.rs b/gui/src/app/view/psbts.rs index cf49d488..2f37599c 100644 --- a/gui/src/app/view/psbts.rs +++ b/gui/src/app/view/psbts.rs @@ -59,7 +59,7 @@ pub fn import_psbt_success_view<'a>() -> Element<'a, Message> { .into() } -pub fn psbts_view<'a>(spend_txs: &[SpendTx]) -> Element<'a, Message> { +pub fn psbts_view<'a>(spend_txs: &'a [SpendTx]) -> Element<'a, Message> { Column::new() .push( Row::new() @@ -90,7 +90,7 @@ pub fn psbts_view<'a>(spend_txs: &[SpendTx]) -> Element<'a, Message> { .into() } -fn spend_tx_list_view<'a>(i: usize, tx: &SpendTx) -> Element<'a, Message> { +fn spend_tx_list_view<'a>(i: usize, tx: &'a SpendTx) -> Element<'a, Message> { Container::new( Button::new( Row::new() @@ -124,6 +124,11 @@ fn spend_tx_list_view<'a>(i: usize, tx: &SpendTx) -> Element<'a, Message> { .push(icon::key_icon().style(color::GREY_3)), ) }) + .push_maybe( + tx.labels + .get(&tx.psbt.unsigned_tx.txid().to_string()) + .map(|label| p1_bold(label)), + ) .spacing(10) .align_items(Alignment::Center) .width(Length::Fill), @@ -134,6 +139,11 @@ fn spend_tx_list_view<'a>(i: usize, tx: &SpendTx) -> Element<'a, Message> { SpendStatus::Spent => Some(badge::spent()), _ => None, }) + .push_maybe(if tx.is_batch() { + Some(badge::batch()) + } else { + None + }) .push( Column::new() .align_items(Alignment::End) diff --git a/gui/src/app/view/receive.rs b/gui/src/app/view/receive.rs index e0f5cb2c..fcc5ae69 100644 --- a/gui/src/app/view/receive.rs +++ b/gui/src/app/view/receive.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use iced::{ widget::{ qr_code::{self, QRCode}, @@ -10,16 +12,23 @@ use liana::miniscript::bitcoin; use liana_ui::{ color, - component::{button, card, text::*}, + component::{ + button, card, form, + text::{self, *}, + }, icon, theme, widget::*, }; +use crate::app::view::label; + use super::message::Message; pub fn receive<'a>( addresses: &'a [bitcoin::Address], qr: Option<&'a qr_code::State>, + labels: &'a HashMap, + labels_editing: &'a HashMap>, ) -> Element<'a, Message> { Column::new() .push( @@ -38,34 +47,54 @@ pub fn receive<'a>( .push(addresses.iter().rev().fold( Column::new().spacing(10).width(Length::Fill), |col, address| { + let addr = address.to_string(); col.push( card::simple( - Row::new() - .push( - Container::new( - scrollable( - Column::new() - .push(Space::with_height(Length::Fixed(10.0))) - .push( - p2_regular(address.to_string()) - .small() - .style(color::GREY_3), - ) - // Space between the address and the scrollbar - .push(Space::with_height(Length::Fixed(10.0))), - ) - .horizontal_scroll( - scrollable::Properties::new().scroller_width(5), - ), + Column::new() + .push(if let Some(label) = labels_editing.get(&addr) { + label::label_editing(addr.clone(), label, text::P1_SIZE) + } else { + label::label_editable( + addr.clone(), + labels.get(&addr), + text::P1_SIZE, ) - .width(Length::Fill), - ) + }) .push( - Button::new(icon::clipboard_icon().style(color::GREY_3)) - .on_press(Message::Clipboard(address.to_string())) - .style(theme::Button::TransparentBorder), - ) - .align_items(Alignment::Center), + Row::new() + .push( + Container::new( + scrollable( + Column::new() + .push(Space::with_height( + Length::Fixed(10.0), + )) + .push( + p2_regular(addr) + .small() + .style(color::GREY_3), + ) + // Space between the address and the scrollbar + .push(Space::with_height( + Length::Fixed(10.0), + )), + ) + .horizontal_scroll( + scrollable::Properties::new() + .scroller_width(5), + ), + ) + .width(Length::Fill), + ) + .push( + Button::new( + icon::clipboard_icon().style(color::GREY_3), + ) + .on_press(Message::Clipboard(address.to_string())) + .style(theme::Button::TransparentBorder), + ) + .align_items(Alignment::Center), + ), ) .padding(20), ) diff --git a/gui/src/app/view/spend/mod.rs b/gui/src/app/view/spend/mod.rs index 1a4cdee8..88b2a4dc 100644 --- a/gui/src/app/view/spend/mod.rs +++ b/gui/src/app/view/spend/mod.rs @@ -35,23 +35,26 @@ pub fn spend_view<'a>( saved: bool, desc_info: &'a LianaPolicy, key_aliases: &'a HashMap, + labels_editing: &'a HashMap>, network: Network, + warning: Option<&Error>, ) -> Element<'a, Message> { dashboard( &Menu::CreateSpendTx, cache, - None, + warning, Column::new() .spacing(20) .push(Container::new(h3("Send")).width(Length::Fill)) - .push(psbt::spend_header(tx)) + .push(psbt::spend_header(tx, labels_editing)) .push(psbt::spend_overview_view(tx, desc_info, key_aliases)) .push(psbt::inputs_and_outputs_view( &tx.coins, &tx.psbt.unsigned_tx, network, Some(tx.change_indexes.clone()), - None, + &tx.labels, + labels_editing, )) .push(if saved { Row::new() @@ -89,6 +92,8 @@ pub fn create_spend_tx<'a>( duplicate: bool, timelock: u16, coins: &[(Coin, bool)], + coins_labels: &'a HashMap, + batch_label: &form::Value, amount_left: Option<&Amount>, feerate: &form::Value, error: Option<&Error>, @@ -104,6 +109,18 @@ pub fn create_spend_tx<'a>( } else { "Send" })) + .push_maybe(if recipients.len() > 1 { + Some( + form::Form::new("Batch label", batch_label, |s| { + Message::CreateSpend(CreateSpendMessage::BatchLabelEdited(s)) + }) + .warning("Invalid label length, cannot be superior to 100") + .size(30) + .padding(10), + ) + } else { + None + }) .push( Column::new() .push(Column::with_children(recipients).spacing(10)) @@ -112,7 +129,7 @@ pub fn create_spend_tx<'a>( .push_maybe(if duplicate { Some( Container::new( - text("Two recipient addresses are the same") + text("Two payment addresses are the same") .style(color::RED), ) .padding(10), @@ -125,7 +142,7 @@ pub fn create_spend_tx<'a>( None } else { Some( - button::secondary(Some(icon::plus_icon()), "Add recipient") + button::secondary(Some(icon::plus_icon()), "Add payment") .on_press(Message::CreateSpend( CreateSpendMessage::AddRecipient, )), @@ -201,6 +218,7 @@ pub fn create_spend_tx<'a>( col.push(coin_list_view( i, coin, + coins_labels, timelock, cache.blockheight as u32, *selected, @@ -246,6 +264,7 @@ pub fn recipient_view<'a>( index: usize, address: &form::Value, amount: &form::Value, + label: &form::Value, ) -> Element<'a, CreateSpendMessage> { Container::new( Column::new() @@ -263,10 +282,10 @@ pub fn recipient_view<'a>( .align_items(Alignment::Start) .spacing(10) .push( - Container::new(p1_bold("Pay to")) + Container::new(p1_bold("Address")) .align_x(alignment::Horizontal::Right) .padding(10) - .width(Length::Fixed(80.0)), + .width(Length::Fixed(110.0)), ) .push( form::Form::new_trimmed("Address", address, move |msg| { @@ -277,6 +296,25 @@ pub fn recipient_view<'a>( .padding(10), ), ) + .push( + Row::new() + .align_items(Alignment::Start) + .spacing(10) + .push( + Container::new(p1_bold("Description")) + .align_x(alignment::Horizontal::Right) + .padding(10) + .width(Length::Fixed(110.0)), + ) + .push( + form::Form::new("Payment label", label, move |msg| { + CreateSpendMessage::RecipientEdited(index, "label", msg) + }) + .warning("Label length is too long (> 100 char)") + .size(20) + .padding(10), + ), + ) .push( Row::new() .align_items(Alignment::Start) @@ -285,7 +323,7 @@ pub fn recipient_view<'a>( Container::new(p1_bold("Amount")) .padding(10) .align_x(alignment::Horizontal::Right) - .width(Length::Fixed(80.0)), + .width(Length::Fixed(110.0)), ) .push( form::Form::new_trimmed("0.001 (in BTC)", amount, move |msg| { @@ -308,6 +346,7 @@ pub fn recipient_view<'a>( fn coin_list_view<'a>( i: usize, coin: &Coin, + coins_labels: &'a HashMap, timelock: u16, blockheight: u32, selected: bool, @@ -318,6 +357,13 @@ fn coin_list_view<'a>( .push(checkbox("", selected, move |_| { Message::CreateSpend(CreateSpendMessage::SelectCoin(i)) })) + .push( + if let Some(label) = coins_labels.get(&coin.outpoint.to_string()) { + Container::new(p1_bold(label)).width(Length::Fill) + } else { + Container::new(p1_bold("")).width(Length::Fill) + }, + ) .push(if coin.spend_info.is_some() { badge::spent() } else if coin.block_height.is_none() { diff --git a/gui/src/app/view/transactions.rs b/gui/src/app/view/transactions.rs index 20491080..d0430674 100644 --- a/gui/src/app/view/transactions.rs +++ b/gui/src/app/view/transactions.rs @@ -1,10 +1,11 @@ use chrono::NaiveDateTime; +use std::collections::HashMap; use iced::{alignment, Alignment, Length}; use liana_ui::{ color, - component::{amount::*, badge, card, text::*}, + component::{amount::*, badge, card, form, text::*}, icon, theme, util::Collection, widget::*, @@ -15,7 +16,7 @@ use crate::{ cache::Cache, error::Error, menu::Menu, - view::{dashboard, message::Message}, + view::{dashboard, label, message::Message}, }, daemon::model::HistoryTransaction, }; @@ -24,8 +25,8 @@ pub const HISTORY_EVENT_PAGE_SIZE: u64 = 20; pub fn transactions_view<'a>( cache: &'a Cache, - pending_txs: &[HistoryTransaction], - txs: &Vec, + pending_txs: &'a [HistoryTransaction], + txs: &'a Vec, warning: Option<&'a Error>, ) -> Element<'a, Message> { dashboard( @@ -83,7 +84,7 @@ pub fn transactions_view<'a>( ) } -fn tx_list_view<'a>(i: usize, tx: &HistoryTransaction) -> Element<'a, Message> { +fn tx_list_view<'a>(i: usize, tx: &'a HistoryTransaction) -> Element<'a, Message> { Container::new( Button::new( Row::new() @@ -96,23 +97,40 @@ fn tx_list_view<'a>(i: usize, tx: &HistoryTransaction) -> Element<'a, Message> { } else { badge::spend() }) - .push(if let Some(t) = tx.time { - Container::new( - text(format!( - "{}", - NaiveDateTime::from_timestamp_opt(t as i64, 0) - .unwrap() - .format("%b. %d, %Y - %T"), - )) - .small(), - ) - } else { - badge::unconfirmed() - }) + .push( + Column::new() + .push_maybe( + tx.labels + .get(&tx.tx.txid().to_string()) + .map(|label| p1_bold(label)), + ) + .push_maybe(tx.time.map(|t| { + Container::new( + text(format!( + "{}", + NaiveDateTime::from_timestamp_opt(t as i64, 0) + .unwrap() + .format("%b. %d, %Y - %T"), + )) + .style(color::GREY_3) + .small(), + ) + })), + ) .spacing(10) .align_items(Alignment::Center) .width(Length::Fill), ) + .push_maybe(if tx.time.is_none() { + Some(badge::unconfirmed()) + } else { + None + }) + .push_maybe(if tx.is_batch() { + Some(badge::batch()) + } else { + None + }) .push(if tx.is_external() { Row::new() .spacing(5) @@ -142,8 +160,10 @@ fn tx_list_view<'a>(i: usize, tx: &HistoryTransaction) -> Element<'a, Message> { pub fn tx_view<'a>( cache: &'a Cache, tx: &'a HistoryTransaction, + labels_editing: &'a HashMap>, warning: Option<&'a Error>, ) -> Element<'a, Message> { + let txid = tx.tx.txid().to_string(); dashboard( &Menu::Transactions, cache, @@ -156,6 +176,11 @@ pub fn tx_view<'a>( } else { Container::new(h3("Outgoing transaction")).width(Length::Fill) }) + .push(if let Some(label) = labels_editing.get(&txid) { + label::label_editing(txid.clone(), label, H3_SIZE) + } else { + label::label_editable(txid.clone(), tx.labels.get(&txid), H1_SIZE) + }) .push( Column::new().spacing(20).push( Column::new() @@ -202,10 +227,10 @@ pub fn tx_view<'a>( .push( Row::new() .align_items(Alignment::Center) - .push(Container::new(text(format!("{}", tx.tx.txid())).small())) + .push(Container::new(text(txid.clone()).small())) .push( Button::new(icon::clipboard_icon()) - .on_press(Message::Clipboard(tx.tx.txid().to_string())) + .on_press(Message::Clipboard(txid.clone())) .style(theme::Button::TransparentBorder), ) .width(Length::Shrink), @@ -222,11 +247,8 @@ pub fn tx_view<'a>( } else { Some(tx.change_indexes.clone()) }, - if tx.is_external() { - Some(tx.change_indexes.clone()) - } else { - None - }, + &tx.labels, + labels_editing, )) .spacing(20), ) diff --git a/gui/src/daemon/client/mod.rs b/gui/src/daemon/client/mod.rs index 6f4e1196..185dfb2a 100644 --- a/gui/src/daemon/client/mod.rs +++ b/gui/src/daemon/client/mod.rs @@ -1,5 +1,6 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt::Debug; +use std::iter::FromIterator; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -10,6 +11,7 @@ pub mod error; pub mod jsonrpc; use liana::{ + commands::LabelItem, config::Config, miniscript::bitcoin::{address, psbt::Psbt, Address, OutPoint, Txid}, }; @@ -145,6 +147,22 @@ impl Daemon for Lianad { )?; Ok(res.psbt) } + + fn get_labels( + &self, + items: &HashSet, + ) -> Result, DaemonError> { + let items = items.iter().map(|a| a.to_string()).collect::>(); + let res: GetLabelsResult = self.call("getlabels", Some(vec![items]))?; + Ok(res.labels) + } + + fn update_labels(&self, items: &HashMap) -> Result<(), DaemonError> { + let labels: HashMap = + HashMap::from_iter(items.iter().map(|(a, l)| (a.to_string(), l.clone()))); + let _res: serde_json::value::Value = self.call("updatelabels", Some(vec![labels]))?; + Ok(()) + } } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/gui/src/daemon/embedded.rs b/gui/src/daemon/embedded.rs index 686bd966..af07036d 100644 --- a/gui/src/daemon/embedded.rs +++ b/gui/src/daemon/embedded.rs @@ -1,7 +1,8 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use super::{model::*, Daemon, DaemonError}; use liana::{ + commands::LabelItem, config::Config, miniscript::bitcoin::{address, psbt::Psbt, Address, OutPoint, Txid}, DaemonControl, DaemonHandle, @@ -126,4 +127,16 @@ impl Daemon for EmbeddedDaemon { .map_err(|e| DaemonError::Unexpected(e.to_string())) .map(|res| res.psbt) } + + fn get_labels( + &self, + items: &HashSet, + ) -> Result, DaemonError> { + Ok(self.handle.control.get_labels(items).labels) + } + + fn update_labels(&self, items: &HashMap) -> Result<(), DaemonError> { + self.handle.control.update_labels(items); + Ok(()) + } } diff --git a/gui/src/daemon/mod.rs b/gui/src/daemon/mod.rs index 1c4e975f..6d5b1743 100644 --- a/gui/src/daemon/mod.rs +++ b/gui/src/daemon/mod.rs @@ -2,11 +2,12 @@ pub mod client; pub mod embedded; pub mod model; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt::Debug; use std::io::ErrorKind; use liana::{ + commands::LabelItem, config::Config, miniscript::bitcoin::{address, psbt::Psbt, Address, OutPoint, Txid}, StartupError, @@ -75,6 +76,11 @@ pub trait Daemon: Debug { sequence: Option, ) -> Result; fn list_txs(&self, txid: &[Txid]) -> Result; + fn get_labels( + &self, + labels: &HashSet, + ) -> Result, DaemonError>; + fn update_labels(&self, labels: &HashMap) -> Result<(), DaemonError>; fn list_spend_transactions(&self) -> Result, DaemonError> { let info = self.get_info()?; @@ -103,8 +109,10 @@ pub trait Daemon: Debug { coins, sigs, info.descriptors.main.max_sat_vbytes(), + info.network, )) } + load_labels(self, &mut spend_txs)?; spend_txs.sort_by(|a, b| { if a.status == b.status { // last updated first @@ -123,9 +131,10 @@ pub trait Daemon: Debug { end: u32, limit: u64, ) -> Result, DaemonError> { + let info = self.get_info()?; let coins = self.list_coins()?.coins; let txs = self.list_confirmed_txs(start, end, limit)?.transactions; - Ok(txs + let mut txs = txs .into_iter() .map(|tx| { let mut tx_coins = Vec::new(); @@ -142,12 +151,22 @@ pub trait Daemon: Debug { tx_coins.push(coin.clone()); } } - model::HistoryTransaction::new(tx.tx, tx.height, tx.time, tx_coins, change_indexes) + model::HistoryTransaction::new( + tx.tx, + tx.height, + tx.time, + tx_coins, + change_indexes, + info.network, + ) }) - .collect()) + .collect(); + load_labels(self, &mut txs)?; + Ok(txs) } fn list_pending_txs(&self) -> Result, DaemonError> { + let info = self.get_info()?; let coins = self.list_coins()?.coins; let mut txids: Vec = Vec::new(); for coin in &coins { @@ -163,7 +182,7 @@ pub trait Daemon: Debug { } let txs = self.list_txs(&txids)?.transactions; - Ok(txs + let mut txs = txs .into_iter() .map(|tx| { let mut tx_coins = Vec::new(); @@ -180,8 +199,38 @@ pub trait Daemon: Debug { tx_coins.push(coin.clone()); } } - model::HistoryTransaction::new(tx.tx, tx.height, tx.time, tx_coins, change_indexes) + model::HistoryTransaction::new( + tx.tx, + tx.height, + tx.time, + tx_coins, + change_indexes, + info.network, + ) }) - .collect()) + .collect(); + + load_labels(self, &mut txs)?; + Ok(txs) } } + +fn load_labels( + daemon: &D, + targets: &mut Vec, +) -> Result<(), DaemonError> { + if targets.is_empty() { + return Ok(()); + } + let mut items = HashSet::::new(); + for target in &*targets { + for item in target.labelled() { + items.insert(item); + } + } + let labels = daemon.get_labels(&items)?; + for target in targets { + target.load_labels(&labels); + } + Ok(()) +} diff --git a/gui/src/daemon/model.rs b/gui/src/daemon/model.rs index b36d9e45..10b33cf0 100644 --- a/gui/src/daemon/model.rs +++ b/gui/src/daemon/model.rs @@ -1,12 +1,15 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; pub use liana::{ commands::{ - CreateSpendResult, GetAddressResult, GetInfoResult, ListCoinsEntry, ListCoinsResult, - ListSpendEntry, ListSpendResult, ListTransactionsResult, TransactionInfo, + CreateSpendResult, GetAddressResult, GetInfoResult, GetLabelsResult, LabelItem, + ListCoinsEntry, ListCoinsResult, ListSpendEntry, ListSpendResult, ListTransactionsResult, + TransactionInfo, }, descriptors::{PartialSpendInfo, PathSpendInfo}, - miniscript::bitcoin::{bip32::Fingerprint, psbt::Psbt, Amount, Transaction}, + miniscript::bitcoin::{ + bip32::Fingerprint, psbt::Psbt, Address, Amount, Network, OutPoint, Transaction, Txid, + }, }; pub type Coin = ListCoinsEntry; @@ -25,7 +28,9 @@ pub fn remaining_sequence(coin: &Coin, blockheight: u32, timelock: u16) -> u32 { #[derive(Debug, Clone)] pub struct SpendTx { + network: Network, pub coins: Vec, + pub labels: HashMap, pub psbt: Psbt, pub change_indexes: Vec, pub spend_amount: Amount, @@ -53,6 +58,7 @@ impl SpendTx { coins: Vec, sigs: PartialSpendInfo, max_sat_vbytes: usize, + network: Network, ) -> Self { let mut change_indexes = Vec::new(); let (change_amount, spend_amount) = psbt.unsigned_tx.output.iter().enumerate().fold( @@ -85,6 +91,7 @@ impl SpendTx { } Self { + labels: HashMap::new(), updated_at, coins, psbt, @@ -94,6 +101,7 @@ impl SpendTx { max_sat_vbytes, status, sigs, + network, } } @@ -135,10 +143,48 @@ impl SpendTx { self.psbt.unsigned_tx.vsize() + (self.max_sat_vbytes * self.psbt.inputs.len()); self.fee_amount.to_sat() / max_tx_vbytes as u64 } + + pub fn is_batch(&self) -> bool { + self.psbt + .unsigned_tx + .output + .iter() + .enumerate() + .filter(|(i, _)| !self.change_indexes.contains(&i)) + .count() + > 1 + } +} + +impl Labelled for SpendTx { + fn labels(&mut self) -> &mut HashMap { + &mut self.labels + } + fn labelled(&self) -> Vec { + let mut items = Vec::new(); + let txid = self.psbt.unsigned_tx.txid(); + items.push(LabelItem::Txid(txid)); + for coin in &self.coins { + items.push(LabelItem::Address(coin.address.clone())); + items.push(LabelItem::OutPoint(coin.outpoint.clone())); + } + for (vout, output) in self.psbt.unsigned_tx.output.iter().enumerate() { + items.push(LabelItem::OutPoint(OutPoint { + txid, + vout: vout as u32, + })); + items.push(LabelItem::Address( + Address::from_script(&output.script_pubkey, self.network).unwrap(), + )); + } + items + } } #[derive(Debug, Clone)] pub struct HistoryTransaction { + pub network: Network, + pub labels: HashMap, pub coins: Vec, pub change_indexes: Vec, pub tx: Transaction, @@ -156,6 +202,7 @@ impl HistoryTransaction { time: Option, coins: Vec, change_indexes: Vec, + network: Network, ) -> Self { let (incoming_amount, outgoing_amount) = tx.output.iter().enumerate().fold( (Amount::from_sat(0), Amount::from_sat(0)), @@ -180,6 +227,7 @@ impl HistoryTransaction { }; Self { + labels: HashMap::new(), tx, coins, change_indexes, @@ -188,6 +236,7 @@ impl HistoryTransaction { fee_amount, height, time, + network, } } @@ -198,4 +247,54 @@ impl HistoryTransaction { pub fn is_self_send(&self) -> bool { !self.coins.is_empty() && self.outgoing_amount == Amount::from_sat(0) } + + pub fn is_batch(&self) -> bool { + self.tx + .output + .iter() + .enumerate() + .filter(|(i, _)| !self.change_indexes.contains(&i)) + .count() + > 1 + } +} + +impl Labelled for HistoryTransaction { + fn labels(&mut self) -> &mut HashMap { + &mut self.labels + } + fn labelled(&self) -> Vec { + let mut items = Vec::new(); + let txid = self.tx.txid(); + items.push(LabelItem::Txid(txid)); + for coin in &self.coins { + items.push(LabelItem::Address(coin.address.clone())); + items.push(LabelItem::OutPoint(coin.outpoint.clone())); + } + for (vout, output) in self.tx.output.iter().enumerate() { + items.push(LabelItem::OutPoint(OutPoint { + txid, + vout: vout as u32, + })); + items.push(LabelItem::Address( + Address::from_script(&output.script_pubkey, self.network).unwrap(), + )); + } + items + } +} + +pub trait Labelled { + fn labelled(&self) -> Vec; + fn labels(&mut self) -> &mut HashMap; + fn load_labels(&mut self, new_labels: &HashMap) { + let items = self.labelled(); + let labels = self.labels(); + for item in items { + let item_str = item.to_string(); + if let Some(l) = new_labels.get(&item_str) { + labels.insert(item_str, l.to_string()); + } + } + } } diff --git a/gui/ui/src/component/badge.rs b/gui/ui/src/component/badge.rs index 199dc165..e64f70f1 100644 --- a/gui/ui/src/component/badge.rs +++ b/gui/ui/src/component/badge.rs @@ -100,6 +100,19 @@ pub fn unconfirmed<'a, T: 'a>() -> Container<'a, T> { ) } +pub fn batch<'a, T: 'a>() -> Container<'a, T> { + Container::new( + tooltip::Tooltip::new( + Container::new(text::p2_regular(" Batch ")) + .padding(10) + .style(theme::Container::Pill(theme::Pill::Simple)), + "This transaction contains multiple payments", + tooltip::Position::Top, + ) + .style(theme::Container::Card(theme::Card::Simple)), + ) +} + pub fn deprecated<'a, T: 'a>() -> Container<'a, T> { Container::new( tooltip::Tooltip::new( diff --git a/gui/ui/src/component/event.rs b/gui/ui/src/component/event.rs index b97e4d78..ffb4bd80 100644 --- a/gui/ui/src/component/event.rs +++ b/gui/ui/src/component/event.rs @@ -1,6 +1,8 @@ use crate::{ + color, component::{amount, badge, text}, theme, + util::Collection, widget::*, }; use bitcoin::Amount; @@ -9,14 +11,21 @@ use iced::{ Alignment, Length, }; -pub fn unconfirmed_outgoing_event<'a, T: Clone + 'a>(amount: &Amount, msg: T) -> Container<'a, T> { +pub fn unconfirmed_outgoing_event<'a, T: Clone + 'a>( + label: Option>>, + amount: &Amount, + msg: T, +) -> Container<'a, T> { Container::new( button( row!( - row!(badge::spend(), badge::unconfirmed()) - .spacing(10) - .align_items(Alignment::Center) - .width(Length::Fill), + row!( + badge::spend(), + Column::new().push_maybe(label).push(badge::unconfirmed()) + ) + .spacing(10) + .align_items(Alignment::Center) + .width(Length::Fill), row!(text::p1_regular("-"), amount::amount(amount)) .spacing(5) .align_items(Alignment::Center), @@ -32,6 +41,7 @@ pub fn unconfirmed_outgoing_event<'a, T: Clone + 'a>(amount: &Amount, msg: T) -> } pub fn confirmed_outgoing_event<'a, T: Clone + 'a>( + label: Option>>, date: chrono::NaiveDateTime, amount: &Amount, msg: T, @@ -41,7 +51,10 @@ pub fn confirmed_outgoing_event<'a, T: Clone + 'a>( row!( row!( badge::spend(), - text::p2_regular(date.format("%b. %d, %Y - %T").to_string()) + Column::new().push_maybe(label).push( + text::p2_regular(date.format("%b. %d, %Y - %T").to_string()) + .style(color::GREY_3) + ) ) .spacing(10) .align_items(Alignment::Center) @@ -60,30 +73,8 @@ pub fn confirmed_outgoing_event<'a, T: Clone + 'a>( .style(theme::Container::Card(theme::Card::Simple)) } -pub fn unconfirmed_incoming_event<'a, T: Clone + 'a>(amount: &Amount, msg: T) -> Container<'a, T> { - Container::new( - button( - row!( - row!(badge::receive(), badge::unconfirmed()) - .spacing(10) - .align_items(Alignment::Center) - .width(Length::Fill), - row!(text::p1_regular("+"), amount::amount(amount)) - .spacing(5) - .align_items(Alignment::Center), - ) - .align_items(Alignment::Center) - .padding(5) - .spacing(20), - ) - .on_press(msg) - .style(theme::Button::TransparentBorder), - ) - .style(theme::Container::Card(theme::Card::Simple)) -} - -pub fn confirmed_incoming_event<'a, T: Clone + 'a>( - date: chrono::NaiveDateTime, +pub fn unconfirmed_incoming_event<'a, T: Clone + 'a>( + label: Option>>, amount: &Amount, msg: T, ) -> Container<'a, T> { @@ -92,7 +83,40 @@ pub fn confirmed_incoming_event<'a, T: Clone + 'a>( row!( row!( badge::receive(), - text::p2_regular(date.format("%b. %d, %Y - %T").to_string()) + Column::new().push_maybe(label).push(badge::unconfirmed()) + ) + .spacing(10) + .align_items(Alignment::Center) + .width(Length::Fill), + row!(text::p1_regular("+"), amount::amount(amount)) + .spacing(5) + .align_items(Alignment::Center), + ) + .align_items(Alignment::Center) + .padding(5) + .spacing(20), + ) + .on_press(msg) + .style(theme::Button::TransparentBorder), + ) + .style(theme::Container::Card(theme::Card::Simple)) +} + +pub fn confirmed_incoming_event<'a, T: Clone + 'a>( + label: Option>>, + date: chrono::NaiveDateTime, + amount: &Amount, + msg: T, +) -> Container<'a, T> { + Container::new( + button( + row!( + row!( + badge::receive(), + Column::new().push_maybe(label).push( + text::p2_regular(date.format("%b. %d, %Y - %T").to_string()) + .style(color::GREY_3) + ) ) .spacing(10) .align_items(Alignment::Center) From 2354ac9175b5766286852a035c106e44425ffb72 Mon Sep 17 00:00:00 2001 From: edouard Date: Tue, 29 Aug 2023 13:38:12 +0200 Subject: [PATCH 2/6] cargo clippy --fix --lib -p liana_gui --- gui/src/app/settings.rs | 4 ++-- gui/src/app/state/coins.rs | 10 ++-------- gui/src/app/state/mod.rs | 4 ++-- gui/src/app/state/psbt.rs | 8 ++++---- gui/src/app/state/spend/mod.rs | 2 +- gui/src/app/state/spend/step.rs | 2 +- gui/src/app/state/transactions.rs | 2 +- gui/src/app/view/coins.rs | 2 +- gui/src/app/view/home.rs | 17 +++++++++-------- gui/src/app/view/label.rs | 6 +++--- gui/src/app/view/psbt.rs | 7 ++++--- gui/src/app/view/psbts.rs | 6 +++--- gui/src/app/view/spend/mod.rs | 1 + gui/src/app/view/transactions.rs | 8 ++------ gui/src/daemon/model.rs | 8 ++++---- 15 files changed, 40 insertions(+), 47 deletions(-) diff --git a/gui/src/app/settings.rs b/gui/src/app/settings.rs index 2d62d9e5..ea82ea63 100644 --- a/gui/src/app/settings.rs +++ b/gui/src/app/settings.rs @@ -1,3 +1,5 @@ +//! Settings is the module to handle the GUI settings file. +//! The settings file is used by the GUI to store useful information. use std::collections::HashMap; use std::fs::OpenOptions; use std::io::Write; @@ -8,8 +10,6 @@ use serde::{Deserialize, Serialize}; use crate::{app::wallet::Wallet, hw::HardwareWalletConfig}; -///! Settings is the module to handle the GUI settings file. -///! The settings file is used by the GUI to store useful information. pub const DEFAULT_FILE_NAME: &str = "settings.json"; #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/gui/src/app/state/coins.rs b/gui/src/app/state/coins.rs index 3af65bb5..23490db5 100644 --- a/gui/src/app/state/coins.rs +++ b/gui/src/app/state/coins.rs @@ -64,13 +64,7 @@ impl CoinsPanel { fn update_coins(&mut self, coins: &[Coin]) { self.coins.list = coins .iter() - .filter_map(|coin| { - if coin.spend_info.is_none() { - Some(coin) - } else { - None - } - }) + .filter(|coin| coin.spend_info.is_none()) .cloned() .collect(); @@ -171,7 +165,7 @@ impl State for CoinsPanel { let coins = daemon2 .list_coins() .map(|res| res.coins) - .map_err(|e| Error::from(e))?; + .map_err(Error::from)?; let mut targets = HashSet::::new(); for coin in coins { targets.insert(LabelItem::OutPoint(coin.outpoint)); diff --git a/gui/src/app/state/mod.rs b/gui/src/app/state/mod.rs index 9c4bcfd1..b6292e06 100644 --- a/gui/src/app/state/mod.rs +++ b/gui/src/app/state/mod.rs @@ -102,7 +102,7 @@ impl State for Home { cache, event, output_index, - &self.labels_edited.cache(), + self.labels_edited.cache(), self.warning.as_ref(), ) } else { @@ -329,7 +329,7 @@ impl State for ReceivePanel { &self.addresses.list, self.qr_code.as_ref(), &self.addresses.labels, - &self.labels_edited.cache(), + self.labels_edited.cache(), ), ) } diff --git a/gui/src/app/state/psbt.rs b/gui/src/app/state/psbt.rs index ad76079e..6d27bc71 100644 --- a/gui/src/app/state/psbt.rs +++ b/gui/src/app/state/psbt.rs @@ -89,7 +89,7 @@ impl PsbtState { self.action = None; } view::SpendTxMessage::Delete => { - self.action = Some(Box::new(DeleteAction::default())); + self.action = Some(Box::::default()); } view::SpendTxMessage::Sign => { let action = SignAction::new(self.tx.signers(), self.wallet.clone()); @@ -104,10 +104,10 @@ impl PsbtState { return cmd; } view::SpendTxMessage::Broadcast => { - self.action = Some(Box::new(BroadcastAction::default())); + self.action = Some(Box::::default()); } view::SpendTxMessage::Save => { - self.action = Some(Box::new(SaveAction::default())); + self.action = Some(Box::::default()); } _ => { if let Some(action) = self.action.as_mut() { @@ -151,7 +151,7 @@ impl PsbtState { self.saved, &self.desc_policy, &self.wallet.keys_aliases, - &self.labels_edited.cache(), + self.labels_edited.cache(), cache.network, self.warning.as_ref(), ); diff --git a/gui/src/app/state/spend/mod.rs b/gui/src/app/state/spend/mod.rs index 59697694..31a585a5 100644 --- a/gui/src/app/state/spend/mod.rs +++ b/gui/src/app/state/spend/mod.rs @@ -122,7 +122,7 @@ impl State for CreateSpendPanel { let coins = daemon .list_coins() .map(|res| res.coins) - .map_err(|e| Error::from(e))?; + .map_err(Error::from)?; let mut targets = HashSet::::new(); for coin in coins { targets.insert(LabelItem::OutPoint(coin.outpoint)); diff --git a/gui/src/app/state/spend/step.rs b/gui/src/app/state/spend/step.rs index 9038e1bd..63d98ba4 100644 --- a/gui/src/app/state/spend/step.rs +++ b/gui/src/app/state/spend/step.rs @@ -549,7 +549,7 @@ impl Step for SaveSpend { spend.saved, &spend.desc_policy, &spend.wallet.keys_aliases, - &spend.labels_edited.cache(), + spend.labels_edited.cache(), cache.network, spend.warning.as_ref(), ); diff --git a/gui/src/app/state/transactions.rs b/gui/src/app/state/transactions.rs index 076636df..3eed0a29 100644 --- a/gui/src/app/state/transactions.rs +++ b/gui/src/app/state/transactions.rs @@ -52,7 +52,7 @@ impl State for TransactionsPanel { view::transactions::tx_view( cache, tx, - &self.labels_edited.cache(), + self.labels_edited.cache(), self.warning.as_ref(), ) } else { diff --git a/gui/src/app/view/coins.rs b/gui/src/app/view/coins.rs index 8e786e0c..1bab2fec 100644 --- a/gui/src/app/view/coins.rs +++ b/gui/src/app/view/coins.rs @@ -259,7 +259,7 @@ pub fn coin_sequence_label<'a, T: 'a>(seq: u32, timelock: u32) -> Container<'a, ) .padding(10) .style(theme::Container::Pill(theme::Pill::Warning)) - } else if seq < timelock as u32 * 10 / 100 { + } else if seq < timelock * 10 / 100 { Container::new( Row::new() .spacing(5) diff --git a/gui/src/app/view/home.rs b/gui/src/app/view/home.rs index 498563e3..131a9f8e 100644 --- a/gui/src/app/view/home.rs +++ b/gui/src/app/view/home.rs @@ -146,7 +146,7 @@ pub fn home_view<'a>( .into() } -fn event_list_view<'a>(i: usize, event: &'a HistoryTransaction) -> Column<'a, Message> { +fn event_list_view(i: usize, event: &HistoryTransaction) -> Column<'_, Message> { event.tx.output.iter().enumerate().fold( Column::new().spacing(10), |col, (output_index, output)| { @@ -158,14 +158,15 @@ fn event_list_view<'a>(i: usize, event: &'a HistoryTransaction) -> Column<'a, Me .to_string(), ) { Some(p1_bold(label)) - } else if let Some(label) = event.labels.get( - &bitcoin::Address::from_script(&output.script_pubkey, event.network) - .unwrap() - .to_string(), - ) { - Some(p1_bold(format!("address label: {}", label)).style(color::GREY_3)) } else { - None + event + .labels + .get( + &bitcoin::Address::from_script(&output.script_pubkey, event.network) + .unwrap() + .to_string(), + ) + .map(|label| p1_bold(format!("address label: {}", label)).style(color::GREY_3)) }; if event.is_external() { if !event.change_indexes.contains(&output_index) { diff --git a/gui/src/app/view/label.rs b/gui/src/app/view/label.rs index bd2cfbfe..c7d2dd71 100644 --- a/gui/src/app/view/label.rs +++ b/gui/src/app/view/label.rs @@ -9,11 +9,11 @@ use liana_ui::{ use crate::app::view; -pub fn label_editable<'a>( +pub fn label_editable( labelled: String, - label: Option<&'a String>, + label: Option<&String>, size: u16, -) -> Element<'a, view::Message> { +) -> Element<'_, view::Message> { if let Some(label) = label { if !label.is_empty() { return Container::new( diff --git a/gui/src/app/view/psbt.rs b/gui/src/app/view/psbt.rs index 17f5b5ed..b0bad4ce 100644 --- a/gui/src/app/view/psbt.rs +++ b/gui/src/app/view/psbt.rs @@ -39,6 +39,7 @@ use crate::{ hw::HardwareWallet, }; +#[allow(clippy::too_many_arguments)] pub fn psbt_view<'a>( cache: &'a Cache, tx: &'a SpendTx, @@ -590,7 +591,7 @@ pub fn inputs_and_outputs_view<'a>( .enumerate() .filter(|(i, _)| { if let Some(indexes) = change_indexes_copy.as_ref() { - !indexes.contains(&i) + !indexes.contains(i) } else { true } @@ -640,7 +641,7 @@ pub fn inputs_and_outputs_view<'a>( .enumerate() .filter(|(i, _)| { if let Some(indexes) = change_indexes_copy.as_ref() { - !indexes.contains(&i) + !indexes.contains(i) } else { true } @@ -703,7 +704,7 @@ pub fn inputs_and_outputs_view<'a>( tx.output .iter() .enumerate() - .filter(|(i, _)| change_indexes.as_ref().unwrap().contains(&i)) + .filter(|(i, _)| change_indexes.as_ref().unwrap().contains(i)) .fold( Column::new().padding(20), |col: Column<'a, Message>, (i, output)| { diff --git a/gui/src/app/view/psbts.rs b/gui/src/app/view/psbts.rs index 2f37599c..1cff99c9 100644 --- a/gui/src/app/view/psbts.rs +++ b/gui/src/app/view/psbts.rs @@ -59,7 +59,7 @@ pub fn import_psbt_success_view<'a>() -> Element<'a, Message> { .into() } -pub fn psbts_view<'a>(spend_txs: &'a [SpendTx]) -> Element<'a, Message> { +pub fn psbts_view(spend_txs: &[SpendTx]) -> Element<'_, Message> { Column::new() .push( Row::new() @@ -90,7 +90,7 @@ pub fn psbts_view<'a>(spend_txs: &'a [SpendTx]) -> Element<'a, Message> { .into() } -fn spend_tx_list_view<'a>(i: usize, tx: &'a SpendTx) -> Element<'a, Message> { +fn spend_tx_list_view(i: usize, tx: &SpendTx) -> Element<'_, Message> { Container::new( Button::new( Row::new() @@ -127,7 +127,7 @@ fn spend_tx_list_view<'a>(i: usize, tx: &'a SpendTx) -> Element<'a, Message> { .push_maybe( tx.labels .get(&tx.psbt.unsigned_tx.txid().to_string()) - .map(|label| p1_bold(label)), + .map(p1_bold), ) .spacing(10) .align_items(Alignment::Center) diff --git a/gui/src/app/view/spend/mod.rs b/gui/src/app/view/spend/mod.rs index 88b2a4dc..8c34ad5e 100644 --- a/gui/src/app/view/spend/mod.rs +++ b/gui/src/app/view/spend/mod.rs @@ -29,6 +29,7 @@ use crate::{ daemon::model::{remaining_sequence, Coin, SpendTx}, }; +#[allow(clippy::too_many_arguments)] pub fn spend_view<'a>( cache: &'a Cache, tx: &'a SpendTx, diff --git a/gui/src/app/view/transactions.rs b/gui/src/app/view/transactions.rs index d0430674..838afe44 100644 --- a/gui/src/app/view/transactions.rs +++ b/gui/src/app/view/transactions.rs @@ -84,7 +84,7 @@ pub fn transactions_view<'a>( ) } -fn tx_list_view<'a>(i: usize, tx: &'a HistoryTransaction) -> Element<'a, Message> { +fn tx_list_view(i: usize, tx: &HistoryTransaction) -> Element<'_, Message> { Container::new( Button::new( Row::new() @@ -99,11 +99,7 @@ fn tx_list_view<'a>(i: usize, tx: &'a HistoryTransaction) -> Element<'a, Message }) .push( Column::new() - .push_maybe( - tx.labels - .get(&tx.tx.txid().to_string()) - .map(|label| p1_bold(label)), - ) + .push_maybe(tx.labels.get(&tx.tx.txid().to_string()).map(p1_bold)) .push_maybe(tx.time.map(|t| { Container::new( text(format!( diff --git a/gui/src/daemon/model.rs b/gui/src/daemon/model.rs index 10b33cf0..a20a58c2 100644 --- a/gui/src/daemon/model.rs +++ b/gui/src/daemon/model.rs @@ -150,7 +150,7 @@ impl SpendTx { .output .iter() .enumerate() - .filter(|(i, _)| !self.change_indexes.contains(&i)) + .filter(|(i, _)| !self.change_indexes.contains(i)) .count() > 1 } @@ -166,7 +166,7 @@ impl Labelled for SpendTx { items.push(LabelItem::Txid(txid)); for coin in &self.coins { items.push(LabelItem::Address(coin.address.clone())); - items.push(LabelItem::OutPoint(coin.outpoint.clone())); + items.push(LabelItem::OutPoint(coin.outpoint)); } for (vout, output) in self.psbt.unsigned_tx.output.iter().enumerate() { items.push(LabelItem::OutPoint(OutPoint { @@ -253,7 +253,7 @@ impl HistoryTransaction { .output .iter() .enumerate() - .filter(|(i, _)| !self.change_indexes.contains(&i)) + .filter(|(i, _)| !self.change_indexes.contains(i)) .count() > 1 } @@ -269,7 +269,7 @@ impl Labelled for HistoryTransaction { items.push(LabelItem::Txid(txid)); for coin in &self.coins { items.push(LabelItem::Address(coin.address.clone())); - items.push(LabelItem::OutPoint(coin.outpoint.clone())); + items.push(LabelItem::OutPoint(coin.outpoint)); } for (vout, output) in self.tx.output.iter().enumerate() { items.push(LabelItem::OutPoint(OutPoint { From 9edcdd9a4e4170ce48ca656e0e8f36dcbe42ff07 Mon Sep 17 00:00:00 2001 From: edouard Date: Tue, 29 Aug 2023 14:55:49 +0200 Subject: [PATCH 3/6] Add labels to change outputs according to main label --- gui/src/app/state/spend/step.rs | 38 +++++++++++++++++++++++++++++---- gui/src/daemon/model.rs | 2 +- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/gui/src/app/state/spend/step.rs b/gui/src/app/state/spend/step.rs index 63d98ba4..b43f8092 100644 --- a/gui/src/app/state/spend/step.rs +++ b/gui/src/app/state/spend/step.rs @@ -30,6 +30,7 @@ const DUST_OUTPUT_SATS: u64 = 5_000; pub struct TransactionDraft { network: Network, inputs: Vec, + recipients: Vec, generated: Option, batch_label: Option, labels: HashMap, @@ -40,6 +41,7 @@ impl TransactionDraft { Self { network, inputs: Vec::new(), + recipients: Vec::new(), generated: None, batch_label: None, labels: HashMap::new(), @@ -373,6 +375,7 @@ impl Step for DefineSpend { } } } + draft.recipients = self.recipients.clone(); if self.recipients.len() > 1 { draft.batch_label = Some(self.batch_label.value.clone()); } @@ -407,7 +410,7 @@ impl Step for DefineSpend { } } -#[derive(Default)] +#[derive(Default, Clone)] struct Recipient { label: form::Value, address: form::Value, @@ -520,9 +523,36 @@ impl Step for SaveSpend { draft.network, ); tx.labels = draft.labels.clone(); - if let Some(label) = &draft.batch_label { - tx.labels - .insert(tx.psbt.unsigned_tx.txid().to_string(), label.clone()); + + if tx.is_batch() { + if let Some(label) = &draft.batch_label { + tx.labels + .insert(tx.psbt.unsigned_tx.txid().to_string(), label.clone()); + for (i, output) in tx.psbt.unsigned_tx.output.iter().enumerate() { + let address_str = Address::from_script(&output.script_pubkey, tx.network) + .unwrap() + .to_string(); + if tx.change_indexes.contains(&i) && tx.labels.contains_key(&address_str) { + tx.labels + .insert(address_str, format!("Change of {}", label.clone())); + } + } + } + } else if let Some(recipient) = draft.recipients.first() { + if !recipient.label.value.is_empty() { + let label = recipient.label.value.clone(); + tx.labels + .insert(tx.psbt.unsigned_tx.txid().to_string(), label.clone()); + for (i, output) in tx.psbt.unsigned_tx.output.iter().enumerate() { + let address_str = Address::from_script(&output.script_pubkey, tx.network) + .unwrap() + .to_string(); + if tx.change_indexes.contains(&i) && tx.labels.contains_key(&address_str) { + tx.labels + .insert(address_str, format!("Change of {}", label.clone())); + } + } + } } self.spend = Some(psbt::PsbtState::new(self.wallet.clone(), tx, false)); diff --git a/gui/src/daemon/model.rs b/gui/src/daemon/model.rs index a20a58c2..ae5c36e7 100644 --- a/gui/src/daemon/model.rs +++ b/gui/src/daemon/model.rs @@ -28,7 +28,7 @@ pub fn remaining_sequence(coin: &Coin, blockheight: u32, timelock: u16) -> u32 { #[derive(Debug, Clone)] pub struct SpendTx { - network: Network, + pub network: Network, pub coins: Vec, pub labels: HashMap, pub psbt: Psbt, From dbb91464c82bb11be4fab679a673c3695248dd7d Mon Sep 17 00:00:00 2001 From: edouard Date: Wed, 18 Oct 2023 16:14:51 +0200 Subject: [PATCH 4/6] fix update labels for pending_txs --- gui/src/app/state/transactions.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gui/src/app/state/transactions.rs b/gui/src/app/state/transactions.rs index 3eed0a29..4bcb37b3 100644 --- a/gui/src/app/state/transactions.rs +++ b/gui/src/app/state/transactions.rs @@ -104,7 +104,10 @@ impl State for TransactionsPanel { match self.labels_edited.update( daemon, message, - self.txs.iter_mut().map(|tx| tx as &mut dyn Labelled), + self.pending_txs + .iter_mut() + .map(|tx| tx as &mut dyn Labelled) + .chain(self.txs.iter_mut().map(|tx| tx as &mut dyn Labelled)), ) { Ok(cmd) => { return cmd; From aeff735ac320e4c3addb11460869fc0cb0146802 Mon Sep 17 00:00:00 2001 From: edouard Date: Wed, 18 Oct 2023 17:01:01 +0200 Subject: [PATCH 5/6] fix unconfirmed payments layouts with labels --- gui/ui/src/component/event.rs | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/gui/ui/src/component/event.rs b/gui/ui/src/component/event.rs index ffb4bd80..2544996e 100644 --- a/gui/ui/src/component/event.rs +++ b/gui/ui/src/component/event.rs @@ -19,13 +19,11 @@ pub fn unconfirmed_outgoing_event<'a, T: Clone + 'a>( Container::new( button( row!( - row!( - badge::spend(), - Column::new().push_maybe(label).push(badge::unconfirmed()) - ) - .spacing(10) - .align_items(Alignment::Center) - .width(Length::Fill), + row!(badge::spend(), Column::new().push_maybe(label),) + .spacing(10) + .align_items(Alignment::Center) + .width(Length::Fill), + badge::unconfirmed(), row!(text::p1_regular("-"), amount::amount(amount)) .spacing(5) .align_items(Alignment::Center), @@ -81,13 +79,11 @@ pub fn unconfirmed_incoming_event<'a, T: Clone + 'a>( Container::new( button( row!( - row!( - badge::receive(), - Column::new().push_maybe(label).push(badge::unconfirmed()) - ) - .spacing(10) - .align_items(Alignment::Center) - .width(Length::Fill), + row!(badge::receive(), Column::new().push_maybe(label)) + .spacing(10) + .align_items(Alignment::Center) + .width(Length::Fill), + badge::unconfirmed(), row!(text::p1_regular("+"), amount::amount(amount)) .spacing(5) .align_items(Alignment::Center), From 757b53ebab43c05a444ce22c8dd666fb367e6ee0 Mon Sep 17 00:00:00 2001 From: edouard Date: Wed, 18 Oct 2023 17:04:56 +0200 Subject: [PATCH 6/6] cargo update -p liana --- gui/Cargo.lock | 4 ++-- gui/src/daemon/embedded.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gui/Cargo.lock b/gui/Cargo.lock index 90aa09e1..ad984f3c 100644 --- a/gui/Cargo.lock +++ b/gui/Cargo.lock @@ -2112,8 +2112,8 @@ dependencies = [ [[package]] name = "liana" -version = "1.0.0" -source = "git+https://github.com/wizardsardine/liana?branch=master#85d470dd8dd67e6726118fe6dd86f9b4c8d3b0ef" +version = "2.0.0" +source = "git+https://github.com/wizardsardine/liana?branch=master#605a13d4bab662f832b8fcb0d915eb17d0360c1f" dependencies = [ "backtrace", "bip39", diff --git a/gui/src/daemon/embedded.rs b/gui/src/daemon/embedded.rs index af07036d..5103932c 100644 --- a/gui/src/daemon/embedded.rs +++ b/gui/src/daemon/embedded.rs @@ -60,7 +60,7 @@ impl Daemon for EmbeddedDaemon { } fn list_coins(&self) -> Result { - Ok(self.control()?.list_coins()) + Ok(self.control()?.list_coins(&[], &[])) } fn list_spend_txs(&self) -> Result {