Merge #621: Gui labels

757b53ebab43c05a444ce22c8dd666fb367e6ee0 cargo update -p liana (edouard)
aeff735ac320e4c3addb11460869fc0cb0146802 fix unconfirmed payments layouts with labels (edouard)
dbb91464c82bb11be4fab679a673c3695248dd7d fix update labels for pending_txs (edouard)
9edcdd9a4e4170ce48ca656e0e8f36dcbe42ff07 Add labels to change outputs according to main label (edouard)
2354ac9175b5766286852a035c106e44425ffb72 cargo clippy --fix --lib -p liana_gui (edouard)
9db4541952561de8d50bdee35fa7ec737c3e65e8 Add labels support to gui (edouard)

Pull request description:

  This PR use the lianad update_labels and get_labels commands.
  It also introduce new concepts from talks with Kevin and Antoine:
  - User spending is creating a payment, when he does not add multiple recipients anymore, he is doing multiple payment in one bitcoin transaction.
  - a transaction that have multiple outgoing outputs (multiple payment) is tagged as a 'Batch'.

ACKs for top commit:
  edouardparis:
    Self-ACK 757b53ebab43c05a444ce22c8dd666fb367e6ee0

Tree-SHA512: 2208009fcc0ab8f587929a347d191d428156984e87b732fb6efa5f08a3962c85510c82fcdeba687a6d2bd2fa45f5a17384d8a9b709e1407ebbbe0f451fe69e90
This commit is contained in:
edouard 2023-10-19 10:49:14 +02:00
commit 2fbafd9325
No known key found for this signature in database
GPG Key ID: E65F7A089C20DC8F
28 changed files with 1598 additions and 382 deletions

4
gui/Cargo.lock generated
View File

@ -2112,8 +2112,8 @@ dependencies = [
[[package]] [[package]]
name = "liana" name = "liana"
version = "1.0.0" version = "2.0.0"
source = "git+https://github.com/wizardsardine/liana?branch=master#85d470dd8dd67e6726118fe6dd86f9b4c8d3b0ef" source = "git+https://github.com/wizardsardine/liana?branch=master#605a13d4bab662f832b8fcb0d915eb17d0360c1f"
dependencies = [ dependencies = [
"backtrace", "backtrace",
"bip39", "bip39",

View File

@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use liana::{ use liana::{
@ -22,6 +23,7 @@ pub enum Message {
Info(Result<GetInfoResult, Error>), Info(Result<GetInfoResult, Error>),
ReceiveAddress(Result<Address, Error>), ReceiveAddress(Result<Address, Error>),
Coins(Result<Vec<Coin>, Error>), Coins(Result<Vec<Coin>, Error>),
Labels(Result<HashMap<String, String>, Error>),
SpendTxs(Result<Vec<SpendTx>, Error>), SpendTxs(Result<Vec<SpendTx>, Error>),
Psbt(Result<Psbt, Error>), Psbt(Result<Psbt, Error>),
Recovery(Result<SpendTx, Error>), Recovery(Result<SpendTx, Error>),
@ -33,4 +35,5 @@ pub enum Message {
ConnectedHardwareWallets(Vec<HardwareWallet>), ConnectedHardwareWallets(Vec<HardwareWallet>),
HistoryTransactions(Result<Vec<HistoryTransaction>, Error>), HistoryTransactions(Result<Vec<HistoryTransaction>, Error>),
PendingTransactions(Result<Vec<HistoryTransaction>, Error>), PendingTransactions(Result<Vec<HistoryTransaction>, Error>),
LabelsUpdated(Result<HashMap<String, String>, Error>),
} }

View File

@ -97,6 +97,7 @@ impl App {
self.wallet.clone(), self.wallet.clone(),
&self.cache.coins, &self.cache.coins,
self.cache.blockheight as u32, self.cache.blockheight as u32,
self.cache.network,
) )
.into(), .into(),
menu::Menu::RefreshCoins(preselected) => CreateSpendPanel::new_self_send( menu::Menu::RefreshCoins(preselected) => CreateSpendPanel::new_self_send(
@ -104,6 +105,7 @@ impl App {
&self.cache.coins, &self.cache.coins,
self.cache.blockheight as u32, self.cache.blockheight as u32,
preselected, preselected,
self.cache.network,
) )
.into(), .into(),
}; };

View File

@ -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::collections::HashMap;
use std::fs::OpenOptions; use std::fs::OpenOptions;
use std::io::Write; use std::io::Write;
@ -8,8 +10,6 @@ use serde::{Deserialize, Serialize};
use crate::{app::wallet::Wallet, hw::HardwareWalletConfig}; 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"; pub const DEFAULT_FILE_NAME: &str = "settings.json";
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]

View File

@ -1,18 +1,48 @@
use std::cmp::Ordering; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::{cmp::Ordering, collections::HashSet};
use iced::Command; use iced::Command;
use liana_ui::widget::Element; use liana_ui::widget::Element;
use crate::{ use crate::{
app::{cache::Cache, error::Error, menu::Menu, message::Message, state::State, view}, app::{
daemon::{model::Coin, Daemon}, 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<Coin>,
labels: HashMap<String, String>,
}
impl Labelled for Coins {
fn labelled(&self) -> Vec<LabelItem> {
self.list
.iter()
.map(|a| LabelItem::OutPoint(a.outpoint))
.collect()
}
fn labels(&mut self) -> &mut HashMap<String, String> {
&mut self.labels
}
}
pub struct CoinsPanel { pub struct CoinsPanel {
coins: Vec<Coin>, coins: Coins,
selected: Vec<usize>, selected: Vec<usize>,
labels_edited: LabelsEdited,
warning: Option<Error>, warning: Option<Error>,
/// timelock value to pass for the heir to consume a coin. /// timelock value to pass for the heir to consume a coin.
timelock: u16, timelock: u16,
@ -21,7 +51,8 @@ pub struct CoinsPanel {
impl CoinsPanel { impl CoinsPanel {
pub fn new(coins: &[Coin], timelock: u16) -> Self { pub fn new(coins: &[Coin], timelock: u16) -> Self {
let mut panel = Self { let mut panel = Self {
coins: Vec::new(), labels_edited: LabelsEdited::default(),
coins: Coins::default(),
selected: Vec::new(), selected: Vec::new(),
warning: None, warning: None,
timelock, timelock,
@ -31,18 +62,14 @@ impl CoinsPanel {
} }
fn update_coins(&mut self, coins: &[Coin]) { fn update_coins(&mut self, coins: &[Coin]) {
self.coins = coins self.coins.list = coins
.iter() .iter()
.filter_map(|coin| { .filter(|coin| coin.spend_info.is_none())
if coin.spend_info.is_none() { .cloned()
Some(coin.clone())
} else {
None
}
})
.collect(); .collect();
self.coins self.coins
.list
.sort_by(|a, b| match (a.block_height, b.block_height) { .sort_by(|a, b| match (a.block_height, b.block_height) {
(Some(a_height), Some(b_height)) => { (Some(a_height), Some(b_height)) => {
if a_height == b_height { if a_height == b_height {
@ -64,13 +91,20 @@ impl State for CoinsPanel {
&Menu::Coins, &Menu::Coins,
cache, cache,
self.warning.as_ref(), 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( fn update(
&mut self, &mut self,
_daemon: Arc<dyn Daemon + Sync + Send>, daemon: Arc<dyn Daemon + Sync + Send>,
_cache: &Cache, _cache: &Cache,
message: Message, message: Message,
) -> Command<Message> { ) -> Command<Message> {
@ -83,6 +117,24 @@ impl State for CoinsPanel {
self.update_coins(&coins); 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)) => { Message::View(view::Message::Select(i)) => {
if let Some(position) = self.selected.iter().position(|j| *j == i) { if let Some(position) = self.selected.iter().position(|j| *j == i) {
self.selected.remove(position); self.selected.remove(position);
@ -96,16 +148,34 @@ impl State for CoinsPanel {
} }
fn load(&self, daemon: Arc<dyn Daemon + Sync + Send>) -> Command<Message> { fn load(&self, daemon: Arc<dyn Daemon + Sync + Send>) -> Command<Message> {
let daemon = daemon.clone(); let daemon1 = daemon.clone();
let daemon2 = daemon.clone();
Command::batch(vec![
Command::perform( Command::perform(
async move { async move {
daemon daemon1
.list_coins() .list_coins()
.map(|res| res.coins) .map(|res| res.coins)
.map_err(|e| e.into()) .map_err(|e| e.into())
}, },
Message::Coins, Message::Coins,
) ),
Command::perform(
async move {
let coins = daemon2
.list_coins()
.map(|res| res.coins)
.map_err(Error::from)?;
let mut targets = HashSet::<LabelItem>::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 +242,7 @@ mod tests {
assert_eq!( assert_eq!(
panel panel
.coins .coins
.list
.iter() .iter()
.map(|c| c.outpoint) .map(|c| c.outpoint)
.collect::<Vec<bitcoin::OutPoint>>(), .collect::<Vec<bitcoin::OutPoint>>(),

View File

@ -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<String, form::Value<String>>);
impl LabelsEdited {
pub fn cache(&self) -> &HashMap<String, form::Value<String>> {
&self.0
}
pub fn update<'a, T: IntoIterator<Item = &'a mut dyn Labelled>>(
&mut self,
daemon: Arc<dyn Daemon + Sync + Send>,
message: Message,
targets: T,
) -> Result<Command<Message>, 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<LabelItem> {
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
}
}

View File

@ -1,4 +1,5 @@
mod coins; mod coins;
mod label;
mod psbt; mod psbt;
mod psbts; mod psbts;
mod recovery; mod recovery;
@ -6,6 +7,7 @@ mod settings;
mod spend; mod spend;
mod transactions; mod transactions;
use std::collections::HashMap;
use std::convert::TryInto; use std::convert::TryInto;
use std::sync::Arc; use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH}; 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 super::{cache::Cache, error::Error, menu::Menu, message::Message, view, wallet::Wallet};
use crate::daemon::{ use crate::daemon::{
model::{remaining_sequence, Coin, HistoryTransaction}, model::{remaining_sequence, Coin, HistoryTransaction, LabelItem, Labelled},
Daemon, Daemon,
}; };
pub use coins::CoinsPanel; pub use coins::CoinsPanel;
use label::LabelsEdited;
pub use psbts::PsbtsPanel; pub use psbts::PsbtsPanel;
pub use recovery::RecoveryPanel; pub use recovery::RecoveryPanel;
pub use settings::SettingsState; pub use settings::SettingsState;
@ -54,6 +57,7 @@ pub struct Home {
pending_events: Vec<HistoryTransaction>, pending_events: Vec<HistoryTransaction>,
events: Vec<HistoryTransaction>, events: Vec<HistoryTransaction>,
selected_event: Option<(usize, usize)>, selected_event: Option<(usize, usize)>,
labels_edited: LabelsEdited,
warning: Option<Error>, warning: Option<Error>,
} }
@ -80,6 +84,7 @@ impl Home {
selected_event: None, selected_event: None,
events: Vec::new(), events: Vec::new(),
pending_events: Vec::new(), pending_events: Vec::new(),
labels_edited: LabelsEdited::default(),
warning: None, warning: None,
} }
} }
@ -93,7 +98,13 @@ impl State for Home {
} else { } else {
&self.events[i - self.pending_events.len()] &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 { } else {
view::dashboard( view::dashboard(
&Menu::Home, &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) => { Message::View(view::Message::Close) => {
self.selected_event = None; self.selected_event = None;
} }
@ -265,9 +293,28 @@ impl From<Home> for Box<dyn State> {
} }
} }
#[derive(Debug, Default)]
pub struct Addresses {
list: Vec<Address>,
labels: HashMap<String, String>,
}
impl Labelled for Addresses {
fn labelled(&self) -> Vec<LabelItem> {
self.list
.iter()
.map(|a| LabelItem::Address(a.clone()))
.collect()
}
fn labels(&mut self) -> &mut HashMap<String, String> {
&mut self.labels
}
}
#[derive(Default)] #[derive(Default)]
pub struct ReceivePanel { pub struct ReceivePanel {
addresses: Vec<Address>, addresses: Addresses,
labels_edited: LabelsEdited,
qr_code: Option<qr_code::State>, qr_code: Option<qr_code::State>,
warning: Option<Error>, warning: Option<Error>,
} }
@ -278,7 +325,12 @@ impl State for ReceivePanel {
&Menu::Receive, &Menu::Receive,
cache, cache,
self.warning.as_ref(), 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( fn update(
@ -288,12 +340,25 @@ impl State for ReceivePanel {
message: Message, message: Message,
) -> Command<Message> { ) -> Command<Message> {
match message { 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) => { Message::ReceiveAddress(res) => {
match res { match res {
Ok(address) => { Ok(address) => {
self.warning = None; self.warning = None;
self.qr_code = Some(qr_code::State::new(address.to_qr_uri()).unwrap()); 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), Err(e) => self.warning = Some(e),
} }
@ -363,6 +428,6 @@ mod tests {
let sandbox = sandbox.load(client, &Cache::default()).await; let sandbox = sandbox.load(client, &Cache::default()).await;
let panel = sandbox.state(); let panel = sandbox.state();
assert_eq!(panel.addresses, vec![addr]); assert_eq!(panel.addresses.list, vec![addr]);
} }
} }

View File

@ -1,4 +1,4 @@
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use std::sync::Arc; use std::sync::Arc;
use iced::Command; use iced::Command;
@ -17,11 +17,12 @@ use crate::{
cache::Cache, cache::Cache,
error::Error, error::Error,
message::Message, message::Message,
state::label::{label_item_from_str, LabelsEdited},
view, view,
wallet::{Wallet, WalletError}, wallet::{Wallet, WalletError},
}, },
daemon::{ daemon::{
model::{SpendStatus, SpendTx}, model::{LabelItem, Labelled, SpendStatus, SpendTx},
Daemon, Daemon,
}, },
hw::{list_hardware_wallets, HardwareWallet}, hw::{list_hardware_wallets, HardwareWallet},
@ -50,6 +51,8 @@ pub struct PsbtState {
pub desc_policy: LianaPolicy, pub desc_policy: LianaPolicy,
pub tx: SpendTx, pub tx: SpendTx,
pub saved: bool, pub saved: bool,
pub warning: Option<Error>,
pub labels_edited: LabelsEdited,
pub action: Option<Box<dyn Action>>, pub action: Option<Box<dyn Action>>,
} }
@ -58,6 +61,8 @@ impl PsbtState {
Self { Self {
desc_policy: wallet.main_descriptor.policy(), desc_policy: wallet.main_descriptor.policy(),
wallet, wallet,
labels_edited: LabelsEdited::default(),
warning: None,
action: None, action: None,
tx, tx,
saved, saved,
@ -84,7 +89,7 @@ impl PsbtState {
self.action = None; self.action = None;
} }
view::SpendTxMessage::Delete => { view::SpendTxMessage::Delete => {
self.action = Some(Box::new(DeleteAction::default())); self.action = Some(Box::<DeleteAction>::default());
} }
view::SpendTxMessage::Sign => { view::SpendTxMessage::Sign => {
let action = SignAction::new(self.tx.signers(), self.wallet.clone()); let action = SignAction::new(self.tx.signers(), self.wallet.clone());
@ -99,10 +104,10 @@ impl PsbtState {
return cmd; return cmd;
} }
view::SpendTxMessage::Broadcast => { view::SpendTxMessage::Broadcast => {
self.action = Some(Box::new(BroadcastAction::default())); self.action = Some(Box::<BroadcastAction>::default());
} }
view::SpendTxMessage::Save => { view::SpendTxMessage::Save => {
self.action = Some(Box::new(SaveAction::default())); self.action = Some(Box::<SaveAction>::default());
} }
_ => { _ => {
if let Some(action) = self.action.as_mut() { if let Some(action) = self.action.as_mut() {
@ -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(_)) => { Message::Updated(Ok(_)) => {
self.saved = true; self.saved = true;
if let Some(action) = self.action.as_mut() { if let Some(action) = self.action.as_mut() {
@ -132,7 +151,9 @@ impl PsbtState {
self.saved, self.saved,
&self.desc_policy, &self.desc_policy,
&self.wallet.keys_aliases, &self.wallet.keys_aliases,
self.labels_edited.cache(),
cache.network, cache.network,
self.warning.as_ref(),
); );
if let Some(action) = &self.action { if let Some(action) = &self.action {
modal::Modal::new(content, action.view()) modal::Modal::new(content, action.view())
@ -161,8 +182,18 @@ impl Action for SaveAction {
Message::View(view::Message::Spend(view::SpendTxMessage::Confirm)) => { Message::View(view::Message::Spend(view::SpendTxMessage::Confirm)) => {
let daemon = daemon.clone(); let daemon = daemon.clone();
let psbt = tx.psbt.clone(); let psbt = tx.psbt.clone();
let mut labels = HashMap::<LabelItem, String>::new();
for (item, label) in tx.labels() {
labels.insert(
label_item_from_str(item).expect("Must be a LabelItem"),
label.clone(),
);
}
return Command::perform( 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, Message::Updated,
); );
} }

View File

@ -140,22 +140,29 @@ impl State for RecoveryPanel {
.recovery_paths .recovery_paths
.get(self.selected_path.expect("A path must be selected")) .get(self.selected_path.expect("A path must be selected"))
.map(|p| p.sequence); .map(|p| p.sequence);
let network = cache.network;
return Command::perform( return Command::perform(
async move { async move {
let psbt = daemon.create_recovery(address, feerate_vb, sequence)?; let psbt = daemon.create_recovery(address, feerate_vb, sequence)?;
let coins = daemon.list_coins().map(|res| res.coins)?; let coins = daemon.list_coins().map(|res| res.coins)?;
let coins = coins let coins = coins
.iter() .into_iter()
.filter(|coin| { .filter(|coin| {
psbt.unsigned_tx psbt.unsigned_tx
.input .input
.iter() .iter()
.any(|input| input.previous_output == coin.outpoint) .any(|input| input.previous_output == coin.outpoint)
}) })
.cloned()
.collect(); .collect();
let sigs = desc.partial_spend_info(&psbt).unwrap(); 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, Message::Recovery,
); );

View File

@ -1,15 +1,20 @@
mod step; mod step;
use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use iced::Command; use iced::Command;
use liana::miniscript::bitcoin::OutPoint; use liana::miniscript::bitcoin::{Network, OutPoint};
use liana_ui::widget::Element; use liana_ui::widget::Element;
use super::{redirect, State}; use super::{redirect, State};
use crate::{ use crate::{
app::{cache::Cache, menu::Menu, message::Message, view, wallet::Wallet}, app::{cache::Cache, error::Error, menu::Menu, message::Message, view, wallet::Wallet},
daemon::{model::Coin, Daemon}, daemon::{
model::{Coin, LabelItem},
Daemon,
},
}; };
pub struct CreateSpendPanel { pub struct CreateSpendPanel {
@ -19,11 +24,11 @@ pub struct CreateSpendPanel {
} }
impl CreateSpendPanel { impl CreateSpendPanel {
pub fn new(wallet: Arc<Wallet>, coins: &[Coin], blockheight: u32) -> Self { pub fn new(wallet: Arc<Wallet>, coins: &[Coin], blockheight: u32, network: Network) -> Self {
let descriptor = wallet.main_descriptor.clone(); let descriptor = wallet.main_descriptor.clone();
let timelock = descriptor.first_timelock_value(); let timelock = descriptor.first_timelock_value();
Self { Self {
draft: step::TransactionDraft::default(), draft: step::TransactionDraft::new(network),
current: 0, current: 0,
steps: vec![ steps: vec![
Box::new( Box::new(
@ -40,11 +45,12 @@ impl CreateSpendPanel {
coins: &[Coin], coins: &[Coin],
blockheight: u32, blockheight: u32,
preselected_coins: &[OutPoint], preselected_coins: &[OutPoint],
network: Network,
) -> Self { ) -> Self {
let descriptor = wallet.main_descriptor.clone(); let descriptor = wallet.main_descriptor.clone();
let timelock = descriptor.first_timelock_value(); let timelock = descriptor.first_timelock_value();
Self { Self {
draft: step::TransactionDraft::default(), draft: step::TransactionDraft::new(network),
current: 0, current: 0,
steps: vec![ steps: vec![
Box::new( Box::new(
@ -99,16 +105,33 @@ impl State for CreateSpendPanel {
} }
fn load(&self, daemon: Arc<dyn Daemon + Sync + Send>) -> Command<Message> { fn load(&self, daemon: Arc<dyn Daemon + Sync + Send>) -> Command<Message> {
let daemon = daemon.clone(); let daemon1 = daemon.clone();
let daemon2 = daemon.clone();
Command::batch(vec![
Command::perform( Command::perform(
async move { async move {
daemon daemon1
.list_coins() .list_coins()
.map(|res| res.coins) .map(|res| res.coins)
.map_err(|e| e.into()) .map_err(|e| e.into())
}, },
Message::Coins, Message::Coins,
) ),
Command::perform(
async move {
let coins = daemon
.list_coins()
.map(|res| res.coins)
.map_err(Error::from)?;
let mut targets = HashSet::<LabelItem>::new();
for coin in coins {
targets.insert(LabelItem::OutPoint(coin.outpoint));
}
daemon2.get_labels(&targets).map_err(|e| e.into())
},
Message::Labels,
),
])
} }
} }

View File

@ -26,10 +26,27 @@ use crate::{
/// See: https://github.com/wizardsardine/liana/blob/master/src/commands/mod.rs#L32 /// See: https://github.com/wizardsardine/liana/blob/master/src/commands/mod.rs#L32
const DUST_OUTPUT_SATS: u64 = 5_000; const DUST_OUTPUT_SATS: u64 = 5_000;
#[derive(Default, Clone)] #[derive(Clone)]
pub struct TransactionDraft { pub struct TransactionDraft {
network: Network,
inputs: Vec<Coin>, inputs: Vec<Coin>,
recipients: Vec<Recipient>,
generated: Option<Psbt>, generated: Option<Psbt>,
batch_label: Option<String>,
labels: HashMap<String, String>,
}
impl TransactionDraft {
pub fn new(network: Network) -> Self {
Self {
network,
inputs: Vec::new(),
recipients: Vec::new(),
generated: None,
batch_label: None,
labels: HashMap::new(),
}
}
} }
pub trait Step { pub trait Step {
@ -53,6 +70,8 @@ pub struct DefineSpend {
descriptor: LianaDescriptor, descriptor: LianaDescriptor,
timelock: u16, timelock: u16,
coins: Vec<(Coin, bool)>, coins: Vec<(Coin, bool)>,
coins_labels: HashMap<String, String>,
batch_label: form::Value<String>,
amount_left_to_select: Option<Amount>, amount_left_to_select: Option<Amount>,
feerate: form::Value<String>, feerate: form::Value<String>,
generated: Option<Psbt>, generated: Option<Psbt>,
@ -88,6 +107,8 @@ impl DefineSpend {
timelock, timelock,
generated: None, generated: None,
coins, coins,
coins_labels: HashMap::new(),
batch_label: form::Value::default(),
recipients: vec![Recipient::default()], recipients: vec![Recipient::default()],
is_valid: false, is_valid: false,
is_duplicate: false, is_duplicate: false,
@ -128,7 +149,9 @@ impl DefineSpend {
} }
fn check_valid(&mut self) { 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; self.is_duplicate = false;
if !self.coins.iter().any(|(_, selected)| *selected) { if !self.coins.iter().any(|(_, selected)| *selected) {
self.is_valid = false; self.is_valid = false;
@ -216,13 +239,22 @@ impl Step for DefineSpend {
cache: &Cache, cache: &Cache,
message: Message, message: Message,
) -> Command<Message> { ) -> Command<Message> {
if let Message::View(view::Message::CreateSpend(msg)) = message { match message {
Message::View(view::Message::CreateSpend(msg)) => {
match msg { match msg {
view::CreateSpendMessage::BatchLabelEdited(label) => {
self.batch_label.valid = label.len() <= 100;
self.batch_label.value = label;
}
view::CreateSpendMessage::AddRecipient => { view::CreateSpendMessage::AddRecipient => {
self.recipients.push(Recipient::default()); self.recipients.push(Recipient::default());
} }
view::CreateSpendMessage::DeleteRecipient(i) => { view::CreateSpendMessage::DeleteRecipient(i) => {
self.recipients.remove(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, _, _) => { view::CreateSpendMessage::RecipientEdited(i, _, _) => {
self.recipients self.recipients
@ -251,14 +283,21 @@ impl Step for DefineSpend {
.coins .coins
.iter() .iter()
.filter_map( .filter_map(
|(coin, selected)| if *selected { Some(coin.outpoint) } else { None }, |(coin, selected)| {
if *selected {
Some(coin.outpoint)
} else {
None
}
},
) )
.collect(); .collect();
let mut outputs: HashMap<Address<address::NetworkUnchecked>, u64> = let mut outputs: HashMap<Address<address::NetworkUnchecked>, u64> =
HashMap::new(); HashMap::new();
for recipient in &self.recipients { for recipient in &self.recipients {
outputs.insert( outputs.insert(
Address::from_str(&recipient.address.value).expect("Checked before"), Address::from_str(&recipient.address.value)
.expect("Checked before"),
recipient.amount().expect("Checked before"), recipient.amount().expect("Checked before"),
); );
} }
@ -283,27 +322,63 @@ impl Step for DefineSpend {
_ => {} _ => {}
} }
self.check_valid(); self.check_valid();
Command::none() }
} else { Message::Psbt(res) => match res {
if let Message::Psbt(res) = message {
match res {
Ok(psbt) => { Ok(psbt) => {
self.generated = Some(psbt); self.generated = Some(psbt);
return Command::perform(async {}, |_| Message::View(view::Message::Next)); return Command::perform(async {}, |_| Message::View(view::Message::Next));
} }
Err(e) => self.warning = Some(e), 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() Command::none()
} }
}
fn apply(&self, draft: &mut TransactionDraft) { fn apply(&self, draft: &mut TransactionDraft) {
draft.inputs = self draft.inputs = self
.coins .coins
.iter() .iter()
.filter_map(|(coin, selected)| if *selected { Some(coin.clone()) } else { None }) .filter_map(|(coin, selected)| if *selected { Some(coin) } else { None })
.cloned()
.collect(); .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,
);
}
}
}
draft.recipients = self.recipients.clone();
if self.recipients.len() > 1 {
draft.batch_label = Some(self.batch_label.value.clone());
}
draft.generated = self.generated.clone(); draft.generated = self.generated.clone();
} }
@ -326,6 +401,8 @@ impl Step for DefineSpend {
self.is_duplicate, self.is_duplicate,
self.timelock, self.timelock,
&self.coins, &self.coins,
&self.coins_labels,
&self.batch_label,
self.amount_left_to_select.as_ref(), self.amount_left_to_select.as_ref(),
&self.feerate, &self.feerate,
self.warning.as_ref(), self.warning.as_ref(),
@ -333,8 +410,9 @@ impl Step for DefineSpend {
} }
} }
#[derive(Default)] #[derive(Default, Clone)]
struct Recipient { struct Recipient {
label: form::Value<String>,
address: form::Value<String>, address: form::Value<String>,
amount: form::Value<String>, amount: form::Value<String>,
} }
@ -372,6 +450,7 @@ impl Recipient {
&& self.address.valid && self.address.valid
&& !self.amount.value.is_empty() && !self.amount.value.is_empty()
&& self.amount.valid && self.amount.valid
&& self.label.valid
} }
fn update(&mut self, network: Network, message: view::CreateSpendMessage) { fn update(&mut self, network: Network, message: view::CreateSpendMessage) {
@ -399,12 +478,16 @@ impl Recipient {
self.amount.valid = true; 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::CreateSpendMessage> { fn view(&self, i: usize) -> Element<view::CreateSpendMessage> {
view::spend::recipient_view(i, &self.address, &self.amount) view::spend::recipient_view(i, &self.address, &self.amount, &self.label)
} }
} }
@ -430,17 +513,49 @@ impl Step for SaveSpend {
.main_descriptor .main_descriptor
.partial_spend_info(&psbt) .partial_spend_info(&psbt)
.unwrap(); .unwrap();
self.spend = Some(psbt::PsbtState::new(
self.wallet.clone(), let mut tx = SpendTx::new(
SpendTx::new(
None, None,
psbt, psbt,
draft.inputs.clone(), draft.inputs.clone(),
sigs, sigs,
self.wallet.main_descriptor.max_sat_vbytes(), self.wallet.main_descriptor.max_sat_vbytes(),
), draft.network,
false, );
)); tx.labels = draft.labels.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));
} }
fn update( fn update(
@ -464,7 +579,9 @@ impl Step for SaveSpend {
spend.saved, spend.saved,
&spend.desc_policy, &spend.desc_policy,
&spend.wallet.keys_aliases, &spend.wallet.keys_aliases,
spend.labels_edited.cache(),
cache.network, cache.network,
spend.warning.as_ref(),
); );
if let Some(action) = &spend.action { if let Some(action) = &spend.action {
modal::Modal::new(content, action.view()) modal::Modal::new(content, action.view())

View File

@ -1,18 +1,30 @@
use std::convert::TryInto; use std::{
use std::sync::Arc; convert::TryInto,
use std::time::{SystemTime, UNIX_EPOCH}; sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use iced::Command; use iced::Command;
use liana_ui::widget::*; 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)] #[derive(Default)]
pub struct TransactionsPanel { pub struct TransactionsPanel {
pending_txs: Vec<HistoryTransaction>, pending_txs: Vec<HistoryTransaction>,
txs: Vec<HistoryTransaction>, txs: Vec<HistoryTransaction>,
labels_edited: LabelsEdited,
selected_tx: Option<usize>, selected_tx: Option<usize>,
warning: Option<Error>, warning: Option<Error>,
} }
@ -23,6 +35,7 @@ impl TransactionsPanel {
selected_tx: None, selected_tx: None,
txs: Vec::new(), txs: Vec::new(),
pending_txs: Vec::new(), pending_txs: Vec::new(),
labels_edited: LabelsEdited::default(),
warning: None, warning: None,
} }
} }
@ -36,7 +49,12 @@ impl State for TransactionsPanel {
} else { } else {
&self.txs[i - self.pending_txs.len()] &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 { } else {
view::transactions::transactions_view( view::transactions::transactions_view(
cache, cache,
@ -82,6 +100,23 @@ impl State for TransactionsPanel {
Message::View(view::Message::Select(i)) => { Message::View(view::Message::Select(i)) => {
self.selected_tx = Some(i); self.selected_tx = Some(i);
} }
Message::View(view::Message::Label(_, _)) | Message::LabelsUpdated(_) => {
match self.labels_edited.update(
daemon,
message,
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;
}
Err(e) => {
self.warning = Some(e);
}
};
}
Message::View(view::Message::Next) => { Message::View(view::Message::Next) => {
if let Some(last) = self.txs.last() { if let Some(last) = self.txs.last() {
let daemon = daemon.clone(); let daemon = daemon.clone();

View File

@ -1,15 +1,21 @@
use std::collections::HashMap;
use iced::{widget::Space, Alignment, Length}; use iced::{widget::Space, Alignment, Length};
use liana_ui::{ use liana_ui::{
color, color,
component::{amount::*, badge, button, text::*}, component::{amount::*, badge, button, form, text::*},
icon, theme, icon, theme,
util::Collection, util::Collection,
widget::*, widget::*,
}; };
use crate::{ use crate::{
app::{cache::Cache, menu::Menu, view::message::Message}, app::{
cache::Cache,
menu::Menu,
view::{label, message::Message},
},
daemon::model::{remaining_sequence, Coin}, daemon::model::{remaining_sequence, Coin},
}; };
@ -18,6 +24,8 @@ pub fn coins_view<'a>(
coins: &'a [Coin], coins: &'a [Coin],
timelock: u16, timelock: u16,
selected: &[usize], selected: &[usize],
labels: &'a HashMap<String, String>,
labels_editing: &'a HashMap<String, form::Value<String>>,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
Column::new() Column::new()
.push(Container::new(h3("Coins")).width(Length::Fill)) .push(Container::new(h3("Coins")).width(Length::Fill))
@ -33,6 +41,8 @@ pub fn coins_view<'a>(
cache.blockheight as u32, cache.blockheight as u32,
i, i,
selected.contains(&i), selected.contains(&i),
labels,
labels_editing,
)) ))
}, },
)), )),
@ -43,13 +53,17 @@ pub fn coins_view<'a>(
} }
#[allow(clippy::collapsible_else_if)] #[allow(clippy::collapsible_else_if)]
fn coin_list_view( fn coin_list_view<'a>(
coin: &Coin, coin: &'a Coin,
timelock: u16, timelock: u16,
blockheight: u32, blockheight: u32,
index: usize, index: usize,
collapsed: bool, collapsed: bool,
) -> Container<Message> { labels: &'a HashMap<String, String>,
labels_editing: &'a HashMap<String, form::Value<String>>,
) -> Container<'a, Message> {
let outpoint = coin.outpoint.to_string();
let address = coin.address.to_string();
Container::new( Container::new(
Column::new() Column::new()
.push( .push(
@ -58,6 +72,17 @@ fn coin_list_view(
.push( .push(
Row::new() Row::new()
.push(badge::coin()) .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() { .push(if coin.spend_info.is_some() {
badge::spent() badge::spent()
} else if coin.block_height.is_none() { } else if coin.block_height.is_none() {
@ -83,6 +108,18 @@ fn coin_list_view(
Column::new() Column::new()
.padding(10) .padding(10)
.spacing(5) .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() { .push_maybe(if coin.spend_info.is_none() {
if let Some(b) = coin.block_height { if let Some(b) = coin.block_height {
if blockheight > b as u32 + timelock as u32 { if blockheight > b as u32 + timelock as u32 {
@ -104,6 +141,42 @@ fn coin_list_view(
}) })
.push( .push(
Column::new() 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( .push(
Row::new() Row::new()
.align_items(Alignment::Center) .align_items(Alignment::Center)
@ -186,7 +259,7 @@ pub fn coin_sequence_label<'a, T: 'a>(seq: u32, timelock: u32) -> Container<'a,
) )
.padding(10) .padding(10)
.style(theme::Container::Pill(theme::Pill::Warning)) .style(theme::Container::Pill(theme::Pill::Warning))
} else if seq < timelock as u32 * 10 / 100 { } else if seq < timelock * 10 / 100 {
Container::new( Container::new(
Row::new() Row::new()
.spacing(5) .spacing(5)

View File

@ -1,11 +1,12 @@
use chrono::NaiveDateTime; 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::miniscript::bitcoin;
use liana_ui::{ use liana_ui::{
color, color,
component::{amount::*, button, card, event, text::*}, component::{amount::*, button, card, event, form, text::*},
icon, theme, icon, theme,
util::Collection, util::Collection,
widget::*, widget::*,
@ -16,7 +17,7 @@ use crate::{
cache::Cache, cache::Cache,
error::Error, error::Error,
menu::Menu, menu::Menu,
view::{coins, dashboard, message::Message}, view::{coins, dashboard, label, message::Message},
}, },
daemon::model::HistoryTransaction, daemon::model::HistoryTransaction,
}; };
@ -28,8 +29,8 @@ pub fn home_view<'a>(
unconfirmed_balance: &'a bitcoin::Amount, unconfirmed_balance: &'a bitcoin::Amount,
remaining_sequence: &Option<u32>, remaining_sequence: &Option<u32>,
expiring_coins: &Vec<bitcoin::OutPoint>, expiring_coins: &Vec<bitcoin::OutPoint>,
pending_events: &[HistoryTransaction], pending_events: &'a [HistoryTransaction],
events: &Vec<HistoryTransaction>, events: &'a Vec<HistoryTransaction>,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
Column::new() Column::new()
.push(h3("Balance")) .push(h3("Balance"))
@ -145,21 +146,41 @@ pub fn home_view<'a>(
.into() .into()
} }
fn event_list_view<'a>(i: usize, event: &HistoryTransaction) -> Column<'a, Message> { fn event_list_view(i: usize, event: &HistoryTransaction) -> Column<'_, Message> {
event.tx.output.iter().enumerate().fold( event.tx.output.iter().enumerate().fold(
Column::new().spacing(10), Column::new().spacing(10),
|col, (output_index, output)| { |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 {
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.is_external() {
if !event.change_indexes.contains(&output_index) { if !event.change_indexes.contains(&output_index) {
col col
} else if let Some(t) = event.time { } else if let Some(t) = event.time {
col.push(event::confirmed_incoming_event( col.push(event::confirmed_incoming_event(
label,
NaiveDateTime::from_timestamp_opt(t as i64, 0).unwrap(), NaiveDateTime::from_timestamp_opt(t as i64, 0).unwrap(),
&Amount::from_sat(output.value), &Amount::from_sat(output.value),
Message::SelectSub(i, output_index), Message::SelectSub(i, output_index),
)) ))
} else { } else {
col.push(event::unconfirmed_incoming_event( col.push(event::unconfirmed_incoming_event(
label,
&Amount::from_sat(output.value), &Amount::from_sat(output.value),
Message::SelectSub(i, output_index), Message::SelectSub(i, output_index),
)) ))
@ -168,12 +189,14 @@ fn event_list_view<'a>(i: usize, event: &HistoryTransaction) -> Column<'a, Messa
col col
} else if let Some(t) = event.time { } else if let Some(t) = event.time {
col.push(event::confirmed_outgoing_event( col.push(event::confirmed_outgoing_event(
label,
NaiveDateTime::from_timestamp_opt(t as i64, 0).unwrap(), NaiveDateTime::from_timestamp_opt(t as i64, 0).unwrap(),
&Amount::from_sat(output.value), &Amount::from_sat(output.value),
Message::SelectSub(i, output_index), Message::SelectSub(i, output_index),
)) ))
} else { } else {
col.push(event::unconfirmed_outgoing_event( col.push(event::unconfirmed_outgoing_event(
label,
&Amount::from_sat(output.value), &Amount::from_sat(output.value),
Message::SelectSub(i, output_index), Message::SelectSub(i, output_index),
)) ))
@ -186,8 +209,15 @@ pub fn payment_view<'a>(
cache: &'a Cache, cache: &'a Cache,
tx: &'a HistoryTransaction, tx: &'a HistoryTransaction,
output_index: usize, output_index: usize,
labels_editing: &'a HashMap<String, form::Value<String>>,
warning: Option<&'a Error>, warning: Option<&'a Error>,
) -> Element<'a, Message> { ) -> 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( dashboard(
&Menu::Home, &Menu::Home,
cache, cache,
@ -200,11 +230,22 @@ pub fn payment_view<'a>(
} else { } else {
Container::new(h3("Outgoing payment")).width(Length::Fill) 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( .push(Container::new(amount_with_size(
&Amount::from_sat(tx.tx.output[output_index].value), &Amount::from_sat(tx.tx.output[output_index].value),
H1_SIZE, H1_SIZE,
))) )))
.push(Space::with_height(H3_SIZE))
.push(Container::new(h3("Transaction")).width(Length::Fill)) .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| { .push_maybe(tx.fee_amount.map(|fee_amount| {
Row::new() Row::new()
.align_items(Alignment::Center) .align_items(Alignment::Center)
@ -259,11 +300,8 @@ pub fn payment_view<'a>(
} else { } else {
Some(tx.change_indexes.clone()) Some(tx.change_indexes.clone())
}, },
if tx.is_external() { &tx.labels,
Some(tx.change_indexes.clone()) labels_editing,
} else {
None
},
)) ))
.spacing(20), .spacing(20),
) )

71
gui/src/app/view/label.rs Normal file
View File

@ -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(
labelled: String,
label: Option<&String>,
size: u16,
) -> Element<'_, 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<String>,
size: u16,
) -> Element<view::Message> {
let e: Element<view::LabelMessage> = 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))
}

View File

@ -9,6 +9,7 @@ pub enum Message {
Close, Close,
Select(usize), Select(usize),
SelectSub(usize, usize), SelectSub(usize, usize),
Label(String, LabelMessage),
Settings(SettingsMessage), Settings(SettingsMessage),
CreateSpend(CreateSpendMessage), CreateSpend(CreateSpendMessage),
ImportSpend(ImportSpendMessage), ImportSpend(ImportSpendMessage),
@ -18,9 +19,17 @@ pub enum Message {
SelectHardwareWallet(usize), SelectHardwareWallet(usize),
} }
#[derive(Debug, Clone)]
pub enum LabelMessage {
Edited(String),
Cancel,
Confirm,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum CreateSpendMessage { pub enum CreateSpendMessage {
AddRecipient, AddRecipient,
BatchLabelEdited(String),
DeleteRecipient(usize), DeleteRecipient(usize),
SelectCoin(usize), SelectCoin(usize),
RecipientEdited(usize, &'static str, String), RecipientEdited(usize, &'static str, String),

View File

@ -1,3 +1,4 @@
mod label;
mod message; mod message;
mod warning; mod warning;

View File

@ -9,14 +9,19 @@ use liana::{
descriptors::{LianaPolicy, PathInfo, PathSpendInfo}, descriptors::{LianaPolicy, PathInfo, PathSpendInfo},
miniscript::bitcoin::{ miniscript::bitcoin::{
bip32::{DerivationPath, Fingerprint}, bip32::{DerivationPath, Fingerprint},
Address, Amount, Network, Transaction, blockdata::transaction::TxOut,
Address, Amount, Network, OutPoint, Transaction, Txid,
}, },
}; };
use liana_ui::{ use liana_ui::{
color, color,
component::{ component::{
amount::*, badge, button, card, collapse::Collapse, form, hw, separation, text::*, amount::*,
badge, button, card,
collapse::Collapse,
form, hw, separation,
text::{self, *},
}, },
icon, theme, icon, theme,
util::Collection, util::Collection,
@ -28,24 +33,27 @@ use crate::{
cache::Cache, cache::Cache,
error::Error, error::Error,
menu::Menu, 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}, daemon::model::{Coin, SpendStatus, SpendTx},
hw::HardwareWallet, hw::HardwareWallet,
}; };
#[allow(clippy::too_many_arguments)]
pub fn psbt_view<'a>( pub fn psbt_view<'a>(
cache: &'a Cache, cache: &'a Cache,
tx: &'a SpendTx, tx: &'a SpendTx,
saved: bool, saved: bool,
desc_info: &'a LianaPolicy, desc_info: &'a LianaPolicy,
key_aliases: &'a HashMap<Fingerprint, String>, key_aliases: &'a HashMap<Fingerprint, String>,
labels_editing: &'a HashMap<String, form::Value<String>>,
network: Network, network: Network,
warning: Option<&Error>,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
dashboard( dashboard(
&Menu::PSBTs, &Menu::PSBTs,
cache, cache,
None, warning,
Column::new() Column::new()
.spacing(20) .spacing(20)
.push( .push(
@ -65,14 +73,15 @@ pub fn psbt_view<'a>(
_ => None, _ => None,
}), }),
) )
.push(spend_header(tx)) .push(spend_header(tx, labels_editing))
.push(spend_overview_view(tx, desc_info, key_aliases)) .push(spend_overview_view(tx, desc_info, key_aliases))
.push(inputs_and_outputs_view( .push(inputs_and_outputs_view(
&tx.coins, &tx.coins,
&tx.psbt.unsigned_tx, &tx.psbt.unsigned_tx,
network, network,
Some(tx.change_indexes.clone()), Some(tx.change_indexes.clone()),
None, &tx.labels,
labels_editing,
)) ))
.push(if saved { .push(if saved {
Row::new() Row::new()
@ -182,9 +191,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<String, form::Value<String>>,
) -> Element<'a, Message> {
let txid = tx.psbt.unsigned_tx.txid().to_string();
Column::new() Column::new()
.spacing(20) .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( .push(
Column::new() Column::new()
.push(if tx.is_self_send() { .push(if tx.is_self_send() {
@ -504,8 +522,10 @@ pub fn inputs_and_outputs_view<'a>(
tx: &'a Transaction, tx: &'a Transaction,
network: Network, network: Network,
change_indexes: Option<Vec<usize>>, change_indexes: Option<Vec<usize>>,
receive_indexes: Option<Vec<usize>>, labels: &'a HashMap<String, String>,
labels_editing: &'a HashMap<String, form::Value<String>>,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let change_indexes_copy = change_indexes.clone();
Column::new() Column::new()
.spacing(20) .spacing(20)
.push_maybe(if !coins.is_empty() { .push_maybe(if !coins.is_empty() {
@ -551,29 +571,9 @@ pub fn inputs_and_outputs_view<'a>(
coins coins
.iter() .iter()
.fold( .fold(
Column::new().padding(20), Column::new().spacing(10).padding(20),
|col: Column<'a, Message>, coin| { |col: Column<'a, Message>, coin| {
col.push( col.push(input_view(coin, labels, labels_editing))
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)),
)
}, },
) )
.into() .into()
@ -584,7 +584,20 @@ pub fn inputs_and_outputs_view<'a>(
} else { } else {
None None
}) })
.push( .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( Container::new(Collapse::new(
move || { move || {
Button::new( Button::new(
@ -592,9 +605,9 @@ pub fn inputs_and_outputs_view<'a>(
.align_items(Alignment::Center) .align_items(Alignment::Center)
.push( .push(
h4_bold(format!( h4_bold(format!(
"{} recipient{}", "{} payment{}",
tx.output.len(), count,
if tx.output.len() == 1 { "" } else { "s" } if count == 1 { "" } else { "s" }
)) ))
.width(Length::Fill), .width(Length::Fill),
) )
@ -610,9 +623,9 @@ pub fn inputs_and_outputs_view<'a>(
.align_items(Alignment::Center) .align_items(Alignment::Center)
.push( .push(
h4_bold(format!( h4_bold(format!(
"{} recipient{}", "{} payment{}",
tx.output.len(), count,
if tx.output.len() == 1 { "" } else { "s" } if count == 1 { "" } else { "s" }
)) ))
.width(Length::Fill), .width(Length::Fill),
) )
@ -626,59 +639,83 @@ pub fn inputs_and_outputs_view<'a>(
tx.output tx.output
.iter() .iter()
.enumerate() .enumerate()
.filter(|(i, _)| {
if let Some(indexes) = change_indexes_copy.as_ref() {
!indexes.contains(i)
} else {
true
}
})
.fold( .fold(
Column::new().padding(20), Column::new().padding(20),
|col: Column<'a, Message>, (i, output)| { |col: Column<'a, Message>, (i, output)| {
let addr = col.spacing(10).push(payment_view(
Address::from_script(&output.script_pubkey, network).unwrap(); i,
col.push( tx.txid(),
Column::new() output,
.width(Length::Fill) network,
.spacing(5) labels,
.push( labels_editing,
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))), .into()
) },
.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),
)) ))
.style(theme::Container::Card(theme::Card::Simple))
} else { } else {
None Container::new(h4_bold("0 payment"))
.padding(20)
.width(Length::Fill)
.style(theme::Container::Card(theme::Card::Simple))
} }
} else {
None
}) })
.push_maybe(if let Some(indexes) = receive_indexes.as_ref() { .push_maybe(
if indexes.contains(&i) { if change_indexes
Some(Container::new(text("Deposit")).padding(5).style( .as_ref()
theme::Container::Pill(theme::Pill::Success), .map(|indexes| !indexes.is_empty())
)) .unwrap_or(false)
} else { {
None Some(
} Container::new(Collapse::new(
} else { move || {
None 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() .into()
@ -686,6 +723,235 @@ pub fn inputs_and_outputs_view<'a>(
)) ))
.style(theme::Container::Card(theme::Card::Simple)), .style(theme::Container::Card(theme::Card::Simple)),
) )
} else {
None
},
)
.into()
}
fn input_view<'a>(
coin: &'a Coin,
labels: &'a HashMap<String, String>,
labels_editing: &'a HashMap<String, form::Value<String>>,
) -> 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<String, String>,
labels_editing: &'a HashMap<String, form::Value<String>>,
) -> 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<String, String>,
labels_editing: &'a HashMap<String, form::Value<String>>,
) -> 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() .into()
} }

View File

@ -59,7 +59,7 @@ pub fn import_psbt_success_view<'a>() -> Element<'a, Message> {
.into() .into()
} }
pub fn psbts_view<'a>(spend_txs: &[SpendTx]) -> Element<'a, Message> { pub fn psbts_view(spend_txs: &[SpendTx]) -> Element<'_, Message> {
Column::new() Column::new()
.push( .push(
Row::new() Row::new()
@ -90,7 +90,7 @@ pub fn psbts_view<'a>(spend_txs: &[SpendTx]) -> Element<'a, Message> {
.into() .into()
} }
fn spend_tx_list_view<'a>(i: usize, tx: &SpendTx) -> Element<'a, Message> { fn spend_tx_list_view(i: usize, tx: &SpendTx) -> Element<'_, Message> {
Container::new( Container::new(
Button::new( Button::new(
Row::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(icon::key_icon().style(color::GREY_3)),
) )
}) })
.push_maybe(
tx.labels
.get(&tx.psbt.unsigned_tx.txid().to_string())
.map(p1_bold),
)
.spacing(10) .spacing(10)
.align_items(Alignment::Center) .align_items(Alignment::Center)
.width(Length::Fill), .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()), SpendStatus::Spent => Some(badge::spent()),
_ => None, _ => None,
}) })
.push_maybe(if tx.is_batch() {
Some(badge::batch())
} else {
None
})
.push( .push(
Column::new() Column::new()
.align_items(Alignment::End) .align_items(Alignment::End)

View File

@ -1,3 +1,5 @@
use std::collections::HashMap;
use iced::{ use iced::{
widget::{ widget::{
qr_code::{self, QRCode}, qr_code::{self, QRCode},
@ -10,16 +12,23 @@ use liana::miniscript::bitcoin;
use liana_ui::{ use liana_ui::{
color, color,
component::{button, card, text::*}, component::{
button, card, form,
text::{self, *},
},
icon, theme, icon, theme,
widget::*, widget::*,
}; };
use crate::app::view::label;
use super::message::Message; use super::message::Message;
pub fn receive<'a>( pub fn receive<'a>(
addresses: &'a [bitcoin::Address], addresses: &'a [bitcoin::Address],
qr: Option<&'a qr_code::State>, qr: Option<&'a qr_code::State>,
labels: &'a HashMap<String, String>,
labels_editing: &'a HashMap<String, form::Value<String>>,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
Column::new() Column::new()
.push( .push(
@ -38,34 +47,54 @@ pub fn receive<'a>(
.push(addresses.iter().rev().fold( .push(addresses.iter().rev().fold(
Column::new().spacing(10).width(Length::Fill), Column::new().spacing(10).width(Length::Fill),
|col, address| { |col, address| {
let addr = address.to_string();
col.push( col.push(
card::simple( card::simple(
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,
)
})
.push(
Row::new() Row::new()
.push( .push(
Container::new( Container::new(
scrollable( scrollable(
Column::new() Column::new()
.push(Space::with_height(Length::Fixed(10.0))) .push(Space::with_height(
Length::Fixed(10.0),
))
.push( .push(
p2_regular(address.to_string()) p2_regular(addr)
.small() .small()
.style(color::GREY_3), .style(color::GREY_3),
) )
// Space between the address and the scrollbar // Space between the address and the scrollbar
.push(Space::with_height(Length::Fixed(10.0))), .push(Space::with_height(
Length::Fixed(10.0),
)),
) )
.horizontal_scroll( .horizontal_scroll(
scrollable::Properties::new().scroller_width(5), scrollable::Properties::new()
.scroller_width(5),
), ),
) )
.width(Length::Fill), .width(Length::Fill),
) )
.push( .push(
Button::new(icon::clipboard_icon().style(color::GREY_3)) Button::new(
icon::clipboard_icon().style(color::GREY_3),
)
.on_press(Message::Clipboard(address.to_string())) .on_press(Message::Clipboard(address.to_string()))
.style(theme::Button::TransparentBorder), .style(theme::Button::TransparentBorder),
) )
.align_items(Alignment::Center), .align_items(Alignment::Center),
),
) )
.padding(20), .padding(20),
) )

View File

@ -29,29 +29,33 @@ use crate::{
daemon::model::{remaining_sequence, Coin, SpendTx}, daemon::model::{remaining_sequence, Coin, SpendTx},
}; };
#[allow(clippy::too_many_arguments)]
pub fn spend_view<'a>( pub fn spend_view<'a>(
cache: &'a Cache, cache: &'a Cache,
tx: &'a SpendTx, tx: &'a SpendTx,
saved: bool, saved: bool,
desc_info: &'a LianaPolicy, desc_info: &'a LianaPolicy,
key_aliases: &'a HashMap<Fingerprint, String>, key_aliases: &'a HashMap<Fingerprint, String>,
labels_editing: &'a HashMap<String, form::Value<String>>,
network: Network, network: Network,
warning: Option<&Error>,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
dashboard( dashboard(
&Menu::CreateSpendTx, &Menu::CreateSpendTx,
cache, cache,
None, warning,
Column::new() Column::new()
.spacing(20) .spacing(20)
.push(Container::new(h3("Send")).width(Length::Fill)) .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::spend_overview_view(tx, desc_info, key_aliases))
.push(psbt::inputs_and_outputs_view( .push(psbt::inputs_and_outputs_view(
&tx.coins, &tx.coins,
&tx.psbt.unsigned_tx, &tx.psbt.unsigned_tx,
network, network,
Some(tx.change_indexes.clone()), Some(tx.change_indexes.clone()),
None, &tx.labels,
labels_editing,
)) ))
.push(if saved { .push(if saved {
Row::new() Row::new()
@ -89,6 +93,8 @@ pub fn create_spend_tx<'a>(
duplicate: bool, duplicate: bool,
timelock: u16, timelock: u16,
coins: &[(Coin, bool)], coins: &[(Coin, bool)],
coins_labels: &'a HashMap<String, String>,
batch_label: &form::Value<String>,
amount_left: Option<&Amount>, amount_left: Option<&Amount>,
feerate: &form::Value<String>, feerate: &form::Value<String>,
error: Option<&Error>, error: Option<&Error>,
@ -104,6 +110,18 @@ pub fn create_spend_tx<'a>(
} else { } else {
"Send" "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( .push(
Column::new() Column::new()
.push(Column::with_children(recipients).spacing(10)) .push(Column::with_children(recipients).spacing(10))
@ -112,7 +130,7 @@ pub fn create_spend_tx<'a>(
.push_maybe(if duplicate { .push_maybe(if duplicate {
Some( Some(
Container::new( Container::new(
text("Two recipient addresses are the same") text("Two payment addresses are the same")
.style(color::RED), .style(color::RED),
) )
.padding(10), .padding(10),
@ -125,7 +143,7 @@ pub fn create_spend_tx<'a>(
None None
} else { } else {
Some( Some(
button::secondary(Some(icon::plus_icon()), "Add recipient") button::secondary(Some(icon::plus_icon()), "Add payment")
.on_press(Message::CreateSpend( .on_press(Message::CreateSpend(
CreateSpendMessage::AddRecipient, CreateSpendMessage::AddRecipient,
)), )),
@ -201,6 +219,7 @@ pub fn create_spend_tx<'a>(
col.push(coin_list_view( col.push(coin_list_view(
i, i,
coin, coin,
coins_labels,
timelock, timelock,
cache.blockheight as u32, cache.blockheight as u32,
*selected, *selected,
@ -246,6 +265,7 @@ pub fn recipient_view<'a>(
index: usize, index: usize,
address: &form::Value<String>, address: &form::Value<String>,
amount: &form::Value<String>, amount: &form::Value<String>,
label: &form::Value<String>,
) -> Element<'a, CreateSpendMessage> { ) -> Element<'a, CreateSpendMessage> {
Container::new( Container::new(
Column::new() Column::new()
@ -263,10 +283,10 @@ pub fn recipient_view<'a>(
.align_items(Alignment::Start) .align_items(Alignment::Start)
.spacing(10) .spacing(10)
.push( .push(
Container::new(p1_bold("Pay to")) Container::new(p1_bold("Address"))
.align_x(alignment::Horizontal::Right) .align_x(alignment::Horizontal::Right)
.padding(10) .padding(10)
.width(Length::Fixed(80.0)), .width(Length::Fixed(110.0)),
) )
.push( .push(
form::Form::new_trimmed("Address", address, move |msg| { form::Form::new_trimmed("Address", address, move |msg| {
@ -277,6 +297,25 @@ pub fn recipient_view<'a>(
.padding(10), .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( .push(
Row::new() Row::new()
.align_items(Alignment::Start) .align_items(Alignment::Start)
@ -285,7 +324,7 @@ pub fn recipient_view<'a>(
Container::new(p1_bold("Amount")) Container::new(p1_bold("Amount"))
.padding(10) .padding(10)
.align_x(alignment::Horizontal::Right) .align_x(alignment::Horizontal::Right)
.width(Length::Fixed(80.0)), .width(Length::Fixed(110.0)),
) )
.push( .push(
form::Form::new_trimmed("0.001 (in BTC)", amount, move |msg| { form::Form::new_trimmed("0.001 (in BTC)", amount, move |msg| {
@ -308,6 +347,7 @@ pub fn recipient_view<'a>(
fn coin_list_view<'a>( fn coin_list_view<'a>(
i: usize, i: usize,
coin: &Coin, coin: &Coin,
coins_labels: &'a HashMap<String, String>,
timelock: u16, timelock: u16,
blockheight: u32, blockheight: u32,
selected: bool, selected: bool,
@ -318,6 +358,13 @@ fn coin_list_view<'a>(
.push(checkbox("", selected, move |_| { .push(checkbox("", selected, move |_| {
Message::CreateSpend(CreateSpendMessage::SelectCoin(i)) 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() { .push(if coin.spend_info.is_some() {
badge::spent() badge::spent()
} else if coin.block_height.is_none() { } else if coin.block_height.is_none() {

View File

@ -1,10 +1,11 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use std::collections::HashMap;
use iced::{alignment, Alignment, Length}; use iced::{alignment, Alignment, Length};
use liana_ui::{ use liana_ui::{
color, color,
component::{amount::*, badge, card, text::*}, component::{amount::*, badge, card, form, text::*},
icon, theme, icon, theme,
util::Collection, util::Collection,
widget::*, widget::*,
@ -15,7 +16,7 @@ use crate::{
cache::Cache, cache::Cache,
error::Error, error::Error,
menu::Menu, menu::Menu,
view::{dashboard, message::Message}, view::{dashboard, label, message::Message},
}, },
daemon::model::HistoryTransaction, daemon::model::HistoryTransaction,
}; };
@ -24,8 +25,8 @@ pub const HISTORY_EVENT_PAGE_SIZE: u64 = 20;
pub fn transactions_view<'a>( pub fn transactions_view<'a>(
cache: &'a Cache, cache: &'a Cache,
pending_txs: &[HistoryTransaction], pending_txs: &'a [HistoryTransaction],
txs: &Vec<HistoryTransaction>, txs: &'a Vec<HistoryTransaction>,
warning: Option<&'a Error>, warning: Option<&'a Error>,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
dashboard( 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(i: usize, tx: &HistoryTransaction) -> Element<'_, Message> {
Container::new( Container::new(
Button::new( Button::new(
Row::new() Row::new()
@ -96,7 +97,10 @@ fn tx_list_view<'a>(i: usize, tx: &HistoryTransaction) -> Element<'a, Message> {
} else { } else {
badge::spend() badge::spend()
}) })
.push(if let Some(t) = tx.time { .push(
Column::new()
.push_maybe(tx.labels.get(&tx.tx.txid().to_string()).map(p1_bold))
.push_maybe(tx.time.map(|t| {
Container::new( Container::new(
text(format!( text(format!(
"{}", "{}",
@ -104,15 +108,25 @@ fn tx_list_view<'a>(i: usize, tx: &HistoryTransaction) -> Element<'a, Message> {
.unwrap() .unwrap()
.format("%b. %d, %Y - %T"), .format("%b. %d, %Y - %T"),
)) ))
.style(color::GREY_3)
.small(), .small(),
) )
} else { })),
badge::unconfirmed() )
})
.spacing(10) .spacing(10)
.align_items(Alignment::Center) .align_items(Alignment::Center)
.width(Length::Fill), .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() { .push(if tx.is_external() {
Row::new() Row::new()
.spacing(5) .spacing(5)
@ -142,8 +156,10 @@ fn tx_list_view<'a>(i: usize, tx: &HistoryTransaction) -> Element<'a, Message> {
pub fn tx_view<'a>( pub fn tx_view<'a>(
cache: &'a Cache, cache: &'a Cache,
tx: &'a HistoryTransaction, tx: &'a HistoryTransaction,
labels_editing: &'a HashMap<String, form::Value<String>>,
warning: Option<&'a Error>, warning: Option<&'a Error>,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let txid = tx.tx.txid().to_string();
dashboard( dashboard(
&Menu::Transactions, &Menu::Transactions,
cache, cache,
@ -156,6 +172,11 @@ pub fn tx_view<'a>(
} else { } else {
Container::new(h3("Outgoing transaction")).width(Length::Fill) 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( .push(
Column::new().spacing(20).push( Column::new().spacing(20).push(
Column::new() Column::new()
@ -202,10 +223,10 @@ pub fn tx_view<'a>(
.push( .push(
Row::new() Row::new()
.align_items(Alignment::Center) .align_items(Alignment::Center)
.push(Container::new(text(format!("{}", tx.tx.txid())).small())) .push(Container::new(text(txid.clone()).small()))
.push( .push(
Button::new(icon::clipboard_icon()) Button::new(icon::clipboard_icon())
.on_press(Message::Clipboard(tx.tx.txid().to_string())) .on_press(Message::Clipboard(txid.clone()))
.style(theme::Button::TransparentBorder), .style(theme::Button::TransparentBorder),
) )
.width(Length::Shrink), .width(Length::Shrink),
@ -222,11 +243,8 @@ pub fn tx_view<'a>(
} else { } else {
Some(tx.change_indexes.clone()) Some(tx.change_indexes.clone())
}, },
if tx.is_external() { &tx.labels,
Some(tx.change_indexes.clone()) labels_editing,
} else {
None
},
)) ))
.spacing(20), .spacing(20),
) )

View File

@ -1,5 +1,6 @@
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::fmt::Debug; use std::fmt::Debug;
use std::iter::FromIterator;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -10,6 +11,7 @@ pub mod error;
pub mod jsonrpc; pub mod jsonrpc;
use liana::{ use liana::{
commands::LabelItem,
config::Config, config::Config,
miniscript::bitcoin::{address, psbt::Psbt, Address, OutPoint, Txid}, miniscript::bitcoin::{address, psbt::Psbt, Address, OutPoint, Txid},
}; };
@ -145,6 +147,22 @@ impl<C: Client + Debug> Daemon for Lianad<C> {
)?; )?;
Ok(res.psbt) Ok(res.psbt)
} }
fn get_labels(
&self,
items: &HashSet<LabelItem>,
) -> Result<HashMap<String, String>, DaemonError> {
let items = items.iter().map(|a| a.to_string()).collect::<Vec<String>>();
let res: GetLabelsResult = self.call("getlabels", Some(vec![items]))?;
Ok(res.labels)
}
fn update_labels(&self, items: &HashMap<LabelItem, String>) -> Result<(), DaemonError> {
let labels: HashMap<String, String> =
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)] #[derive(Debug, Clone, Deserialize, Serialize)]

View File

@ -1,7 +1,8 @@
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use super::{model::*, Daemon, DaemonError}; use super::{model::*, Daemon, DaemonError};
use liana::{ use liana::{
commands::LabelItem,
config::Config, config::Config,
miniscript::bitcoin::{address, psbt::Psbt, Address, OutPoint, Txid}, miniscript::bitcoin::{address, psbt::Psbt, Address, OutPoint, Txid},
DaemonControl, DaemonHandle, DaemonControl, DaemonHandle,
@ -59,7 +60,7 @@ impl Daemon for EmbeddedDaemon {
} }
fn list_coins(&self) -> Result<ListCoinsResult, DaemonError> { fn list_coins(&self) -> Result<ListCoinsResult, DaemonError> {
Ok(self.control()?.list_coins()) Ok(self.control()?.list_coins(&[], &[]))
} }
fn list_spend_txs(&self) -> Result<ListSpendResult, DaemonError> { fn list_spend_txs(&self) -> Result<ListSpendResult, DaemonError> {
@ -126,4 +127,16 @@ impl Daemon for EmbeddedDaemon {
.map_err(|e| DaemonError::Unexpected(e.to_string())) .map_err(|e| DaemonError::Unexpected(e.to_string()))
.map(|res| res.psbt) .map(|res| res.psbt)
} }
fn get_labels(
&self,
items: &HashSet<LabelItem>,
) -> Result<HashMap<String, String>, DaemonError> {
Ok(self.handle.control.get_labels(items).labels)
}
fn update_labels(&self, items: &HashMap<LabelItem, String>) -> Result<(), DaemonError> {
self.handle.control.update_labels(items);
Ok(())
}
} }

View File

@ -2,11 +2,12 @@ pub mod client;
pub mod embedded; pub mod embedded;
pub mod model; pub mod model;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::fmt::Debug; use std::fmt::Debug;
use std::io::ErrorKind; use std::io::ErrorKind;
use liana::{ use liana::{
commands::LabelItem,
config::Config, config::Config,
miniscript::bitcoin::{address, psbt::Psbt, Address, OutPoint, Txid}, miniscript::bitcoin::{address, psbt::Psbt, Address, OutPoint, Txid},
StartupError, StartupError,
@ -75,6 +76,11 @@ pub trait Daemon: Debug {
sequence: Option<u16>, sequence: Option<u16>,
) -> Result<Psbt, DaemonError>; ) -> Result<Psbt, DaemonError>;
fn list_txs(&self, txid: &[Txid]) -> Result<model::ListTransactionsResult, DaemonError>; fn list_txs(&self, txid: &[Txid]) -> Result<model::ListTransactionsResult, DaemonError>;
fn get_labels(
&self,
labels: &HashSet<LabelItem>,
) -> Result<HashMap<String, String>, DaemonError>;
fn update_labels(&self, labels: &HashMap<LabelItem, String>) -> Result<(), DaemonError>;
fn list_spend_transactions(&self) -> Result<Vec<model::SpendTx>, DaemonError> { fn list_spend_transactions(&self) -> Result<Vec<model::SpendTx>, DaemonError> {
let info = self.get_info()?; let info = self.get_info()?;
@ -103,8 +109,10 @@ pub trait Daemon: Debug {
coins, coins,
sigs, sigs,
info.descriptors.main.max_sat_vbytes(), info.descriptors.main.max_sat_vbytes(),
info.network,
)) ))
} }
load_labels(self, &mut spend_txs)?;
spend_txs.sort_by(|a, b| { spend_txs.sort_by(|a, b| {
if a.status == b.status { if a.status == b.status {
// last updated first // last updated first
@ -123,9 +131,10 @@ pub trait Daemon: Debug {
end: u32, end: u32,
limit: u64, limit: u64,
) -> Result<Vec<model::HistoryTransaction>, DaemonError> { ) -> Result<Vec<model::HistoryTransaction>, DaemonError> {
let info = self.get_info()?;
let coins = self.list_coins()?.coins; let coins = self.list_coins()?.coins;
let txs = self.list_confirmed_txs(start, end, limit)?.transactions; let txs = self.list_confirmed_txs(start, end, limit)?.transactions;
Ok(txs let mut txs = txs
.into_iter() .into_iter()
.map(|tx| { .map(|tx| {
let mut tx_coins = Vec::new(); let mut tx_coins = Vec::new();
@ -142,12 +151,22 @@ pub trait Daemon: Debug {
tx_coins.push(coin.clone()); 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<Vec<model::HistoryTransaction>, DaemonError> { fn list_pending_txs(&self) -> Result<Vec<model::HistoryTransaction>, DaemonError> {
let info = self.get_info()?;
let coins = self.list_coins()?.coins; let coins = self.list_coins()?.coins;
let mut txids: Vec<Txid> = Vec::new(); let mut txids: Vec<Txid> = Vec::new();
for coin in &coins { for coin in &coins {
@ -163,7 +182,7 @@ pub trait Daemon: Debug {
} }
let txs = self.list_txs(&txids)?.transactions; let txs = self.list_txs(&txids)?.transactions;
Ok(txs let mut txs = txs
.into_iter() .into_iter()
.map(|tx| { .map(|tx| {
let mut tx_coins = Vec::new(); let mut tx_coins = Vec::new();
@ -180,8 +199,38 @@ pub trait Daemon: Debug {
tx_coins.push(coin.clone()); 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<T: model::Labelled, D: Daemon + ?Sized>(
daemon: &D,
targets: &mut Vec<T>,
) -> Result<(), DaemonError> {
if targets.is_empty() {
return Ok(());
}
let mut items = HashSet::<LabelItem>::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(())
}

View File

@ -1,12 +1,15 @@
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
pub use liana::{ pub use liana::{
commands::{ commands::{
CreateSpendResult, GetAddressResult, GetInfoResult, ListCoinsEntry, ListCoinsResult, CreateSpendResult, GetAddressResult, GetInfoResult, GetLabelsResult, LabelItem,
ListSpendEntry, ListSpendResult, ListTransactionsResult, TransactionInfo, ListCoinsEntry, ListCoinsResult, ListSpendEntry, ListSpendResult, ListTransactionsResult,
TransactionInfo,
}, },
descriptors::{PartialSpendInfo, PathSpendInfo}, 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; pub type Coin = ListCoinsEntry;
@ -25,7 +28,9 @@ pub fn remaining_sequence(coin: &Coin, blockheight: u32, timelock: u16) -> u32 {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SpendTx { pub struct SpendTx {
pub network: Network,
pub coins: Vec<Coin>, pub coins: Vec<Coin>,
pub labels: HashMap<String, String>,
pub psbt: Psbt, pub psbt: Psbt,
pub change_indexes: Vec<usize>, pub change_indexes: Vec<usize>,
pub spend_amount: Amount, pub spend_amount: Amount,
@ -53,6 +58,7 @@ impl SpendTx {
coins: Vec<Coin>, coins: Vec<Coin>,
sigs: PartialSpendInfo, sigs: PartialSpendInfo,
max_sat_vbytes: usize, max_sat_vbytes: usize,
network: Network,
) -> Self { ) -> Self {
let mut change_indexes = Vec::new(); let mut change_indexes = Vec::new();
let (change_amount, spend_amount) = psbt.unsigned_tx.output.iter().enumerate().fold( let (change_amount, spend_amount) = psbt.unsigned_tx.output.iter().enumerate().fold(
@ -85,6 +91,7 @@ impl SpendTx {
} }
Self { Self {
labels: HashMap::new(),
updated_at, updated_at,
coins, coins,
psbt, psbt,
@ -94,6 +101,7 @@ impl SpendTx {
max_sat_vbytes, max_sat_vbytes,
status, status,
sigs, sigs,
network,
} }
} }
@ -135,10 +143,48 @@ impl SpendTx {
self.psbt.unsigned_tx.vsize() + (self.max_sat_vbytes * self.psbt.inputs.len()); self.psbt.unsigned_tx.vsize() + (self.max_sat_vbytes * self.psbt.inputs.len());
self.fee_amount.to_sat() / max_tx_vbytes as u64 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<String, String> {
&mut self.labels
}
fn labelled(&self) -> Vec<LabelItem> {
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));
}
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)] #[derive(Debug, Clone)]
pub struct HistoryTransaction { pub struct HistoryTransaction {
pub network: Network,
pub labels: HashMap<String, String>,
pub coins: Vec<Coin>, pub coins: Vec<Coin>,
pub change_indexes: Vec<usize>, pub change_indexes: Vec<usize>,
pub tx: Transaction, pub tx: Transaction,
@ -156,6 +202,7 @@ impl HistoryTransaction {
time: Option<u32>, time: Option<u32>,
coins: Vec<Coin>, coins: Vec<Coin>,
change_indexes: Vec<usize>, change_indexes: Vec<usize>,
network: Network,
) -> Self { ) -> Self {
let (incoming_amount, outgoing_amount) = tx.output.iter().enumerate().fold( let (incoming_amount, outgoing_amount) = tx.output.iter().enumerate().fold(
(Amount::from_sat(0), Amount::from_sat(0)), (Amount::from_sat(0), Amount::from_sat(0)),
@ -180,6 +227,7 @@ impl HistoryTransaction {
}; };
Self { Self {
labels: HashMap::new(),
tx, tx,
coins, coins,
change_indexes, change_indexes,
@ -188,6 +236,7 @@ impl HistoryTransaction {
fee_amount, fee_amount,
height, height,
time, time,
network,
} }
} }
@ -198,4 +247,54 @@ impl HistoryTransaction {
pub fn is_self_send(&self) -> bool { pub fn is_self_send(&self) -> bool {
!self.coins.is_empty() && self.outgoing_amount == Amount::from_sat(0) !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<String, String> {
&mut self.labels
}
fn labelled(&self) -> Vec<LabelItem> {
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));
}
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<LabelItem>;
fn labels(&mut self) -> &mut HashMap<String, String>;
fn load_labels(&mut self, new_labels: &HashMap<String, String>) {
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());
}
}
}
} }

View File

@ -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> { pub fn deprecated<'a, T: 'a>() -> Container<'a, T> {
Container::new( Container::new(
tooltip::Tooltip::new( tooltip::Tooltip::new(

View File

@ -1,6 +1,8 @@
use crate::{ use crate::{
color,
component::{amount, badge, text}, component::{amount, badge, text},
theme, theme,
util::Collection,
widget::*, widget::*,
}; };
use bitcoin::Amount; use bitcoin::Amount;
@ -9,14 +11,19 @@ use iced::{
Alignment, Length, 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<iced::widget::Text<'a, iced::Renderer<theme::Theme>>>,
amount: &Amount,
msg: T,
) -> Container<'a, T> {
Container::new( Container::new(
button( button(
row!( row!(
row!(badge::spend(), badge::unconfirmed()) row!(badge::spend(), Column::new().push_maybe(label),)
.spacing(10) .spacing(10)
.align_items(Alignment::Center) .align_items(Alignment::Center)
.width(Length::Fill), .width(Length::Fill),
badge::unconfirmed(),
row!(text::p1_regular("-"), amount::amount(amount)) row!(text::p1_regular("-"), amount::amount(amount))
.spacing(5) .spacing(5)
.align_items(Alignment::Center), .align_items(Alignment::Center),
@ -32,6 +39,7 @@ pub fn unconfirmed_outgoing_event<'a, T: Clone + 'a>(amount: &Amount, msg: T) ->
} }
pub fn confirmed_outgoing_event<'a, T: Clone + 'a>( pub fn confirmed_outgoing_event<'a, T: Clone + 'a>(
label: Option<iced::widget::Text<'a, iced::Renderer<theme::Theme>>>,
date: chrono::NaiveDateTime, date: chrono::NaiveDateTime,
amount: &Amount, amount: &Amount,
msg: T, msg: T,
@ -41,7 +49,10 @@ pub fn confirmed_outgoing_event<'a, T: Clone + 'a>(
row!( row!(
row!( row!(
badge::spend(), badge::spend(),
Column::new().push_maybe(label).push(
text::p2_regular(date.format("%b. %d, %Y - %T").to_string()) text::p2_regular(date.format("%b. %d, %Y - %T").to_string())
.style(color::GREY_3)
)
) )
.spacing(10) .spacing(10)
.align_items(Alignment::Center) .align_items(Alignment::Center)
@ -60,14 +71,19 @@ pub fn confirmed_outgoing_event<'a, T: Clone + 'a>(
.style(theme::Container::Card(theme::Card::Simple)) .style(theme::Container::Card(theme::Card::Simple))
} }
pub fn unconfirmed_incoming_event<'a, T: Clone + 'a>(amount: &Amount, msg: T) -> Container<'a, T> { pub fn unconfirmed_incoming_event<'a, T: Clone + 'a>(
label: Option<iced::widget::Text<'a, iced::Renderer<theme::Theme>>>,
amount: &Amount,
msg: T,
) -> Container<'a, T> {
Container::new( Container::new(
button( button(
row!( row!(
row!(badge::receive(), badge::unconfirmed()) row!(badge::receive(), Column::new().push_maybe(label))
.spacing(10) .spacing(10)
.align_items(Alignment::Center) .align_items(Alignment::Center)
.width(Length::Fill), .width(Length::Fill),
badge::unconfirmed(),
row!(text::p1_regular("+"), amount::amount(amount)) row!(text::p1_regular("+"), amount::amount(amount))
.spacing(5) .spacing(5)
.align_items(Alignment::Center), .align_items(Alignment::Center),
@ -83,6 +99,7 @@ pub fn unconfirmed_incoming_event<'a, T: Clone + 'a>(amount: &Amount, msg: T) ->
} }
pub fn confirmed_incoming_event<'a, T: Clone + 'a>( pub fn confirmed_incoming_event<'a, T: Clone + 'a>(
label: Option<iced::widget::Text<'a, iced::Renderer<theme::Theme>>>,
date: chrono::NaiveDateTime, date: chrono::NaiveDateTime,
amount: &Amount, amount: &Amount,
msg: T, msg: T,
@ -92,7 +109,10 @@ pub fn confirmed_incoming_event<'a, T: Clone + 'a>(
row!( row!(
row!( row!(
badge::receive(), badge::receive(),
Column::new().push_maybe(label).push(
text::p2_regular(date.format("%b. %d, %Y - %T").to_string()) text::p2_regular(date.format("%b. %d, %Y - %T").to_string())
.style(color::GREY_3)
)
) )
.spacing(10) .spacing(10)
.align_items(Alignment::Center) .align_items(Alignment::Center)