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]]
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",

View File

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

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::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)]

View File

@ -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<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 {
coins: Vec<Coin>,
coins: Coins,
selected: Vec<usize>,
labels_edited: LabelsEdited,
warning: Option<Error>,
/// 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,14 @@ 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())
} else {
None
}
})
.filter(|coin| coin.spend_info.is_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 +91,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<dyn Daemon + Sync + Send>,
daemon: Arc<dyn Daemon + Sync + Send>,
_cache: &Cache,
message: Message,
) -> Command<Message> {
@ -83,6 +117,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 +148,34 @@ impl State for CoinsPanel {
}
fn load(&self, daemon: Arc<dyn Daemon + Sync + Send>) -> Command<Message> {
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(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!(
panel
.coins
.list
.iter()
.map(|c| c.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 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<HistoryTransaction>,
events: Vec<HistoryTransaction>,
selected_event: Option<(usize, usize)>,
labels_edited: LabelsEdited,
warning: Option<Error>,
}
@ -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<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)]
pub struct ReceivePanel {
addresses: Vec<Address>,
addresses: Addresses,
labels_edited: LabelsEdited,
qr_code: Option<qr_code::State>,
warning: Option<Error>,
}
@ -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<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) => {
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]);
}
}

View File

@ -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<Error>,
pub labels_edited: LabelsEdited,
pub action: Option<Box<dyn Action>>,
}
@ -58,6 +61,8 @@ impl PsbtState {
Self {
desc_policy: wallet.main_descriptor.policy(),
wallet,
labels_edited: LabelsEdited::default(),
warning: None,
action: None,
tx,
saved,
@ -84,7 +89,7 @@ impl PsbtState {
self.action = None;
}
view::SpendTxMessage::Delete => {
self.action = Some(Box::new(DeleteAction::default()));
self.action = Some(Box::<DeleteAction>::default());
}
view::SpendTxMessage::Sign => {
let action = SignAction::new(self.tx.signers(), self.wallet.clone());
@ -99,10 +104,10 @@ impl PsbtState {
return cmd;
}
view::SpendTxMessage::Broadcast => {
self.action = Some(Box::new(BroadcastAction::default()));
self.action = Some(Box::<BroadcastAction>::default());
}
view::SpendTxMessage::Save => {
self.action = Some(Box::new(SaveAction::default()));
self.action = Some(Box::<SaveAction>::default());
}
_ => {
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(_)) => {
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::<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(
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,
);
}

View File

@ -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,
);

View File

@ -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<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 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<dyn Daemon + Sync + Send>) -> Command<Message> {
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(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
const DUST_OUTPUT_SATS: u64 = 5_000;
#[derive(Default, Clone)]
#[derive(Clone)]
pub struct TransactionDraft {
network: Network,
inputs: Vec<Coin>,
recipients: Vec<Recipient>,
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 {
@ -53,6 +70,8 @@ pub struct DefineSpend {
descriptor: LianaDescriptor,
timelock: u16,
coins: Vec<(Coin, bool)>,
coins_labels: HashMap<String, String>,
batch_label: form::Value<String>,
amount_left_to_select: Option<Amount>,
feerate: form::Value<String>,
generated: Option<Psbt>,
@ -88,6 +107,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 +149,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 +239,146 @@ impl Step for DefineSpend {
cache: &Cache,
message: Message,
) -> Command<Message> {
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::<u64>() {
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<OutPoint> = self
.coins
.iter()
.filter_map(
|(coin, selected)| if *selected { Some(coin.outpoint) } else { None },
)
.collect();
let mut outputs: HashMap<Address<address::NetworkUnchecked>, u64> =
HashMap::new();
for recipient in &self.recipients {
outputs.insert(
Address::from_str(&recipient.address.value).expect("Checked before"),
recipient.amount().expect("Checked before"),
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::<u64>() {
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<OutPoint> = self
.coins
.iter()
.filter_map(
|(coin, selected)| {
if *selected {
Some(coin.outpoint)
} else {
None
}
},
)
.collect();
let mut outputs: HashMap<Address<address::NetworkUnchecked>, u64> =
HashMap::new();
for recipient in &self.recipients {
outputs.insert(
Address::from_str(&recipient.address.value)
.expect("Checked before"),
recipient.amount().expect("Checked before"),
);
}
let feerate_vb = self.feerate.value.parse::<u64>().unwrap_or(0);
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::<u64>().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,
);
}
}
}
draft.recipients = self.recipients.clone();
if self.recipients.len() > 1 {
draft.batch_label = Some(self.batch_label.value.clone());
}
draft.generated = self.generated.clone();
}
@ -326,6 +401,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(),
@ -333,8 +410,9 @@ impl Step for DefineSpend {
}
}
#[derive(Default)]
#[derive(Default, Clone)]
struct Recipient {
label: form::Value<String>,
address: form::Value<String>,
amount: form::Value<String>,
}
@ -372,6 +450,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 +478,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::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
.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 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(
@ -464,7 +579,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())

View File

@ -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<HistoryTransaction>,
txs: Vec<HistoryTransaction>,
labels_edited: LabelsEdited,
selected_tx: Option<usize>,
warning: Option<Error>,
}
@ -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,23 @@ 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.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) => {
if let Some(last) = self.txs.last() {
let daemon = daemon.clone();

View File

@ -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<String, String>,
labels_editing: &'a HashMap<String, form::Value<String>>,
) -> 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<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(
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)
@ -186,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)

View File

@ -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<u32>,
expiring_coins: &Vec<bitcoin::OutPoint>,
pending_events: &[HistoryTransaction],
events: &Vec<HistoryTransaction>,
pending_events: &'a [HistoryTransaction],
events: &'a Vec<HistoryTransaction>,
) -> Element<'a, Message> {
Column::new()
.push(h3("Balance"))
@ -145,21 +146,41 @@ pub fn home_view<'a>(
.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(
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 {
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) {
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 +189,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 +209,15 @@ pub fn payment_view<'a>(
cache: &'a Cache,
tx: &'a HistoryTransaction,
output_index: usize,
labels_editing: &'a HashMap<String, form::Value<String>>,
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 +230,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 +300,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),
)

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,
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),

View File

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

View File

@ -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,24 +33,27 @@ 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,
};
#[allow(clippy::too_many_arguments)]
pub fn psbt_view<'a>(
cache: &'a Cache,
tx: &'a SpendTx,
saved: bool,
desc_info: &'a LianaPolicy,
key_aliases: &'a HashMap<Fingerprint, String>,
labels_editing: &'a HashMap<String, form::Value<String>>,
network: Network,
warning: Option<&Error>,
) -> Element<'a, Message> {
dashboard(
&Menu::PSBTs,
cache,
None,
warning,
Column::new()
.spacing(20)
.push(
@ -65,14 +73,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 +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()
.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 +522,10 @@ pub fn inputs_and_outputs_view<'a>(
tx: &'a Transaction,
network: Network,
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> {
let change_indexes_copy = change_indexes.clone();
Column::new()
.spacing(20)
.push_maybe(if !coins.is_empty() {
@ -551,29 +571,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 +584,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<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()
}

View File

@ -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(spend_txs: &[SpendTx]) -> Element<'_, 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(i: usize, tx: &SpendTx) -> Element<'_, 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(p1_bold),
)
.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)

View File

@ -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<String, String>,
labels_editing: &'a HashMap<String, form::Value<String>>,
) -> 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),
)

View File

@ -29,29 +29,33 @@ use crate::{
daemon::model::{remaining_sequence, Coin, SpendTx},
};
#[allow(clippy::too_many_arguments)]
pub fn spend_view<'a>(
cache: &'a Cache,
tx: &'a SpendTx,
saved: bool,
desc_info: &'a LianaPolicy,
key_aliases: &'a HashMap<Fingerprint, String>,
labels_editing: &'a HashMap<String, form::Value<String>>,
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 +93,8 @@ pub fn create_spend_tx<'a>(
duplicate: bool,
timelock: u16,
coins: &[(Coin, bool)],
coins_labels: &'a HashMap<String, String>,
batch_label: &form::Value<String>,
amount_left: Option<&Amount>,
feerate: &form::Value<String>,
error: Option<&Error>,
@ -104,6 +110,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 +130,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 +143,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 +219,7 @@ pub fn create_spend_tx<'a>(
col.push(coin_list_view(
i,
coin,
coins_labels,
timelock,
cache.blockheight as u32,
*selected,
@ -246,6 +265,7 @@ pub fn recipient_view<'a>(
index: usize,
address: &form::Value<String>,
amount: &form::Value<String>,
label: &form::Value<String>,
) -> Element<'a, CreateSpendMessage> {
Container::new(
Column::new()
@ -263,10 +283,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 +297,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 +324,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 +347,7 @@ pub fn recipient_view<'a>(
fn coin_list_view<'a>(
i: usize,
coin: &Coin,
coins_labels: &'a HashMap<String, String>,
timelock: u16,
blockheight: u32,
selected: bool,
@ -318,6 +358,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() {

View File

@ -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<HistoryTransaction>,
pending_txs: &'a [HistoryTransaction],
txs: &'a Vec<HistoryTransaction>,
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(i: usize, tx: &HistoryTransaction) -> Element<'_, Message> {
Container::new(
Button::new(
Row::new()
@ -96,23 +97,36 @@ 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(p1_bold))
.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 +156,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<String, form::Value<String>>,
warning: Option<&'a Error>,
) -> Element<'a, Message> {
let txid = tx.tx.txid().to_string();
dashboard(
&Menu::Transactions,
cache,
@ -156,6 +172,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 +223,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 +243,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),
)

View File

@ -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<C: Client + Debug> Daemon for Lianad<C> {
)?;
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)]

View File

@ -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,
@ -59,7 +60,7 @@ impl Daemon for EmbeddedDaemon {
}
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> {
@ -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<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 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<u16>,
) -> Result<Psbt, 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> {
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<Vec<model::HistoryTransaction>, DaemonError> {
let info = self.get_info()?;
let coins = self.list_coins()?.coins;
let txs = self.list_confirmed_txs(start, end, limit)?.transactions;
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<Vec<model::HistoryTransaction>, DaemonError> {
let info = self.get_info()?;
let coins = self.list_coins()?.coins;
let mut txids: Vec<Txid> = 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<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::{
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 {
pub network: Network,
pub coins: Vec<Coin>,
pub labels: HashMap<String, String>,
pub psbt: Psbt,
pub change_indexes: Vec<usize>,
pub spend_amount: Amount,
@ -53,6 +58,7 @@ impl SpendTx {
coins: Vec<Coin>,
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<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)]
pub struct HistoryTransaction {
pub network: Network,
pub labels: HashMap<String, String>,
pub coins: Vec<Coin>,
pub change_indexes: Vec<usize>,
pub tx: Transaction,
@ -156,6 +202,7 @@ impl HistoryTransaction {
time: Option<u32>,
coins: Vec<Coin>,
change_indexes: Vec<usize>,
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<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> {
Container::new(
tooltip::Tooltip::new(

View File

@ -1,6 +1,8 @@
use crate::{
color,
component::{amount, badge, text},
theme,
util::Collection,
widget::*,
};
use bitcoin::Amount;
@ -9,14 +11,19 @@ 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<iced::widget::Text<'a, iced::Renderer<theme::Theme>>>,
amount: &Amount,
msg: T,
) -> Container<'a, T> {
Container::new(
button(
row!(
row!(badge::spend(), badge::unconfirmed())
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),
@ -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>(
label: Option<iced::widget::Text<'a, iced::Renderer<theme::Theme>>>,
date: chrono::NaiveDateTime,
amount: &Amount,
msg: T,
@ -41,7 +49,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,14 +71,19 @@ 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> {
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(
button(
row!(
row!(badge::receive(), badge::unconfirmed())
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),
@ -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>(
label: Option<iced::widget::Text<'a, iced::Renderer<theme::Theme>>>,
date: chrono::NaiveDateTime,
amount: &Amount,
msg: T,
@ -92,7 +109,10 @@ 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(
text::p2_regular(date.format("%b. %d, %Y - %T").to_string())
.style(color::GREY_3)
)
)
.spacing(10)
.align_items(Alignment::Center)