diff --git a/gui/src/app/state/mod.rs b/gui/src/app/state/mod.rs index 5e1867f1..c7170b61 100644 --- a/gui/src/app/state/mod.rs +++ b/gui/src/app/state/mod.rs @@ -1,4 +1,5 @@ mod coins; +mod psbt; mod psbts; mod recovery; mod settings; diff --git a/gui/src/app/state/spend/detail.rs b/gui/src/app/state/psbt.rs similarity index 97% rename from gui/src/app/state/spend/detail.rs rename to gui/src/app/state/psbt.rs index 990c36b7..d6d2a832 100644 --- a/gui/src/app/state/spend/detail.rs +++ b/gui/src/app/state/psbt.rs @@ -21,7 +21,6 @@ use crate::{ error::Error, message::Message, view, - view::spend::detail, wallet::{Wallet, WalletError}, }, daemon::{ @@ -49,7 +48,7 @@ trait Action { fn view(&self) -> Element; } -pub struct SpendTxState { +pub struct PsbtState { wallet: Arc, desc_policy: LianaPolicy, tx: SpendTx, @@ -57,7 +56,7 @@ pub struct SpendTxState { action: Option>, } -impl SpendTxState { +impl PsbtState { pub fn new(wallet: Arc, tx: SpendTx, saved: bool) -> Self { Self { desc_policy: wallet.main_descriptor.policy(), @@ -130,7 +129,8 @@ impl SpendTxState { } pub fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { - let content = detail::spend_view( + let content = view::psbt::spend_view( + cache, &self.tx, self.saved, &self.desc_policy, @@ -178,7 +178,7 @@ impl Action for SaveAction { Command::none() } fn view(&self) -> Element { - detail::save_action(self.error.as_ref(), self.saved) + view::psbt::save_action(self.error.as_ref(), self.saved) } } @@ -221,7 +221,7 @@ impl Action for BroadcastAction { Command::none() } fn view(&self) -> Element { - detail::broadcast_action(self.error.as_ref(), self.broadcast) + view::psbt::broadcast_action(self.error.as_ref(), self.broadcast) } } @@ -261,7 +261,7 @@ impl Action for DeleteAction { Command::none() } fn view(&self) -> Element { - detail::delete_action(self.error.as_ref(), self.deleted) + view::psbt::delete_action(self.error.as_ref(), self.deleted) } } @@ -376,7 +376,7 @@ impl Action for SignAction { Command::none() } fn view(&self) -> Element { - view::spend::detail::sign_action( + view::psbt::sign_action( self.error.as_ref(), &self.hws, self.wallet.signer.as_ref().map(|s| s.fingerprint()), @@ -439,9 +439,9 @@ impl UpdateAction { impl Action for UpdateAction { fn view(&self) -> Element { if self.success { - view::spend::detail::update_spend_success_view() + view::psbt::update_spend_success_view() } else { - view::spend::detail::update_spend_view( + view::psbt::update_spend_view( self.psbt.clone(), &self.updated, self.error.as_ref(), diff --git a/gui/src/app/state/psbts.rs b/gui/src/app/state/psbts.rs index 73f6a413..e6ed2675 100644 --- a/gui/src/app/state/psbts.rs +++ b/gui/src/app/state/psbts.rs @@ -8,7 +8,7 @@ use liana_ui::{ widget::Element, }; -use super::{spend::detail, State}; +use super::{psbt, State}; use crate::{ app::{cache::Cache, error::Error, menu::Menu, message::Message, view, wallet::Wallet}, daemon::{model::SpendTx, Daemon}, @@ -16,7 +16,7 @@ use crate::{ pub struct PsbtsPanel { wallet: Arc, - selected_tx: Option, + selected_tx: Option, spend_txs: Vec, warning: Option, import_tx: Option, @@ -90,7 +90,7 @@ impl State for PsbtsPanel { } Message::View(view::Message::Select(i)) => { if let Some(tx) = self.spend_txs.get(i) { - let tx = detail::SpendTxState::new(self.wallet.clone(), tx.clone(), true); + let tx = psbt::PsbtState::new(self.wallet.clone(), tx.clone(), true); let cmd = tx.load(daemon); self.selected_tx = Some(tx); return cmd; diff --git a/gui/src/app/state/recovery.rs b/gui/src/app/state/recovery.rs index 05f39b55..7d62568b 100644 --- a/gui/src/app/state/recovery.rs +++ b/gui/src/app/state/recovery.rs @@ -12,7 +12,7 @@ use crate::{ error::Error, menu::Menu, message::Message, - state::spend::detail, + state::psbt, state::{redirect, State}, view, wallet::Wallet, @@ -32,7 +32,7 @@ pub struct RecoveryPanel { warning: Option, feerate: form::Value, recipient: form::Value, - generated: Option, + generated: Option, } impl RecoveryPanel { @@ -102,7 +102,7 @@ impl State for RecoveryPanel { }, Message::Recovery(res) => match res { Ok(tx) => { - self.generated = Some(detail::SpendTxState::new(self.wallet.clone(), tx, false)) + self.generated = Some(psbt::PsbtState::new(self.wallet.clone(), tx, false)) } Err(e) => self.warning = Some(e), }, diff --git a/gui/src/app/state/spend/mod.rs b/gui/src/app/state/spend/mod.rs index fda7f111..ce4ccc6e 100644 --- a/gui/src/app/state/spend/mod.rs +++ b/gui/src/app/state/spend/mod.rs @@ -1,4 +1,3 @@ -pub mod detail; mod step; use std::sync::Arc; diff --git a/gui/src/app/state/spend/step.rs b/gui/src/app/state/spend/step.rs index a85a792d..206c9be9 100644 --- a/gui/src/app/state/spend/step.rs +++ b/gui/src/app/state/spend/step.rs @@ -13,9 +13,7 @@ use liana::{ use liana_ui::{component::form, widget::Element}; use crate::{ - app::{ - cache::Cache, error::Error, message::Message, state::spend::detail, view, wallet::Wallet, - }, + app::{cache::Cache, error::Error, message::Message, state::psbt, view, wallet::Wallet}, daemon::{ model::{remaining_sequence, Coin, SpendTx}, Daemon, @@ -286,7 +284,7 @@ impl Step for DefineSpend { } fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { - view::spend::step::create_spend_tx( + view::spend::create_spend_tx( cache, &self.balance_available, self.recipients @@ -382,13 +380,13 @@ impl Recipient { } fn view(&self, i: usize) -> Element { - view::spend::step::recipient_view(i, &self.address, &self.amount) + view::spend::recipient_view(i, &self.address, &self.amount) } } pub struct SaveSpend { wallet: Arc, - spend: Option, + spend: Option, } impl SaveSpend { @@ -408,7 +406,7 @@ impl Step for SaveSpend { .main_descriptor .partial_spend_info(&psbt) .unwrap(); - self.spend = Some(detail::SpendTxState::new( + self.spend = Some(psbt::PsbtState::new( self.wallet.clone(), SpendTx::new(None, psbt, draft.inputs.clone(), sigs), false, diff --git a/gui/src/app/view/mod.rs b/gui/src/app/view/mod.rs index b2e957f9..c7c3d787 100644 --- a/gui/src/app/view/mod.rs +++ b/gui/src/app/view/mod.rs @@ -4,6 +4,7 @@ mod warning; pub mod coins; pub mod home; pub mod hw; +pub mod psbt; pub mod psbts; pub mod receive; pub mod recovery; diff --git a/gui/src/app/view/spend/detail.rs b/gui/src/app/view/pbst.rs similarity index 98% rename from gui/src/app/view/spend/detail.rs rename to gui/src/app/view/pbst.rs index 70925c60..32850458 100644 --- a/gui/src/app/view/spend/detail.rs +++ b/gui/src/app/view/pbst.rs @@ -29,22 +29,25 @@ use liana_ui::{ use crate::{ app::{ + cache::Cache, error::Error, - view::{hw::hw_list_view, message::*, warning::warn}, + menu::Menu, + view::{dashboard, hw::hw_list_view, message::*, warning::warn}, }, daemon::model::{Coin, SpendStatus, SpendTx}, hw::HardwareWallet, }; -pub fn spend_view<'a>( +pub fn psbt_view<'a>( + cache: &'a Cache, tx: &'a SpendTx, - saved: bool, desc_info: &'a LianaPolicy, key_aliases: &'a HashMap, network: Network, ) -> Element<'a, Message> { - spend_modal( - saved, + dashboard( + &Menu::PSBTs, + &cache, None, Column::new() .align_items(Alignment::Center) @@ -197,7 +200,7 @@ pub fn spend_modal<'a, T: Into>>( .into() } -fn spend_header<'a>(tx: &SpendTx) -> Element<'a, Message> { +pub fn spend_header<'a>(tx: &SpendTx) -> Element<'a, Message> { Column::new() .spacing(20) .align_items(Alignment::Center) @@ -233,7 +236,7 @@ fn spend_header<'a>(tx: &SpendTx) -> Element<'a, Message> { .into() } -fn spend_overview_view<'a>( +pub fn spend_overview_view<'a>( tx: &'a SpendTx, desc_info: &'a LianaPolicy, key_aliases: &'a HashMap, @@ -561,7 +564,7 @@ pub fn inputs_and_outputs_view<'a>( coins .iter() .fold(Column::new(), |col: Column<'a, Message>, coin| { - col.push(separation().width(Length::Fill)).push( + col.push( Row::new() .padding(15) .align_items(Alignment::Center) @@ -641,7 +644,7 @@ pub fn inputs_and_outputs_view<'a>( .enumerate() .fold(Column::new(), |col: Column<'a, Message>, (i, output)| { let addr = Address::from_script(&output.script_pubkey, network).unwrap(); - col.push(separation().width(Length::Fill)).push( + col.push( Column::new() .padding(15) .width(Length::Fill) diff --git a/gui/src/app/view/psbt.rs b/gui/src/app/view/psbt.rs new file mode 100644 index 00000000..9d2a7731 --- /dev/null +++ b/gui/src/app/view/psbt.rs @@ -0,0 +1,836 @@ +use std::collections::{HashMap, HashSet}; + +use iced::{ + widget::{scrollable, tooltip, Space}, + Alignment, Length, +}; + +use liana::{ + descriptors::{LianaPolicy, PathInfo, PathSpendInfo}, + miniscript::bitcoin::{ + util::bip32::{DerivationPath, Fingerprint}, + Address, Amount, Network, Transaction, + }, +}; + +use liana_ui::{ + color, + component::{ + amount::*, + badge, button, card, + collapse::Collapse, + form, hw, separation, + text::{text, Text}, + }, + icon, theme, + util::Collection, + widget::*, +}; + +use crate::{ + app::{ + cache::Cache, + error::Error, + view::{dashboard, hw::hw_list_view, message::*, warning::warn}, + }, + daemon::model::{Coin, SpendStatus, SpendTx}, + hw::HardwareWallet, +}; + +pub fn spend_view<'a>( + cache: &'a Cache, + tx: &'a SpendTx, + _saved: bool, + desc_info: &'a LianaPolicy, + key_aliases: &'a HashMap, + network: Network, +) -> Element<'a, Message> { + dashboard( + &crate::app::menu::Menu::CreateSpendTx, + &cache, + None, + Column::new() + .align_items(Alignment::Center) + .spacing(20) + .push(spend_header(tx)) + .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, + )), + ) +} + +pub fn save_action<'a>(warning: Option<&Error>, saved: bool) -> Element<'a, Message> { + if saved { + card::simple(text("Transaction is saved")) + .width(Length::Units(400)) + .align_x(iced::alignment::Horizontal::Center) + .into() + } else { + card::simple( + Column::new() + .spacing(10) + .push_maybe(warning.map(|w| warn(Some(w)))) + .push(text("Save the transaction as draft")) + .push( + Row::new() + .push(Column::new().width(Length::Fill)) + .push(button::alert(None, "Ignore").on_press(Message::Close)) + .push( + button::primary(None, "Save") + .on_press(Message::Spend(SpendTxMessage::Confirm)), + ), + ), + ) + .width(Length::Units(400)) + .into() + } +} + +pub fn broadcast_action<'a>(warning: Option<&Error>, saved: bool) -> Element<'a, Message> { + if saved { + card::simple(text("Transaction is broadcast")) + .width(Length::Units(400)) + .align_x(iced::alignment::Horizontal::Center) + .into() + } else { + card::simple( + Column::new() + .spacing(10) + .push_maybe(warning.map(|w| warn(Some(w)))) + .push(text("Broadcast the transaction")) + .push( + Row::new().push(Column::new().width(Length::Fill)).push( + button::primary(None, "Broadcast") + .on_press(Message::Spend(SpendTxMessage::Confirm)), + ), + ), + ) + .width(Length::Units(400)) + .into() + } +} + +pub fn delete_action<'a>(warning: Option<&Error>, deleted: bool) -> Element<'a, Message> { + if deleted { + card::simple( + Column::new() + .spacing(20) + .align_items(Alignment::Center) + .push(text("Transaction is deleted")) + .push(button::primary(None, "Go back to drafts").on_press(Message::Close)), + ) + .align_x(iced::alignment::Horizontal::Center) + .width(Length::Units(400)) + .into() + } else { + card::simple( + Column::new() + .spacing(10) + .push_maybe(warning.map(|w| warn(Some(w)))) + .push(text("Delete the transaction draft")) + .push( + Row::new() + .push(Column::new().width(Length::Fill)) + .push( + button::transparent(None, "Cancel") + .on_press(Message::Spend(SpendTxMessage::Cancel)), + ) + .push( + button::alert(None, "Delete") + .on_press(Message::Spend(SpendTxMessage::Confirm)), + ), + ), + ) + .width(Length::Units(400)) + .into() + } +} + +pub fn spend_modal<'a, T: Into>>( + saved: bool, + warning: Option<&Error>, + content: T, +) -> Element<'a, Message> { + Column::new() + .push(warn(warning)) + .push( + Container::new( + Row::new() + .push(if saved { + Column::new() + .push( + button::alert(Some(icon::trash_icon()), "Delete") + .on_press(Message::Spend(SpendTxMessage::Delete)), + ) + .width(Length::Fill) + } else { + Column::new() + .push( + button::transparent(None, "< Previous").on_press(Message::Previous), + ) + .width(Length::Fill) + }) + .align_items(iced::Alignment::Center) + .push(if saved { + button::primary(Some(icon::cross_icon()), "Close").on_press(Message::Close) + } else { + button::primary(Some(icon::cross_icon()), "Close") + .on_press(Message::Spend(SpendTxMessage::Save)) + }), + ) + .padding(10) + .style(theme::Container::Background), + ) + .push( + Container::new(scrollable( + Container::new(Container::new(content).max_width(800)) + .width(Length::Fill) + .center_x(), + )) + .height(Length::Fill) + .style(theme::Container::Background), + ) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +pub fn spend_header<'a>(tx: &SpendTx) -> Element<'a, Message> { + Column::new() + .spacing(20) + .align_items(Alignment::Center) + .push( + Row::new() + .push(badge::Badge::new(icon::send_icon()).style(theme::Badge::Standard)) + .push(if !tx.sigs.recovery_paths().is_empty() { + text("Recovery").bold() + } else if tx.spend_amount == Amount::from_sat(0) { + text("Self send").bold() + } else { + text("Spend").bold() + }) + .spacing(5) + .align_items(Alignment::Center), + ) + .push_maybe(match tx.status { + SpendStatus::Deprecated => Some(badge::deprecated()), + SpendStatus::Broadcast => Some(badge::unconfirmed()), + SpendStatus::Spent => Some(badge::spent()), + _ => None, + }) + .push( + Column::new() + .align_items(Alignment::Center) + .push(amount_with_size(&tx.spend_amount, 50)) + .push( + Row::new() + .push(text("Miner fee: ")) + .push(amount(&tx.fee_amount)), + ), + ) + .into() +} + +pub fn spend_overview_view<'a>( + tx: &'a SpendTx, + desc_info: &'a LianaPolicy, + key_aliases: &'a HashMap, +) -> Element<'a, Message> { + Container::new( + Column::new() + .push( + Column::new() + .padding(15) + .spacing(10) + .push( + Row::new() + .align_items(Alignment::Center) + .push(text("PSBT:").bold().width(Length::Fill)) + .push( + Row::new() + .spacing(5) + .push( + button::secondary(Some(icon::clipboard_icon()), "Copy") + .on_press(Message::Clipboard(tx.psbt.to_string())), + ) + .push( + button::secondary(Some(icon::import_icon()), "Update") + .on_press(Message::Spend(SpendTxMessage::EditPsbt)), + ), + ) + .align_items(Alignment::Center), + ) + .push( + Row::new() + .push(text("Tx ID:").bold().width(Length::Fill)) + .push(text(tx.psbt.unsigned_tx.txid().to_string()).small()) + .push( + Button::new(icon::clipboard_icon()) + .on_press(Message::Clipboard( + tx.psbt.unsigned_tx.txid().to_string(), + )) + .style(theme::Button::TransparentBorder), + ) + .align_items(Alignment::Center), + ), + ) + .push(signatures(tx, desc_info, key_aliases)), + ) + .style(theme::Container::Card(theme::Card::Simple)) + .into() +} + +pub fn signatures<'a>( + tx: &'a SpendTx, + desc_info: &'a LianaPolicy, + keys_aliases: &'a HashMap, +) -> Element<'a, Message> { + Column::new() + .push( + if let Some(sigs) = tx.path_ready() { + Container::new( + scrollable( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push(icon::circle_check_icon().style(color::GREEN)) + .push(text("Ready").bold().style(color::GREEN)) + .push(text(", signed by")) + .push( + sigs.signed_pubkeys + .keys() + .fold(Row::new().spacing(5), |row, value| { + row.push(if let Some(alias) = keys_aliases.get(&value.0) { + Container::new( + tooltip::Tooltip::new( + Container::new(text(alias)) + .padding(3) + .style(theme::Container::Pill(theme::Pill::Simple)), + value.0.to_string(), + tooltip::Position::Bottom, + ) + .style(theme::Container::Card(theme::Card::Simple)), + ) + } else { + Container::new(text(value.0.to_string())) + .padding(3) + .style(theme::Container::Pill(theme::Pill::Simple)) + }) + }), + ) + ).horizontal_scroll(scrollable::Properties::new().width(2).scroller_width(2)) + ).padding(15) + } else{ + Container::new( + Collapse::new( + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push(Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push(icon::circle_cross_icon()) + .push(text("Not ready").bold()) + .width(Length::Fill) + ) + .push(icon::collapse_icon()), + ) + .padding(15) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push(icon::circle_cross_icon()) + .push(text("Not ready").bold()) + .width(Length::Fill) + ) + .push(icon::collapsed_icon()), + ) + .padding(15) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + Into::>::into( + Column::new().push(separation().width(Length::Fill)).push( + Column::new() + .padding(15) + .spacing(10) + .push(text(if !tx.sigs.recovery_paths().is_empty() { + "Multiple spending paths available. Finalizing this transaction requires either:" + } else { + "1 spending path available. Finalizing this transaction requires:" + })) + .push(path_view( + desc_info.primary_path(), + tx.sigs.primary_path(), + keys_aliases, + )) + .push(tx.sigs.recovery_paths().iter().fold(Column::new().spacing(10), |col, (seq, path)| { + let keys = &desc_info.recovery_paths()[seq]; + col.push(path_view(keys, path, keys_aliases)) + })), + ), + ) + }, + ))}) + .push_maybe(if tx.status == SpendStatus::Pending { + Some( + Column::new().push(separation().width(Length::Fill)).push( + Container::new( + Row::new() + .push(Space::with_width(Length::Fill)) + .push_maybe(if tx.path_ready().is_none() { + Some( + button::primary(None, "Sign") + .on_press(Message::Spend(SpendTxMessage::Sign)) + .width(Length::Units(150)), + ) + } else { + Some( + button::primary(None, "Broadcast") + .on_press(Message::Spend(SpendTxMessage::Broadcast)) + .width(Length::Units(150)), + ) + }) + .align_items(Alignment::Center) + .spacing(20), + ) + .padding(15), + ), + ) + } else { + None + }) + .into() +} + +pub fn path_view<'a>( + path: &'a PathInfo, + sigs: &'a PathSpendInfo, + key_aliases: &'a HashMap, +) -> Element<'a, Message> { + let mut keys: Vec<(Fingerprint, DerivationPath)> = + path.thresh_origins().1.into_iter().collect(); + let missing_signatures = if sigs.sigs_count >= sigs.threshold { + 0 + } else { + sigs.threshold - sigs.sigs_count + }; + keys.sort(); + scrollable( + Row::new() + .align_items(Alignment::Center) + .push(if sigs.sigs_count >= sigs.threshold { + icon::circle_check_icon().style(color::GREEN) + } else { + icon::circle_cross_icon() + }) + .push(text(format!(" {}", missing_signatures)).bold()) + .push(text(format!( + " more signature{}", + if missing_signatures > 1 { + "s from " + } else if missing_signatures == 0 { + "" + } else { + " from " + } + ))) + .push_maybe(if keys.is_empty() { + None + } else { + Some(keys.iter().fold(Row::new().spacing(5), |row, value| { + row.push_maybe(if !sigs.signed_pubkeys.contains_key(value) { + Some(if let Some(alias) = key_aliases.get(&value.0) { + Container::new( + tooltip::Tooltip::new( + Container::new(text(alias)) + .padding(3) + .style(theme::Container::Pill(theme::Pill::Simple)), + value.0.to_string(), + tooltip::Position::Bottom, + ) + .style(theme::Container::Card(theme::Card::Simple)), + ) + } else { + Container::new(text(value.0.to_string())) + .padding(3) + .style(theme::Container::Pill(theme::Pill::Simple)) + }) + } else { + None + }) + })) + }) + .push_maybe(if sigs.signed_pubkeys.is_empty() { + None + } else { + Some(text(", already signed by ")) + }) + .push( + sigs.signed_pubkeys + .keys() + .fold(Row::new().spacing(5), |row, value| { + row.push(if let Some(alias) = key_aliases.get(&value.0) { + Container::new( + tooltip::Tooltip::new( + Container::new(text(alias)) + .padding(3) + .style(theme::Container::Pill(theme::Pill::Simple)), + value.0.to_string(), + tooltip::Position::Bottom, + ) + .style(theme::Container::Card(theme::Card::Simple)), + ) + } else { + Container::new(text(value.0.to_string())) + .padding(3) + .style(theme::Container::Pill(theme::Pill::Simple)) + }) + }), + ), + ) + .horizontal_scroll(scrollable::Properties::new().width(2).scroller_width(2)) + .into() +} + +pub fn inputs_and_outputs_view<'a>( + coins: &'a [Coin], + tx: &'a Transaction, + network: Network, + change_indexes: Option>, + receive_indexes: Option>, +) -> Element<'a, Message> { + Column::new() + .push( + Column::new() + .spacing(10) + .push_maybe(if !coins.is_empty() { + Some( + Container::new(Collapse::new( + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push( + text(format!( + "{} spent coin{}", + coins.len(), + if coins.len() == 1 { "" } else { "s" } + )) + .bold() + .width(Length::Fill), + ) + .push(icon::collapse_icon()), + ) + .padding(15) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push( + text(format!( + "{} spent coin{}", + coins.len(), + if coins.len() == 1 { "" } else { "s" } + )) + .bold() + .width(Length::Fill), + ) + .push(icon::collapsed_icon()), + ) + .padding(15) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + coins + .iter() + .fold(Column::new(), |col: Column<'a, Message>, coin| { + col.push( + Row::new() + .padding(15) + .align_items(Alignment::Center) + .width(Length::Fill) + .push( + Row::new() + .width(Length::Fill) + .align_items(Alignment::Center) + .push( + text(coin.outpoint.to_string()) + .small() + ) + .push( + Button::new(icon::clipboard_icon()) + .on_press(Message::Clipboard( + coin.outpoint.to_string(), + )) + .style( + theme::Button::TransparentBorder, + ), + ), + ) + .push(amount(&coin.amount)), + ) + }) + .into() + }, + )) + .style(theme::Container::Card(theme::Card::Simple)), + ) + } else { + None + }) + .push( + Container::new(Collapse::new( + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push( + text(format!( + "{} recipient{}", + tx.output.len(), + if tx.output.len() == 1 { "" } else { "s" } + )) + .bold() + .width(Length::Fill), + ) + .push(icon::collapse_icon()), + ) + .padding(15) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push( + text(format!( + "{} recipient{}", + tx.output.len(), + if tx.output.len() == 1 { "" } else { "s" } + )) + .bold() + .width(Length::Fill), + ) + .push(icon::collapsed_icon()), + ) + .padding(15) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + tx.output + .iter() + .enumerate() + .fold(Column::new(), |col: Column<'a, Message>, (i, output)| { + let addr = Address::from_script(&output.script_pubkey, network).unwrap(); + col.push( + Column::new() + .padding(15) + .width(Length::Fill) + .spacing(10) + .push( + Row::new() + .width(Length::Fill) + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .push(text(addr.to_string()).small()) + .push( + Button::new(icon::clipboard_icon()) + .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 + }, + ), + ) + }) + .into() + }, + )) + .style(theme::Container::Card(theme::Card::Simple)), + ), + ) + .into() +} + +pub fn sign_action<'a>( + warning: Option<&Error>, + hws: &'a [HardwareWallet], + signer: Option, + signer_alias: Option<&'a String>, + processing: bool, + chosen_hw: Option, + signed: &HashSet, +) -> Element<'a, Message> { + Column::new() + .push_maybe(warning.map(|w| warn(Some(w)))) + .push(card::simple( + Column::new() + .push( + Column::new() + .push( + Row::new() + .push( + text("Select signing device to sign with:") + .bold() + .width(Length::Fill), + ) + .push(button::secondary(None, "Refresh").on_press(Message::Reload)) + .align_items(Alignment::Center), + ) + .spacing(10) + .push(hws.iter().enumerate().fold( + Column::new().spacing(10), + |col, (i, hw)| { + col.push(hw_list_view( + i, + hw, + Some(i) == chosen_hw, + processing, + hw.fingerprint() + .map(|f| signed.contains(&f)) + .unwrap_or(false), + )) + }, + )) + .push_maybe(signer.map(|fingerprint| { + Button::new(if signed.contains(&fingerprint) { + hw::sign_success_hot_signer(fingerprint, signer_alias) + } else { + hw::hot_signer(fingerprint, signer_alias) + }) + .on_press(Message::Spend(SpendTxMessage::SelectHotSigner)) + .padding(10) + .style(theme::Button::Border) + .width(Length::Fill) + })) + .width(Length::Fill), + ) + .spacing(20) + .width(Length::Fill) + .align_items(Alignment::Center), + )) + .width(Length::Units(500)) + .into() +} + +pub fn update_spend_view<'a>( + psbt: String, + updated: &form::Value, + error: Option<&Error>, + processing: bool, +) -> Element<'a, Message> { + Column::new() + .push(warn(error)) + .push(card::simple( + Column::new() + .spacing(20) + .push( + Row::new() + .push(text("PSBT:").bold().width(Length::Fill)) + .push( + button::border(Some(icon::clipboard_icon()), "Copy") + .on_press(Message::Clipboard(psbt)), + ) + .align_items(Alignment::Center), + ) + .push(separation().width(Length::Fill)) + .push( + Column::new() + .spacing(10) + .push(text("Insert updated PSBT:").bold()) + .push( + form::Form::new("PSBT", updated, move |msg| { + Message::ImportSpend(ImportSpendMessage::PsbtEdited(msg)) + }) + .warning("Please enter the correct base64 encoded PSBT") + .size(20) + .padding(10), + ) + .push(Row::new().push(Space::with_width(Length::Fill)).push( + if updated.valid && !updated.value.is_empty() && !processing { + button::primary(None, "Update") + .on_press(Message::ImportSpend(ImportSpendMessage::Confirm)) + } else if processing { + button::primary(None, "Processing...") + } else { + button::primary(None, "Update") + }, + )), + ), + )) + .max_width(400) + .into() +} + +pub fn update_spend_success_view<'a>() -> Element<'a, Message> { + Column::new() + .push( + card::simple(Container::new( + text("Spend transaction is updated").style(color::GREEN), + )) + .padding(50), + ) + .width(Length::Units(400)) + .align_items(Alignment::Center) + .into() +} diff --git a/gui/src/app/view/psbts.rs b/gui/src/app/view/psbts.rs index 6c187fb1..214a0739 100644 --- a/gui/src/app/view/psbts.rs +++ b/gui/src/app/view/psbts.rs @@ -108,16 +108,19 @@ fn spend_tx_list_view<'a>(i: usize, tx: &SpendTx) -> Element<'a, Message> { Row::new() .spacing(5) .align_items(Alignment::Center) - .push(text(format!( - "{}/{}", - if sigs.sigs_count <= sigs.threshold { - sigs.sigs_count - } else { + .push( + p2_regular(format!( + "{}/{}", + if sigs.sigs_count <= sigs.threshold { + sigs.sigs_count + } else { + sigs.threshold + }, sigs.threshold - }, - sigs.threshold - ))) - .push(icon::key_icon()) + )) + .style(color::GREY_3), + ) + .push(icon::key_icon().style(color::GREY_3)) }) .spacing(10) .align_items(Alignment::Center) @@ -132,7 +135,7 @@ fn spend_tx_list_view<'a>(i: usize, tx: &SpendTx) -> Element<'a, Message> { .push( Column::new() .push(amount(&tx.spend_amount)) - .push(text(format!("fee: {:8}", tx.fee_amount.to_btc())).small()) + .push(amount_with_size(&tx.fee_amount, P2_SIZE)) .width(Length::Shrink), ) .align_items(Alignment::Center) diff --git a/gui/src/app/view/spend/mod.rs b/gui/src/app/view/spend/mod.rs index 0536ceab..8dd63617 100644 --- a/gui/src/app/view/spend/mod.rs +++ b/gui/src/app/view/spend/mod.rs @@ -1,2 +1,281 @@ -pub mod detail; -pub mod step; +use std::collections::HashMap; + +use iced::{ + alignment, + widget::{checkbox, scrollable, Space}, + Alignment, Length, +}; + +use liana::{ + descriptors::LianaPolicy, + miniscript::bitcoin::{util::bip32::Fingerprint, Amount, Network}, +}; + +use liana_ui::{ + color, + component::{amount::*, badge, button, form, text::*}, + icon, theme, + util::Collection, + widget::*, +}; + +use crate::{ + app::{ + cache::Cache, + error::Error, + menu::Menu, + view::{coins, dashboard, message::*, psbt}, + }, + daemon::model::{remaining_sequence, Coin, SpendTx}, +}; + +pub fn spend_view<'a>( + cache: &'a Cache, + tx: &'a SpendTx, + _saved: bool, + desc_info: &'a LianaPolicy, + key_aliases: &'a HashMap, + network: Network, +) -> Element<'a, Message> { + dashboard( + &Menu::CreateSpendTx, + &cache, + None, + Column::new() + .align_items(Alignment::Center) + .spacing(20) + .push(psbt::spend_header(tx)) + .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, + )), + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn create_spend_tx<'a>( + cache: &'a Cache, + balance_available: &'a Amount, + recipients: Vec>, + total_amount: Amount, + is_valid: bool, + duplicate: bool, + timelock: u16, + coins: &[(Coin, bool)], + amount_left: Option<&Amount>, + feerate: &form::Value, + error: Option<&Error>, +) -> Element<'a, Message> { + dashboard( + &Menu::CreateSpendTx, + cache, + error, + Column::new() + .push(h3("Send")) + .push( + Column::new() + .push(Column::with_children(recipients).spacing(10)) + .push( + Row::new() + .push_maybe(if duplicate { + Some( + Container::new( + text("Two recipient addresses are the same") + .style(color::RED), + ) + .padding(10), + ) + } else { + None + }) + .push(Space::with_width(Length::Fill)) + .push( + button::secondary(Some(icon::plus_icon()), "Add recipient") + .on_press(Message::CreateSpend( + CreateSpendMessage::AddRecipient, + )), + ), + ) + .spacing(20), + ) + .push( + Row::new() + .push( + Row::new() + .push(Container::new(p1_bold("Fee rate")).padding(10)) + .spacing(10) + .push( + form::Form::new("Feerate (sat/vbyte)", feerate, move |msg| { + Message::CreateSpend(CreateSpendMessage::FeerateEdited(msg)) + }) + .warning("Invalid feerate") + .size(20) + .padding(10), + ) + .width(Length::FillPortion(1)), + ) + .push(Space::with_width(Length::FillPortion(1))), + ) + .push( + Container::new( + Column::new() + .spacing(10) + .push( + Row::new() + .align_items(Alignment::Center) + .push(p1_bold("Coins selection").width(Length::Fill)) + .push(Container::new(if let Some(amount_left) = amount_left { + Row::new() + .spacing(5) + .push(amount_with_size(amount_left, P2_SIZE)) + .push(p2_regular("left to select").style(color::GREY_3)) + } else { + Row::new() + .push(text("Feerate needs to be set.").style(color::GREY_3)) + })) + .width(Length::Fill), + ) + .push( + Container::new(scrollable(coins.iter().enumerate().fold( + Column::new().spacing(10), + |col, (i, (coin, selected))| { + col.push(coin_list_view( + i, + coin, + timelock, + cache.blockheight as u32, + *selected, + )) + }, + ))) + .max_height(300), + ), + ) + .padding(20) + .style(theme::Card::Simple), + ) + .push( + Row::new() + .spacing(20) + .align_items(Alignment::Center) + .push(Space::with_width(Length::Fill)) + .push( + button::primary(None, "Clear") + .on_press(Message::Menu(Menu::CreateSpendTx)) + .width(Length::Units(100)), + ) + .push( + if is_valid + && total_amount < *balance_available + && Some(&Amount::from_sat(0)) == amount_left + { + button::primary(None, "Next") + .on_press(Message::CreateSpend(CreateSpendMessage::Generate)) + .width(Length::Units(100)) + } else { + button::primary(None, "Next").width(Length::Units(100)) + }, + ), + ) + .push(Space::with_height(Length::Units(20))) + .spacing(20), + ) +} + +pub fn recipient_view<'a>( + index: usize, + address: &form::Value, + amount: &form::Value, +) -> Element<'a, CreateSpendMessage> { + Container::new( + Column::new() + .spacing(10) + .push( + Row::new().push(Space::with_width(Length::Fill)).push( + Button::new(icon::cross_icon()) + .style(theme::Button::Transparent) + .on_press(CreateSpendMessage::DeleteRecipient(index)) + .width(Length::Shrink), + ), + ) + .push( + Row::new() + .align_items(Alignment::Start) + .spacing(10) + .push( + Container::new(p1_bold("Pay to")) + .align_x(alignment::Horizontal::Right) + .padding(10) + .width(Length::Units(80)), + ) + .push( + form::Form::new("Address", address, move |msg| { + CreateSpendMessage::RecipientEdited(index, "address", msg) + }) + .warning("Invalid address (maybe it is for another network?)") + .size(20) + .padding(10), + ), + ) + .push( + Row::new() + .align_items(Alignment::Start) + .spacing(10) + .push( + Container::new(p1_bold("Amount")) + .padding(10) + .align_x(alignment::Horizontal::Right) + .width(Length::Units(80)), + ) + .push( + form::Form::new("ex: 0.001", amount, move |msg| { + CreateSpendMessage::RecipientEdited(index, "amount", msg) + }) + .warning("Invalid amount. Must be > 0.00005000 BTC.") + .size(20) + .padding(10), + ) + .width(Length::Fill), + ), + ) + .padding(20) + .style(theme::Card::Simple) + .into() +} + +fn coin_list_view<'a>( + i: usize, + coin: &Coin, + timelock: u16, + blockheight: u32, + selected: bool, +) -> Element<'a, Message> { + Row::new() + .push( + Row::new() + .push(checkbox("", selected, move |_| { + Message::CreateSpend(CreateSpendMessage::SelectCoin(i)) + })) + .push(if coin.spend_info.is_some() { + badge::spent() + } else if coin.block_height.is_none() { + badge::unconfirmed() + } else { + let seq = remaining_sequence(coin, blockheight, timelock); + coins::coin_sequence_label(seq, timelock as u32) + }) + .spacing(10) + .align_items(Alignment::Center) + .width(Length::Fill), + ) + .push(amount(&coin.amount)) + // give some space for the scroll bar without using padding + .push(Space::with_width(Length::Units(0))) + .align_items(Alignment::Center) + .spacing(20) + .into() +} diff --git a/gui/src/app/view/spend/step.rs b/gui/src/app/view/spend/step.rs deleted file mode 100644 index 9fc5d720..00000000 --- a/gui/src/app/view/spend/step.rs +++ /dev/null @@ -1,249 +0,0 @@ -use iced::{ - alignment, - widget::{checkbox, scrollable, Space}, - Alignment, Length, -}; - -use liana::miniscript::bitcoin::Amount; - -use liana_ui::{ - color, - component::{amount::*, badge, button, form, text::*}, - icon, theme, - util::Collection, - widget::*, -}; - -use crate::{ - app::{ - cache::Cache, - error::Error, - menu::Menu, - view::{coins, dashboard, message::*}, - }, - daemon::model::{remaining_sequence, Coin}, -}; - -#[allow(clippy::too_many_arguments)] -pub fn create_spend_tx<'a>( - cache: &'a Cache, - balance_available: &'a Amount, - recipients: Vec>, - total_amount: Amount, - is_valid: bool, - duplicate: bool, - timelock: u16, - coins: &[(Coin, bool)], - amount_left: Option<&Amount>, - feerate: &form::Value, - error: Option<&Error>, -) -> Element<'a, Message> { - dashboard( - &Menu::CreateSpendTx, - cache, - error, - Column::new() - .push(h3("Send")) - .push( - Column::new() - .push(Column::with_children(recipients).spacing(10)) - .push( - Row::new() - .push_maybe(if duplicate { - Some( - Container::new( - text("Two recipient addresses are the same") - .style(color::RED), - ) - .padding(10), - ) - } else { - None - }) - .push(Space::with_width(Length::Fill)) - .push( - button::secondary(Some(icon::plus_icon()), "Add recipient") - .on_press(Message::CreateSpend( - CreateSpendMessage::AddRecipient, - )), - ), - ) - .spacing(20), - ) - .push( - Row::new() - .push( - Row::new() - .push(Container::new(p1_bold("Fee rate")).padding(10)) - .spacing(10) - .push( - form::Form::new("Feerate (sat/vbyte)", feerate, move |msg| { - Message::CreateSpend(CreateSpendMessage::FeerateEdited(msg)) - }) - .warning("Invalid feerate") - .size(20) - .padding(10), - ) - .width(Length::FillPortion(1)), - ) - .push(Space::with_width(Length::FillPortion(1))), - ) - .push( - Container::new( - Column::new() - .spacing(10) - .push( - Row::new() - .align_items(Alignment::Center) - .push(p1_bold("Coins selection").width(Length::Fill)) - .push(Container::new(if let Some(amount_left) = amount_left { - Row::new() - .spacing(5) - .push(amount_with_size(amount_left, P2_SIZE)) - .push(p2_regular("left to select").style(color::GREY_3)) - } else { - Row::new() - .push(text("Feerate needs to be set.").style(color::GREY_3)) - })) - .width(Length::Fill), - ) - .push( - Container::new(scrollable(coins.iter().enumerate().fold( - Column::new().spacing(10), - |col, (i, (coin, selected))| { - col.push(coin_list_view( - i, - coin, - timelock, - cache.blockheight as u32, - *selected, - )) - }, - ))) - .max_height(300), - ), - ) - .padding(20) - .style(theme::Card::Simple), - ) - .push( - Row::new() - .spacing(20) - .align_items(Alignment::Center) - .push(Space::with_width(Length::Fill)) - .push( - button::primary(None, "Clear") - .on_press(Message::Menu(Menu::CreateSpendTx)) - .width(Length::Units(100)), - ) - .push( - if is_valid - && total_amount < *balance_available - && Some(&Amount::from_sat(0)) == amount_left - { - button::primary(None, "Next") - .on_press(Message::CreateSpend(CreateSpendMessage::Generate)) - .width(Length::Units(100)) - } else { - button::primary(None, "Next").width(Length::Units(100)) - }, - ), - ) - .push(Space::with_height(Length::Units(20))) - .spacing(20), - ) -} - -pub fn recipient_view<'a>( - index: usize, - address: &form::Value, - amount: &form::Value, -) -> Element<'a, CreateSpendMessage> { - Container::new( - Column::new() - .spacing(10) - .push( - Row::new().push(Space::with_width(Length::Fill)).push( - Button::new(icon::cross_icon()) - .style(theme::Button::Transparent) - .on_press(CreateSpendMessage::DeleteRecipient(index)) - .width(Length::Shrink), - ), - ) - .push( - Row::new() - .align_items(Alignment::Start) - .spacing(10) - .push( - Container::new(p1_bold("Pay to")) - .align_x(alignment::Horizontal::Right) - .padding(10) - .width(Length::Units(80)), - ) - .push( - form::Form::new("Address", address, move |msg| { - CreateSpendMessage::RecipientEdited(index, "address", msg) - }) - .warning("Invalid address (maybe it is for another network?)") - .size(20) - .padding(10), - ), - ) - .push( - Row::new() - .align_items(Alignment::Start) - .spacing(10) - .push( - Container::new(p1_bold("Amount")) - .padding(10) - .align_x(alignment::Horizontal::Right) - .width(Length::Units(80)), - ) - .push( - form::Form::new("ex: 0.001", amount, move |msg| { - CreateSpendMessage::RecipientEdited(index, "amount", msg) - }) - .warning("Invalid amount. Must be > 0.00005000 BTC.") - .size(20) - .padding(10), - ) - .width(Length::Fill), - ), - ) - .padding(20) - .style(theme::Card::Simple) - .into() -} - -fn coin_list_view<'a>( - i: usize, - coin: &Coin, - timelock: u16, - blockheight: u32, - selected: bool, -) -> Element<'a, Message> { - Row::new() - .push( - Row::new() - .push(checkbox("", selected, move |_| { - Message::CreateSpend(CreateSpendMessage::SelectCoin(i)) - })) - .push(if coin.spend_info.is_some() { - badge::spent() - } else if coin.block_height.is_none() { - badge::unconfirmed() - } else { - let seq = remaining_sequence(coin, blockheight, timelock); - coins::coin_sequence_label(seq, timelock as u32) - }) - .spacing(10) - .align_items(Alignment::Center) - .width(Length::Fill), - ) - .push(amount(&coin.amount)) - // give some space for the scroll bar without using padding - .push(Space::with_width(Length::Units(0))) - .align_items(Alignment::Center) - .spacing(20) - .into() -} diff --git a/gui/src/app/view/transactions.rs b/gui/src/app/view/transactions.rs index 57a1a275..e2ac8f01 100644 --- a/gui/src/app/view/transactions.rs +++ b/gui/src/app/view/transactions.rs @@ -176,7 +176,7 @@ pub fn tx_view<'a>(cache: &Cache, tx: &'a HistoryTransaction) -> Element<'a, Mes ) .spacing(5), )) - .push(super::spend::detail::inputs_and_outputs_view( + .push(super::psbt::inputs_and_outputs_view( &tx.coins, &tx.tx, cache.network,