From 95aa8a152993a43dc2451c45efcfe73d68f4c45b Mon Sep 17 00:00:00 2001 From: edouard Date: Thu, 27 Oct 2022 10:52:29 +0200 Subject: [PATCH 1/8] Add spend txs to app cache --- gui/src/app/cache.rs | 3 +- gui/src/app/menu.rs | 1 + gui/src/app/mod.rs | 1 + gui/src/app/state/coins.rs | 3 +- gui/src/app/state/mod.rs | 14 +++- gui/src/app/state/settings.rs | 1 + gui/src/app/view/mod.rs | 147 +++++++++++++++++++++++++++++++--- gui/src/app/view/settings.rs | 4 +- gui/src/daemon/client/mod.rs | 4 + gui/src/daemon/embedded.rs | 11 +++ gui/src/daemon/mod.rs | 2 + gui/src/daemon/model.rs | 6 +- gui/src/loader.rs | 15 +++- gui/src/main.rs | 3 +- gui/src/ui/component/badge.rs | 24 ++++++ 15 files changed, 216 insertions(+), 23 deletions(-) diff --git a/gui/src/app/cache.rs b/gui/src/app/cache.rs index 39ede414..146afeba 100644 --- a/gui/src/app/cache.rs +++ b/gui/src/app/cache.rs @@ -1,7 +1,8 @@ -use crate::daemon::model::Coin; +use crate::daemon::model::{Coin, SpendTx}; #[derive(Default)] pub struct Cache { pub blockheight: i32, pub coins: Vec, + pub spend_txs: Vec, } diff --git a/gui/src/app/menu.rs b/gui/src/app/menu.rs index 8f1ba2f8..1fca2c30 100644 --- a/gui/src/app/menu.rs +++ b/gui/src/app/menu.rs @@ -2,6 +2,7 @@ pub enum Menu { Home, Receive, + Spend, Settings, Coins, } diff --git a/gui/src/app/mod.rs b/gui/src/app/mod.rs index 53fc656e..a8593d4a 100644 --- a/gui/src/app/mod.rs +++ b/gui/src/app/mod.rs @@ -65,6 +65,7 @@ impl App { menu::Menu::Home => Home::new(&self.cache.coins).into(), menu::Menu::Coins => CoinsPanel::new(&self.cache.coins).into(), menu::Menu::Receive => ReceivePanel::default().into(), + menu::Menu::Spend => ReceivePanel::default().into(), }; self.state.load(self.daemon.clone()) } diff --git a/gui/src/app/state/coins.rs b/gui/src/app/state/coins.rs index 14c66770..0b5123c5 100644 --- a/gui/src/app/state/coins.rs +++ b/gui/src/app/state/coins.rs @@ -25,9 +25,10 @@ impl CoinsPanel { } impl State for CoinsPanel { - fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> { + fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { view::dashboard( &Menu::Coins, + cache, self.warning.as_ref(), view::coins::coins_view(&self.coins), ) diff --git a/gui/src/app/state/mod.rs b/gui/src/app/state/mod.rs index 3160de69..2a8f0b65 100644 --- a/gui/src/app/state/mod.rs +++ b/gui/src/app/state/mod.rs @@ -42,8 +42,13 @@ impl Home { } impl State for Home { - fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> { - view::dashboard(&Menu::Home, None, view::home::home_view(&self.balance)) + fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { + view::dashboard( + &Menu::Home, + cache, + None, + view::home::home_view(&self.balance), + ) } fn update( @@ -83,15 +88,16 @@ pub struct ReceivePanel { } impl State for ReceivePanel { - fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> { + fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { if let Some(address) = &self.address { view::dashboard( &Menu::Receive, + cache, self.warning.as_ref(), view::receive::receive(address, self.qr_code.as_ref().unwrap()), ) } else { - view::dashboard(&Menu::Receive, self.warning.as_ref(), column()) + view::dashboard(&Menu::Receive, cache, self.warning.as_ref(), column()) } } fn update( diff --git a/gui/src/app/state/settings.rs b/gui/src/app/state/settings.rs index 3241703b..d9a54b25 100644 --- a/gui/src/app/state/settings.rs +++ b/gui/src/app/state/settings.rs @@ -103,6 +103,7 @@ impl State for SettingsState { fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { let can_edit = self.current.is_none() && !self.daemon_is_external; view::settings::list( + cache, self.warning.as_ref(), self.settings .iter() diff --git a/gui/src/app/view/mod.rs b/gui/src/app/view/mod.rs index 86037f9f..4627e1b1 100644 --- a/gui/src/app/view/mod.rs +++ b/gui/src/app/view/mod.rs @@ -16,13 +16,14 @@ use iced::{ use crate::ui::{ color, - component::{button, separation, text::*}, - icon::{coin_icon, home_icon, receive_icon, settings_icon}, + component::{badge, button, separation, text::*}, + icon::{coin_icon, home_icon, receive_icon, send_icon, settings_icon}, + util::Collection, }; -use crate::app::{error::Error, menu::Menu}; +use crate::app::{cache::Cache, error::Error, menu::Menu}; -pub fn sidebar(menu: &Menu) -> widget::Container { +pub fn sidebar<'a>(menu: &Menu, cache: &'a Cache) -> widget::Container<'a, Message> { let home_button = if *menu == Menu::Home { button::primary(Some(home_icon()), "Home") .on_press(Message::Reload) @@ -34,13 +35,131 @@ pub fn sidebar(menu: &Menu) -> widget::Container { }; let coins_button = if *menu == Menu::Coins { - button::primary(Some(coin_icon()), "Coins") - .on_press(Message::Reload) - .width(iced::Length::Units(200)) + iced::pure::widget::button::Button::new( + container( + row() + .push( + row() + .push(coin_icon()) + .push(text("Coins")) + .spacing(10) + .width(iced::Length::Fill) + .align_items(iced::Alignment::Center), + ) + .push( + container(text(&format!(" {} ", cache.coins.len())).small().bold()) + .style(badge::PillStyle::InversePrimary), + ) + .spacing(10) + .width(iced::Length::Fill) + .align_items(iced::Alignment::Center), + ) + .width(iced::Length::Fill) + .padding(5) + .center_x(), + ) + .style(button::Style::Primary) + .on_press(Message::Reload) + .width(iced::Length::Units(200)) } else { - button::transparent(Some(coin_icon()), "Coins") - .on_press(Message::Menu(Menu::Coins)) - .width(iced::Length::Units(200)) + iced::pure::widget::button::Button::new( + container( + row() + .push( + row() + .push(coin_icon()) + .push(text("Coins")) + .spacing(10) + .width(iced::Length::Fill) + .align_items(iced::Alignment::Center), + ) + .push( + container(text(&format!(" {} ", cache.coins.len())).small().bold()) + .style(badge::PillStyle::Primary), + ) + .spacing(10) + .width(iced::Length::Fill) + .align_items(iced::Alignment::Center), + ) + .width(iced::Length::Fill) + .padding(5) + .center_x(), + ) + .style(button::Style::Transparent) + .on_press(Message::Menu(Menu::Coins)) + .width(iced::Length::Units(200)) + }; + + let spend_button = if *menu == Menu::Spend { + iced::pure::widget::button::Button::new( + container( + row() + .push( + row() + .push(send_icon()) + .push(text("Send")) + .spacing(10) + .width(iced::Length::Fill) + .align_items(iced::Alignment::Center), + ) + .push_maybe(if cache.spend_txs.is_empty() { + None + } else { + Some( + container( + text(&format!(" {} ", cache.spend_txs.len())) + .small() + .bold(), + ) + .style(badge::PillStyle::InversePrimary), + ) + }) + .spacing(10) + .width(iced::Length::Fill) + .align_items(iced::Alignment::Center), + ) + .width(iced::Length::Fill) + .padding(5) + .center_x(), + ) + .style(button::Style::Primary) + .on_press(Message::Reload) + .width(iced::Length::Units(200)) + } else { + iced::pure::widget::button::Button::new( + container( + row() + .push( + row() + .push(coin_icon()) + .push(text("Send")) + .spacing(10) + .width(iced::Length::Fill) + .align_items(iced::Alignment::Center), + ) + .push_maybe(if cache.spend_txs.is_empty() { + None + } else { + Some( + container( + text(&format!(" {} ", cache.spend_txs.len())) + .small() + .bold(), + ) + .style(badge::PillStyle::Primary), + ) + }) + .spacing(10) + .width(iced::Length::Fill) + .align_items(iced::Alignment::Center), + ) + .width(iced::Length::Fill) + .padding(5) + .center_x(), + ) + .style(button::Style::Transparent) + .on_press(Message::Menu(Menu::Spend)) + .width(iced::Length::Units(200)) }; let receive_button = if *menu == Menu::Receive { @@ -76,6 +195,7 @@ pub fn sidebar(menu: &Menu) -> widget::Container { ) .push(home_button) .push(coins_button) + .push(spend_button) .push(receive_button) .spacing(15) .height(Length::Fill), @@ -99,11 +219,16 @@ impl widget::container::StyleSheet for SidebarStyle { pub fn dashboard<'a, T: Into>>( menu: &'a Menu, + cache: &'a Cache, warning: Option<&Error>, content: T, ) -> Element<'a, Message> { row() - .push(sidebar(menu).width(Length::Shrink).height(Length::Fill)) + .push( + sidebar(menu, cache) + .width(Length::Shrink) + .height(Length::Fill), + ) .push( column().push(warn(warning)).push( main_section(container(scrollable(content))) diff --git a/gui/src/app/view/settings.rs b/gui/src/app/view/settings.rs index 0767418b..a8f3223b 100644 --- a/gui/src/app/view/settings.rs +++ b/gui/src/app/view/settings.rs @@ -12,7 +12,7 @@ use super::{ }; use crate::{ - app::{error::Error, menu::Menu}, + app::{cache::Cache, error::Error, menu::Menu}, ui::{ color, component::{badge, button, card, form, separation, text::*}, @@ -21,11 +21,13 @@ use crate::{ }; pub fn list<'a>( + cache: &'a Cache, warning: Option<&Error>, settings: Vec>, ) -> Element<'a, Message> { dashboard( &Menu::Settings, + cache, warning, widget::Column::with_children(settings).spacing(20), ) diff --git a/gui/src/daemon/client/mod.rs b/gui/src/daemon/client/mod.rs index 4db519a9..a378e0ab 100644 --- a/gui/src/daemon/client/mod.rs +++ b/gui/src/daemon/client/mod.rs @@ -70,6 +70,10 @@ impl Daemon for Minisafed { fn list_coins(&self) -> Result { self.call("listcoins", Option::::None) } + + fn list_spend_txs(&self) -> Result { + self.call("listspend", Option::::None) + } } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/gui/src/daemon/embedded.rs b/gui/src/daemon/embedded.rs index 924c1699..1ec3da82 100644 --- a/gui/src/daemon/embedded.rs +++ b/gui/src/daemon/embedded.rs @@ -91,4 +91,15 @@ impl Daemon for EmbeddedDaemon { .control .list_coins()) } + + fn list_spend_txs(&self) -> Result { + Ok(self + .handle + .as_ref() + .ok_or(DaemonError::NoAnswer)? + .lock() + .unwrap() + .control + .list_spend()) + } } diff --git a/gui/src/daemon/mod.rs b/gui/src/daemon/mod.rs index 32e6ab40..7feec6a1 100644 --- a/gui/src/daemon/mod.rs +++ b/gui/src/daemon/mod.rs @@ -49,4 +49,6 @@ pub trait Daemon: Debug { fn get_new_address(&self) -> Result; fn list_coins(&self) -> Result; + + fn list_spend_txs(&self) -> Result; } diff --git a/gui/src/daemon/model.rs b/gui/src/daemon/model.rs index 33178c77..43e0ff84 100644 --- a/gui/src/daemon/model.rs +++ b/gui/src/daemon/model.rs @@ -1,3 +1,7 @@ -pub use minisafe::commands::{GetAddressResult, GetInfoResult, ListCoinsEntry, ListCoinsResult}; +pub use minisafe::commands::{ + GetAddressResult, GetInfoResult, ListCoinsEntry, ListCoinsResult, ListSpendEntry, + ListSpendResult, +}; pub type Coin = ListCoinsEntry; +pub type SpendTx = ListSpendEntry; diff --git a/gui/src/loader.rs b/gui/src/loader.rs index 89988c66..e8123f06 100644 --- a/gui/src/loader.rs +++ b/gui/src/loader.rs @@ -42,7 +42,12 @@ enum Step { pub enum Message { Event(iced_native::Event), Syncing(Result), - Synced(GetInfoResult, Vec, Arc), + Synced( + GetInfoResult, + Vec, + Vec, + Arc, + ), Started(Result, Error>), Loaded(Result, Error>), Failure(DaemonError), @@ -127,9 +132,13 @@ impl Loader { .list_coins() .map(|res| res.coins) .unwrap_or_else(|_| Vec::new()); - (info, coins, daemon) + let spend_txs = daemon + .list_spend_txs() + .map(|res| res.spend_txs) + .unwrap_or_else(|_| Vec::new()); + (info, coins, spend_txs, daemon) }, - |res| Message::Synced(res.0, res.1, res.2), + |res| Message::Synced(res.0, res.1, res.2, res.3), ); } else { *progress = info.sync diff --git a/gui/src/main.rs b/gui/src/main.rs index 807ae5d6..c0980818 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -161,10 +161,11 @@ impl Application for GUI { } } (State::Loader(loader), Message::Load(msg)) => { - if let loader::Message::Synced(info, coins, minisafed) = *msg { + if let loader::Message::Synced(info, coins, spend_txs, minisafed) = *msg { let cache = Cache { blockheight: info.blockheight, coins, + spend_txs, }; let (app, command) = App::new(cache, loader.gui_config.clone(), minisafed); diff --git a/gui/src/ui/component/badge.rs b/gui/src/ui/component/badge.rs index 9a793fb1..ae7c8cc7 100644 --- a/gui/src/ui/component/badge.rs +++ b/gui/src/ui/component/badge.rs @@ -117,3 +117,27 @@ pub fn coin() -> widget::container::Container<'static, T> { .center_x() .center_y() } + +pub enum PillStyle { + InversePrimary, + Primary, +} + +impl widget::container::StyleSheet for PillStyle { + fn style(&self) -> widget::container::Style { + match self { + Self::Primary => widget::container::Style { + background: color::PRIMARY.into(), + border_radius: 10.0, + text_color: iced::Color::WHITE.into(), + ..widget::container::Style::default() + }, + Self::InversePrimary => widget::container::Style { + background: color::FOREGROUND.into(), + border_radius: 10.0, + text_color: color::PRIMARY.into(), + ..widget::container::Style::default() + }, + } + } +} From e1209d2cff01a11053263c2e4934e80c088a83bd Mon Sep 17 00:00:00 2001 From: edouard Date: Thu, 27 Oct 2022 12:12:18 +0200 Subject: [PATCH 2/8] Add spend panel --- gui/src/app/message.rs | 1 + gui/src/app/mod.rs | 7 ++-- gui/src/app/state/mod.rs | 2 ++ gui/src/app/state/spend.rs | 65 ++++++++++++++++++++++++++++++++++++++ gui/src/app/view/mod.rs | 3 +- gui/src/app/view/spend.rs | 59 ++++++++++++++++++++++++++++++++++ 6 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 gui/src/app/state/spend.rs create mode 100644 gui/src/app/view/spend.rs diff --git a/gui/src/app/message.rs b/gui/src/app/message.rs index 9393c34e..8d2eb808 100644 --- a/gui/src/app/message.rs +++ b/gui/src/app/message.rs @@ -15,4 +15,5 @@ pub enum Message { BlockHeight(Result), ReceiveAddress(Result), Coins(Result, Error>), + SpendTxs(Result, Error>), } diff --git a/gui/src/app/mod.rs b/gui/src/app/mod.rs index a8593d4a..85663144 100644 --- a/gui/src/app/mod.rs +++ b/gui/src/app/mod.rs @@ -21,7 +21,7 @@ pub use minisafe::config::Config as DaemonConfig; pub use config::Config; pub use message::Message; -use state::{CoinsPanel, Home, ReceivePanel, State}; +use state::{CoinsPanel, Home, ReceivePanel, SpendPanel, State}; use crate::{ app::{cache::Cache, error::Error, menu::Menu}, @@ -65,7 +65,7 @@ impl App { menu::Menu::Home => Home::new(&self.cache.coins).into(), menu::Menu::Coins => CoinsPanel::new(&self.cache.coins).into(), menu::Menu::Receive => ReceivePanel::default().into(), - menu::Menu::Spend => ReceivePanel::default().into(), + menu::Menu::Spend => SpendPanel::new(&self.cache.coins, &self.cache.spend_txs).into(), }; self.state.load(self.daemon.clone()) } @@ -103,6 +103,9 @@ impl App { Message::Coins(Ok(coins)) => { self.cache.coins = coins.clone(); } + Message::SpendTxs(Ok(txs)) => { + self.cache.spend_txs = txs.clone(); + } Message::BlockHeight(Ok(blockheight)) => { self.cache.blockheight = *blockheight; } diff --git a/gui/src/app/state/mod.rs b/gui/src/app/state/mod.rs index 2a8f0b65..270b0f6d 100644 --- a/gui/src/app/state/mod.rs +++ b/gui/src/app/state/mod.rs @@ -1,5 +1,6 @@ mod coins; mod settings; +mod spend; use std::sync::Arc; @@ -12,6 +13,7 @@ use crate::daemon::{model::Coin, Daemon}; pub use coins::CoinsPanel; pub use settings::SettingsState; +pub use spend::SpendPanel; pub trait State { fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message>; diff --git a/gui/src/app/state/spend.rs b/gui/src/app/state/spend.rs new file mode 100644 index 00000000..1dc5ac92 --- /dev/null +++ b/gui/src/app/state/spend.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; + +use iced::{pure::Element, Command}; + +use super::State; +use crate::{ + app::{cache::Cache, error::Error, menu::Menu, message::Message, view}, + daemon::{ + model::{Coin, SpendTx}, + Daemon, + }, +}; + +pub struct SpendPanel { + spend_txs: Vec, + warning: Option, +} + +impl SpendPanel { + pub fn new(_coins: &[Coin], spend_txs: &[SpendTx]) -> Self { + Self { + spend_txs: spend_txs.to_vec(), + warning: None, + } + } +} + +impl State for SpendPanel { + fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { + view::dashboard( + &Menu::Spend, + cache, + self.warning.as_ref(), + view::spend::spend_view(&self.spend_txs), + ) + } + + fn update( + &mut self, + _daemon: Arc, + _cache: &Cache, + _message: Message, + ) -> Command { + Command::none() + } + + fn load(&self, daemon: Arc) -> Command { + let daemon = daemon.clone(); + Command::perform( + async move { + daemon + .list_spend_txs() + .map(|res| res.spend_txs) + .map_err(|e| e.into()) + }, + Message::SpendTxs, + ) + } +} + +impl From for Box { + fn from(s: SpendPanel) -> Box { + Box::new(s) + } +} diff --git a/gui/src/app/view/mod.rs b/gui/src/app/view/mod.rs index 4627e1b1..701d7146 100644 --- a/gui/src/app/view/mod.rs +++ b/gui/src/app/view/mod.rs @@ -5,6 +5,7 @@ pub mod coins; pub mod home; pub mod receive; pub mod settings; +pub mod spend; pub use message::*; use warning::warn; @@ -131,7 +132,7 @@ pub fn sidebar<'a>(menu: &Menu, cache: &'a Cache) -> widget::Container<'a, Messa row() .push( row() - .push(coin_icon()) + .push(send_icon()) .push(text("Send")) .spacing(10) .width(iced::Length::Fill) diff --git a/gui/src/app/view/spend.rs b/gui/src/app/view/spend.rs new file mode 100644 index 00000000..c69ef48c --- /dev/null +++ b/gui/src/app/view/spend.rs @@ -0,0 +1,59 @@ +use iced::{ + pure::{button, column, container, row, Element}, + Alignment, Length, +}; + +use crate::{ + daemon::model::SpendTx, + ui::component::{badge, button::Style, card, text::*}, +}; + +use super::message::Message; + +pub fn spend_view<'a>(spend_txs: &[SpendTx]) -> Element<'a, Message> { + column() + .push( + container( + row() + .push(text(&format!(" {}", spend_txs.len())).bold()) + .push(text(" draft transactions")), + ) + .width(Length::Fill), + ) + .push( + column().spacing(10).push( + spend_txs + .iter() + .enumerate() + .fold(column().spacing(10), |col, (i, tx)| { + col.push(spend_tx_list_view(i, tx)) + }), + ), + ) + .align_items(Alignment::Center) + .spacing(20) + .into() +} + +fn spend_tx_list_view<'a>(i: usize, _tx: &SpendTx) -> Element<'a, Message> { + container( + button( + row() + .push( + row() + .push(badge::spend()) + .spacing(10) + .align_items(Alignment::Center) + .width(Length::Fill), + ) + .push(text(&format!("{} BTC", 0)).bold().width(Length::Shrink)) + .align_items(Alignment::Center) + .spacing(20), + ) + .padding(10) + .on_press(Message::Select(i)) + .style(Style::TransparentBorder), + ) + .style(card::SimpleCardStyle) + .into() +} From 5b9414260b34c37e9cddc7d79eb0a16d8d42767e Mon Sep 17 00:00:00 2001 From: edouard Date: Thu, 27 Oct 2022 18:22:13 +0200 Subject: [PATCH 3/8] Add choose recipient step --- gui/src/app/menu.rs | 1 + gui/src/app/mod.rs | 3 +- gui/src/app/state/mod.rs | 9 +- gui/src/app/state/spend.rs | 65 -------- gui/src/app/state/spend/mod.rs | 141 +++++++++++++++++ gui/src/app/state/spend/step.rs | 164 ++++++++++++++++++++ gui/src/app/view/message.rs | 13 ++ gui/src/app/view/mod.rs | 50 +++++- gui/src/app/view/{spend.rs => spend/mod.rs} | 16 +- gui/src/app/view/spend/step.rs | 85 ++++++++++ gui/src/ui/component/form.rs | 4 +- 11 files changed, 479 insertions(+), 72 deletions(-) delete mode 100644 gui/src/app/state/spend.rs create mode 100644 gui/src/app/state/spend/mod.rs create mode 100644 gui/src/app/state/spend/step.rs rename gui/src/app/view/{spend.rs => spend/mod.rs} (78%) create mode 100644 gui/src/app/view/spend/step.rs diff --git a/gui/src/app/menu.rs b/gui/src/app/menu.rs index 1fca2c30..65baad12 100644 --- a/gui/src/app/menu.rs +++ b/gui/src/app/menu.rs @@ -5,4 +5,5 @@ pub enum Menu { Spend, Settings, Coins, + CreateSpendTx, } diff --git a/gui/src/app/mod.rs b/gui/src/app/mod.rs index 85663144..0c50c2b9 100644 --- a/gui/src/app/mod.rs +++ b/gui/src/app/mod.rs @@ -21,7 +21,7 @@ pub use minisafe::config::Config as DaemonConfig; pub use config::Config; pub use message::Message; -use state::{CoinsPanel, Home, ReceivePanel, SpendPanel, State}; +use state::{CoinsPanel, CreateSpendPanel, Home, ReceivePanel, SpendPanel, State}; use crate::{ app::{cache::Cache, error::Error, menu::Menu}, @@ -66,6 +66,7 @@ impl App { menu::Menu::Coins => CoinsPanel::new(&self.cache.coins).into(), menu::Menu::Receive => ReceivePanel::default().into(), menu::Menu::Spend => SpendPanel::new(&self.cache.coins, &self.cache.spend_txs).into(), + menu::Menu::CreateSpendTx => CreateSpendPanel::new(&self.cache.coins).into(), }; self.state.load(self.daemon.clone()) } diff --git a/gui/src/app/state/mod.rs b/gui/src/app/state/mod.rs index 270b0f6d..f6f12043 100644 --- a/gui/src/app/state/mod.rs +++ b/gui/src/app/state/mod.rs @@ -13,7 +13,7 @@ use crate::daemon::{model::Coin, Daemon}; pub use coins::CoinsPanel; pub use settings::SettingsState; -pub use spend::SpendPanel; +pub use spend::{CreateSpendPanel, SpendPanel}; pub trait State { fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message>; @@ -141,6 +141,13 @@ impl From for Box { } } +/// redirect to another state with a message menu +pub fn redirect(menu: Menu) -> Command { + Command::perform(async { menu }, |menu| { + Message::View(view::Message::Menu(menu)) + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/gui/src/app/state/spend.rs b/gui/src/app/state/spend.rs deleted file mode 100644 index 1dc5ac92..00000000 --- a/gui/src/app/state/spend.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::sync::Arc; - -use iced::{pure::Element, Command}; - -use super::State; -use crate::{ - app::{cache::Cache, error::Error, menu::Menu, message::Message, view}, - daemon::{ - model::{Coin, SpendTx}, - Daemon, - }, -}; - -pub struct SpendPanel { - spend_txs: Vec, - warning: Option, -} - -impl SpendPanel { - pub fn new(_coins: &[Coin], spend_txs: &[SpendTx]) -> Self { - Self { - spend_txs: spend_txs.to_vec(), - warning: None, - } - } -} - -impl State for SpendPanel { - fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { - view::dashboard( - &Menu::Spend, - cache, - self.warning.as_ref(), - view::spend::spend_view(&self.spend_txs), - ) - } - - fn update( - &mut self, - _daemon: Arc, - _cache: &Cache, - _message: Message, - ) -> Command { - Command::none() - } - - fn load(&self, daemon: Arc) -> Command { - let daemon = daemon.clone(); - Command::perform( - async move { - daemon - .list_spend_txs() - .map(|res| res.spend_txs) - .map_err(|e| e.into()) - }, - Message::SpendTxs, - ) - } -} - -impl From for Box { - fn from(s: SpendPanel) -> Box { - Box::new(s) - } -} diff --git a/gui/src/app/state/spend/mod.rs b/gui/src/app/state/spend/mod.rs new file mode 100644 index 00000000..dabc1aa6 --- /dev/null +++ b/gui/src/app/state/spend/mod.rs @@ -0,0 +1,141 @@ +mod step; +use std::sync::Arc; + +use iced::{pure::Element, Command}; + +use super::{redirect, State}; +use crate::{ + app::{cache::Cache, error::Error, menu::Menu, message::Message, view}, + daemon::{ + model::{Coin, SpendTx}, + Daemon, + }, +}; + +pub struct SpendPanel { + selected_tx: Option, + spend_txs: Vec, + warning: Option, +} + +impl SpendPanel { + pub fn new(_coins: &[Coin], spend_txs: &[SpendTx]) -> Self { + Self { + spend_txs: spend_txs.to_vec(), + warning: None, + selected_tx: None, + } + } +} + +impl State for SpendPanel { + fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { + view::dashboard( + &Menu::Spend, + cache, + self.warning.as_ref(), + view::spend::spend_view(&self.spend_txs), + ) + } + + fn update( + &mut self, + _daemon: Arc, + _cache: &Cache, + message: Message, + ) -> Command { + match message { + Message::SpendTxs(res) => match res { + Err(e) => self.warning = Some(e), + Ok(txs) => { + self.warning = None; + self.spend_txs = txs; + } + }, + Message::View(view::Message::Select(i)) => { + self.selected_tx = Some(i); + } + _ => {} + } + Command::none() + } + + fn load(&self, daemon: Arc) -> Command { + let daemon = daemon.clone(); + Command::perform( + async move { + daemon + .list_spend_txs() + .map(|res| res.spend_txs) + .map_err(|e| e.into()) + }, + Message::SpendTxs, + ) + } +} + +impl From for Box { + fn from(s: SpendPanel) -> Box { + Box::new(s) + } +} + +pub struct CreateSpendPanel { + coins: Vec, + draft: step::TransactionDraft, + current: usize, + steps: Vec>, +} + +impl CreateSpendPanel { + pub fn new(coins: &[Coin]) -> Self { + Self { + coins: coins.to_vec(), + draft: step::TransactionDraft::default(), + current: 0, + steps: vec![Box::new(step::ChooseRecipients::default())], + } + } +} + +impl State for CreateSpendPanel { + fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { + self.steps.get(self.current).unwrap().view(cache) + } + + fn update( + &mut self, + daemon: Arc, + cache: &Cache, + message: Message, + ) -> Command { + if matches!(message, Message::View(view::Message::Close)) { + return redirect(Menu::Spend); + } + + if let Some(step) = self.steps.get_mut(self.current) { + return step.update(daemon, cache, message); + } + + Command::none() + } + + fn load(&self, daemon: Arc) -> Command { + let daemon = daemon.clone(); + Command::perform( + async move { + daemon + .list_coins() + .map(|res| res.coins) + .map_err(|e| e.into()) + }, + Message::Coins, + ) + } +} + +impl From for Box { + fn from(s: CreateSpendPanel) -> Box { + Box::new(s) + } +} diff --git a/gui/src/app/state/spend/step.rs b/gui/src/app/state/spend/step.rs new file mode 100644 index 00000000..be703e93 --- /dev/null +++ b/gui/src/app/state/spend/step.rs @@ -0,0 +1,164 @@ +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::Arc; + +use iced::pure::{column, Element}; +use iced::Command; +use minisafe::miniscript::bitcoin::{util::psbt::Psbt, Address, Amount, Denomination, OutPoint}; + +use crate::{ + app::{cache::Cache, error::Error, menu::Menu, message::Message, view}, + daemon::{model::Coin, Daemon}, + ui::component::form, +}; + +#[derive(Default)] +pub struct TransactionDraft { + inputs: Vec, + outputs: HashMap, + feerate: u64, + generated: Option, +} + +pub trait Step { + fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message>; + fn update( + &mut self, + daemon: Arc, + cache: &Cache, + message: Message, + ) -> Command; + + fn apply(&self, draft: &mut TransactionDraft); +} + +pub struct ChooseRecipients { + recipients: Vec, +} + +impl std::default::Default for ChooseRecipients { + fn default() -> Self { + Self { + recipients: vec![Recipient::default()], + } + } +} + +impl Step for ChooseRecipients { + fn update( + &mut self, + _daemon: Arc, + _cache: &Cache, + message: Message, + ) -> Command { + match message { + Message::View(view::Message::CreateSpend(msg)) => 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(msg); + } + _ => {} + }, + _ => {} + } + Command::none() + } + + fn apply(&self, draft: &mut TransactionDraft) { + let mut outputs: HashMap = HashMap::new(); + for recipient in &self.recipients { + outputs.insert( + Address::from_str(&recipient.address.value).expect("Checked before"), + Amount::from_sat(recipient.amount().expect("Checked before")), + ); + } + draft.outputs = outputs; + } + + fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> { + view::spend::step::choose_recipients_view( + self.recipients + .iter() + .enumerate() + .map(|(i, recipient)| recipient.view(i).map(view::Message::CreateSpend)) + .collect(), + !self.recipients.iter().any(|recipient| !recipient.valid()), + ) + } +} + +#[derive(Default)] +struct Recipient { + address: form::Value, + amount: form::Value, +} + +impl Recipient { + fn amount(&self) -> Result { + if self.amount.value.is_empty() { + return Err(Error::Unexpected("Amount should be non-zero".to_string())); + } + + let amount = Amount::from_str_in(&self.amount.value, Denomination::Bitcoin) + .map_err(|_| Error::Unexpected("cannot parse output amount".to_string()))?; + + if amount.to_sat() == 0 { + return Err(Error::Unexpected("Amount should be non-zero".to_string())); + } + + if let Ok(address) = Address::from_str(&self.address.value) { + if amount <= address.script_pubkey().dust_value() { + return Err(Error::Unexpected( + "Amount must be superior to script dust value".to_string(), + )); + } + } + + Ok(amount.to_sat()) + } + + fn valid(&self) -> bool { + !self.address.value.is_empty() + && self.address.valid + && !self.amount.value.is_empty() + && self.amount.valid + } + + fn update(&mut self, message: view::CreateSpendMessage) { + match message { + view::CreateSpendMessage::RecipientEdited(_, "address", address) => { + self.address.value = address; + if self.address.value.is_empty() { + // Make the error disappear if we deleted the invalid address + self.address.valid = true; + } else if Address::from_str(&self.address.value).is_ok() { + self.address.valid = true; + if !self.amount.value.is_empty() { + self.amount.valid = self.amount().is_ok(); + } + } else { + self.address.valid = false; + } + } + view::CreateSpendMessage::RecipientEdited(_, "amount", amount) => { + self.amount.value = amount; + if !self.amount.value.is_empty() { + self.amount.valid = self.amount().is_ok(); + } else { + // Make the error disappear if we deleted the invalid amount + self.amount.valid = true; + } + } + _ => {} + }; + } + + fn view(&self, i: usize) -> Element { + view::spend::step::recipient_view(i, &self.address, &self.amount) + } +} diff --git a/gui/src/app/view/message.rs b/gui/src/app/view/message.rs index ba779777..80f0bd41 100644 --- a/gui/src/app/view/message.rs +++ b/gui/src/app/view/message.rs @@ -8,6 +8,19 @@ pub enum Message { Close, Select(usize), Settings(usize, SettingsMessage), + CreateSpend(CreateSpendMessage), + Next, +} + +#[derive(Debug, Clone)] +pub enum CreateSpendMessage { + AddRecipient, + DeleteRecipient(usize), + SelectInput(usize), + RecipientEdited(usize, &'static str, String), + FeerateEdited(String), + Generate, + Save, } #[derive(Debug, Clone)] diff --git a/gui/src/app/view/mod.rs b/gui/src/app/view/mod.rs index 701d7146..ee4cac6b 100644 --- a/gui/src/app/view/mod.rs +++ b/gui/src/app/view/mod.rs @@ -18,7 +18,7 @@ use iced::{ use crate::ui::{ color, component::{badge, button, separation, text::*}, - icon::{coin_icon, home_icon, receive_icon, send_icon, settings_icon}, + icon::{coin_icon, cross_icon, home_icon, receive_icon, send_icon, settings_icon}, util::Collection, }; @@ -260,3 +260,51 @@ impl widget::container::StyleSheet for MainSectionStyle { } } } + +pub fn modal<'a, T: Into>>( + is_previous: bool, + warning: Option<&Error>, + content: T, +) -> Element<'a, Message> { + column() + .push(warn(warning)) + .push( + container( + row() + .push(if is_previous { + column() + .push(button::transparent(None, "< Previous")) + .width(Length::Fill) + } else { + column().width(Length::Fill) + }) + .align_items(iced::Alignment::Center) + .push(button::primary(Some(cross_icon()), "Close").on_press(Message::Close)), + ) + .padding(10) + .style(ModalSectionStyle), + ) + .push(modal_section(container(scrollable(content)))) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +fn modal_section<'a, T: 'a>(menu: widget::Container<'a, T>) -> widget::Container<'a, T> { + container(menu.max_width(1500)) + .padding(20) + .style(ModalSectionStyle) + .center_x() + .width(Length::Fill) + .height(Length::Fill) +} + +pub struct ModalSectionStyle; +impl widget::container::StyleSheet for ModalSectionStyle { + fn style(&self) -> widget::container::Style { + widget::container::Style { + background: color::BACKGROUND.into(), + ..widget::container::Style::default() + } + } +} diff --git a/gui/src/app/view/spend.rs b/gui/src/app/view/spend/mod.rs similarity index 78% rename from gui/src/app/view/spend.rs rename to gui/src/app/view/spend/mod.rs index c69ef48c..9dc38819 100644 --- a/gui/src/app/view/spend.rs +++ b/gui/src/app/view/spend/mod.rs @@ -1,17 +1,29 @@ +pub mod step; + use iced::{ pure::{button, column, container, row, Element}, Alignment, Length, }; use crate::{ + app::menu::Menu, daemon::model::SpendTx, - ui::component::{badge, button::Style, card, text::*}, + ui::{ + component::{badge, button, card, text::*}, + icon, + }, }; use super::message::Message; pub fn spend_view<'a>(spend_txs: &[SpendTx]) -> Element<'a, Message> { column() + .push( + row().push(column().width(Length::Fill)).push( + button::primary(Some(icon::plus_icon()), "Create a new transaction") + .on_press(Message::Menu(Menu::CreateSpendTx)), + ), + ) .push( container( row() @@ -52,7 +64,7 @@ fn spend_tx_list_view<'a>(i: usize, _tx: &SpendTx) -> Element<'a, Message> { ) .padding(10) .on_press(Message::Select(i)) - .style(Style::TransparentBorder), + .style(button::Style::TransparentBorder), ) .style(card::SimpleCardStyle) .into() diff --git a/gui/src/app/view/spend/step.rs b/gui/src/app/view/spend/step.rs new file mode 100644 index 00000000..815450fe --- /dev/null +++ b/gui/src/app/view/spend/step.rs @@ -0,0 +1,85 @@ +use iced::{ + pure::{column, container, row, widget, Element}, + Alignment, Length, +}; + +use crate::{ + app::view::{message::*, modal}, + ui::{ + component::{ + button, form, + text::{text, Text}, + }, + icon, + util::Collection, + }, +}; + +pub fn choose_recipients_view<'a>( + recipients: Vec>, + is_valid: bool, +) -> Element<'a, Message> { + modal( + false, + None, + column() + .push(text("Choose recipients").bold().size(50)) + .push( + column() + .push(widget::Column::with_children(recipients).spacing(10)) + .push( + button::transparent(Some(icon::plus_icon()), "Add recipient") + .on_press(Message::CreateSpend(CreateSpendMessage::AddRecipient)), + ) + .max_width(1000) + .spacing(10), + ) + .push_maybe(if is_valid { + Some( + button::primary(None, "Next") + .on_press(Message::Next) + .width(Length::Units(100)), + ) + } else { + None + }) + .spacing(20) + .align_items(Alignment::Center), + ) +} + +pub fn recipient_view<'a>( + index: usize, + address: &form::Value, + amount: &form::Value, +) -> Element<'a, CreateSpendMessage> { + row() + .push( + form::Form::new("Address", address, move |msg| { + CreateSpendMessage::RecipientEdited(index, "address", msg) + }) + .warning("Please enter correct bitcoin address") + .size(20) + .padding(10), + ) + .push( + container( + form::Form::new("Amount", amount, move |msg| { + CreateSpendMessage::RecipientEdited(index, "amount", msg) + }) + .warning("Please enter correct amount") + .size(20) + .padding(10), + ) + .width(Length::Units(250)), + ) + .spacing(5) + .push( + button::transparent(Some(icon::trash_icon()), "") + .on_press(CreateSpendMessage::DeleteRecipient(index)) + .width(Length::Shrink), + ) + .align_items(Alignment::Center) + .width(Length::Fill) + .into() +} diff --git a/gui/src/ui/component/form.rs b/gui/src/ui/component/form.rs index 34933e55..861993d8 100644 --- a/gui/src/ui/component/form.rs +++ b/gui/src/ui/component/form.rs @@ -5,7 +5,7 @@ use iced::pure::{ }; use iced::Length; -use crate::ui::{color, component::text::text}; +use crate::ui::{color, component::text::*}; #[derive(Debug, Clone)] pub struct Value { @@ -75,7 +75,7 @@ impl<'a, Message: 'a + Clone> From> for Element<'a, Message> { return container( column() .push(form.input.style(InvalidFormStyle)) - .push(text(message).color(color::ALERT)) + .push(text(message).color(color::ALERT).small()) .width(Length::Fill) .spacing(5), ) From 0b2f64279b92bf0502f4edc1d9bee3c231e933e8 Mon Sep 17 00:00:00 2001 From: edouard Date: Thu, 27 Oct 2022 18:47:47 +0200 Subject: [PATCH 4/8] Add choose feerate step --- gui/src/app/state/spend/mod.rs | 21 +++++++++++++---- gui/src/app/state/spend/step.rs | 42 +++++++++++++++++++++++++++++++++ gui/src/app/view/message.rs | 1 + gui/src/app/view/mod.rs | 4 +++- gui/src/app/view/spend/step.rs | 34 ++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 5 deletions(-) diff --git a/gui/src/app/state/spend/mod.rs b/gui/src/app/state/spend/mod.rs index dabc1aa6..f37f3a37 100644 --- a/gui/src/app/state/spend/mod.rs +++ b/gui/src/app/state/spend/mod.rs @@ -81,19 +81,20 @@ impl From for Box { } pub struct CreateSpendPanel { - coins: Vec, draft: step::TransactionDraft, current: usize, steps: Vec>, } impl CreateSpendPanel { - pub fn new(coins: &[Coin]) -> Self { + pub fn new(_coins: &[Coin]) -> Self { Self { - coins: coins.to_vec(), draft: step::TransactionDraft::default(), current: 0, - steps: vec![Box::new(step::ChooseRecipients::default())], + steps: vec![ + Box::new(step::ChooseRecipients::default()), + Box::new(step::ChooseFeerate::default()), + ], } } } @@ -113,6 +114,18 @@ impl State for CreateSpendPanel { return redirect(Menu::Spend); } + if matches!(message, Message::View(view::Message::Next)) { + if self.steps.get(self.current + 1).is_some() { + self.current += 1; + } + } + + if matches!(message, Message::View(view::Message::Previous)) { + if self.steps.get(self.current - 1).is_some() { + self.current -= 1; + } + } + if let Some(step) = self.steps.get_mut(self.current) { return step.update(daemon, cache, message); } diff --git a/gui/src/app/state/spend/step.rs b/gui/src/app/state/spend/step.rs index be703e93..d3fb7cb5 100644 --- a/gui/src/app/state/spend/step.rs +++ b/gui/src/app/state/spend/step.rs @@ -162,3 +162,45 @@ impl Recipient { view::spend::step::recipient_view(i, &self.address, &self.amount) } } + +#[derive(Default)] +pub struct ChooseFeerate { + feerate: form::Value, +} + +impl Step for ChooseFeerate { + fn update( + &mut self, + _daemon: Arc, + _cache: &Cache, + message: Message, + ) -> Command { + if let Message::View(view::Message::CreateSpend(view::CreateSpendMessage::FeerateEdited( + s, + ))) = message + { + if s.parse::().is_ok() { + self.feerate.value = s; + self.feerate.valid = true; + } else if s.is_empty() { + self.feerate.value = "".to_string(); + self.feerate.valid = true; + } else { + self.feerate.valid = false; + } + } + + Command::none() + } + + fn apply(&self, draft: &mut TransactionDraft) { + draft.feerate = self.feerate.value.parse::().expect("Checked before"); + } + + fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> { + view::spend::step::choose_feerate_view( + &self.feerate, + self.feerate.valid && !self.feerate.value.is_empty(), + ) + } +} diff --git a/gui/src/app/view/message.rs b/gui/src/app/view/message.rs index 80f0bd41..c4a7f0a4 100644 --- a/gui/src/app/view/message.rs +++ b/gui/src/app/view/message.rs @@ -10,6 +10,7 @@ pub enum Message { Settings(usize, SettingsMessage), CreateSpend(CreateSpendMessage), Next, + Previous, } #[derive(Debug, Clone)] diff --git a/gui/src/app/view/mod.rs b/gui/src/app/view/mod.rs index ee4cac6b..6633b1d7 100644 --- a/gui/src/app/view/mod.rs +++ b/gui/src/app/view/mod.rs @@ -273,7 +273,9 @@ pub fn modal<'a, T: Into>>( row() .push(if is_previous { column() - .push(button::transparent(None, "< Previous")) + .push( + button::transparent(None, "< Previous").on_press(Message::Previous), + ) .width(Length::Fill) } else { column().width(Length::Fill) diff --git a/gui/src/app/view/spend/step.rs b/gui/src/app/view/spend/step.rs index 815450fe..7b426e90 100644 --- a/gui/src/app/view/spend/step.rs +++ b/gui/src/app/view/spend/step.rs @@ -83,3 +83,37 @@ pub fn recipient_view<'a>( .width(Length::Fill) .into() } + +pub fn choose_feerate_view<'a>( + feerate: &form::Value, + is_valid: bool, +) -> Element<'a, Message> { + modal( + true, + None, + column() + .push(text("Choose feerate").bold().size(50)) + .push( + container( + form::Form::new("Feerate", feerate, move |msg| { + Message::CreateSpend(CreateSpendMessage::FeerateEdited(msg)) + }) + .warning("Please enter correct feerate") + .size(20) + .padding(10), + ) + .width(Length::Units(250)), + ) + .push_maybe(if is_valid { + Some( + button::primary(None, "Next") + .on_press(Message::Next) + .width(Length::Units(100)), + ) + } else { + None + }) + .spacing(20) + .align_items(Alignment::Center), + ) +} From 7fda64f4adae04e2f95439975e6e82a66c0a6035 Mon Sep 17 00:00:00 2001 From: edouard Date: Fri, 28 Oct 2022 11:36:56 +0200 Subject: [PATCH 5/8] Add select inputs step --- gui/src/app/state/spend/mod.rs | 9 +++- gui/src/app/state/spend/step.rs | 67 ++++++++++++++++++++++++++++ gui/src/app/view/message.rs | 2 +- gui/src/app/view/spend/step.rs | 77 ++++++++++++++++++++++++++++++++- gui/src/ui/component/card.rs | 26 +++++++++++ 5 files changed, 177 insertions(+), 4 deletions(-) diff --git a/gui/src/app/state/spend/mod.rs b/gui/src/app/state/spend/mod.rs index f37f3a37..983d044f 100644 --- a/gui/src/app/state/spend/mod.rs +++ b/gui/src/app/state/spend/mod.rs @@ -87,12 +87,13 @@ pub struct CreateSpendPanel { } impl CreateSpendPanel { - pub fn new(_coins: &[Coin]) -> Self { + pub fn new(coins: &[Coin]) -> Self { Self { draft: step::TransactionDraft::default(), current: 0, steps: vec![ Box::new(step::ChooseRecipients::default()), + Box::new(step::ChooseCoins::new(coins.to_vec())), Box::new(step::ChooseFeerate::default()), ], } @@ -115,6 +116,10 @@ impl State for CreateSpendPanel { } if matches!(message, Message::View(view::Message::Next)) { + if let Some(step) = self.steps.get(self.current) { + step.apply(&mut self.draft); + } + if self.steps.get(self.current + 1).is_some() { self.current += 1; } @@ -127,7 +132,7 @@ impl State for CreateSpendPanel { } if let Some(step) = self.steps.get_mut(self.current) { - return step.update(daemon, cache, message); + return step.update(daemon, cache, &self.draft, message); } Command::none() diff --git a/gui/src/app/state/spend/step.rs b/gui/src/app/state/spend/step.rs index d3fb7cb5..26bee254 100644 --- a/gui/src/app/state/spend/step.rs +++ b/gui/src/app/state/spend/step.rs @@ -26,6 +26,7 @@ pub trait Step { &mut self, daemon: Arc, cache: &Cache, + draft: &TransactionDraft, message: Message, ) -> Command; @@ -49,6 +50,7 @@ impl Step for ChooseRecipients { &mut self, _daemon: Arc, _cache: &Cache, + _draft: &TransactionDraft, message: Message, ) -> Command { match message { @@ -173,6 +175,7 @@ impl Step for ChooseFeerate { &mut self, _daemon: Arc, _cache: &Cache, + _draft: &TransactionDraft, message: Message, ) -> Command { if let Message::View(view::Message::CreateSpend(view::CreateSpendMessage::FeerateEdited( @@ -204,3 +207,67 @@ impl Step for ChooseFeerate { ) } } + +#[derive(Default)] +pub struct ChooseCoins { + coins: Vec<(Coin, bool)>, + /// draft output amount must be superior to total input amount. + is_valid: bool, + total_needed: Option, +} + +impl ChooseCoins { + pub fn new(coins: Vec) -> Self { + Self { + coins: coins.into_iter().map(|c| (c, false)).collect(), + is_valid: false, + total_needed: None, + } + } +} + +impl Step for ChooseCoins { + fn update( + &mut self, + _daemon: Arc, + _cache: &Cache, + draft: &TransactionDraft, + message: Message, + ) -> Command { + if let Message::View(view::Message::CreateSpend(view::CreateSpendMessage::SelectCoin(i))) = + message + { + if let Some(coin) = self.coins.get_mut(i) { + coin.1 = !coin.1; + } + + let total_needed = draft + .outputs + .values() + .fold(Amount::from_sat(0), |acc, a| acc + *a); + + self.is_valid = self + .coins + .iter() + .filter_map(|(coin, selected)| if *selected { Some(coin.amount) } else { None }) + .sum::() + > total_needed; + + self.total_needed = Some(total_needed); + } + + Command::none() + } + + fn apply(&self, draft: &mut TransactionDraft) { + draft.inputs = self + .coins + .iter() + .filter_map(|(coin, selected)| if *selected { Some(coin.outpoint) } else { None }) + .collect(); + } + + fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> { + view::spend::step::choose_coins_view(&self.coins, self.total_needed.as_ref(), self.is_valid) + } +} diff --git a/gui/src/app/view/message.rs b/gui/src/app/view/message.rs index c4a7f0a4..bf5768d2 100644 --- a/gui/src/app/view/message.rs +++ b/gui/src/app/view/message.rs @@ -17,7 +17,7 @@ pub enum Message { pub enum CreateSpendMessage { AddRecipient, DeleteRecipient(usize), - SelectInput(usize), + SelectCoin(usize), RecipientEdited(usize, &'static str, String), FeerateEdited(String), Generate, diff --git a/gui/src/app/view/spend/step.rs b/gui/src/app/view/spend/step.rs index 7b426e90..376211e3 100644 --- a/gui/src/app/view/spend/step.rs +++ b/gui/src/app/view/spend/step.rs @@ -3,11 +3,14 @@ use iced::{ Alignment, Length, }; +use minisafe::miniscript::bitcoin::Amount; + use crate::{ app::view::{message::*, modal}, + daemon::model::Coin, ui::{ component::{ - button, form, + badge, button, card, form, text::{text, Text}, }, icon, @@ -117,3 +120,75 @@ pub fn choose_feerate_view<'a>( .align_items(Alignment::Center), ) } + +pub fn choose_coins_view<'a>( + coins: &[(Coin, bool)], + total_needed: Option<&Amount>, + is_valid: bool, +) -> Element<'a, Message> { + modal( + true, + None, + column() + .push(text("Choose coins").bold().size(50)) + .push( + column().spacing(10).push( + coins + .iter() + .enumerate() + .fold(column().spacing(10), |col, (i, (coin, selected))| { + col.push(coin_list_view(i, coin, *selected)) + }), + ), + ) + .push_maybe(if is_valid { + Some(container( + button::primary(None, "Next") + .on_press(Message::Next) + .width(Length::Units(100)), + )) + } else if total_needed.is_some() { + Some(container(card::warning(&format!( + "Total amount must be superior to {}", + total_needed.unwrap().to_btc(), + )))) + } else { + None + }) + .spacing(20) + .align_items(Alignment::Center), + ) +} + +fn coin_list_view<'a>(i: usize, coin: &Coin, selected: bool) -> Element<'a, Message> { + container( + iced::pure::button( + row() + .push( + row() + .push(if selected { + icon::square_check_icon() + } else { + icon::square_icon() + }) + .push(badge::coin()) + .push(text(&format!("block: {}", coin.block_height.unwrap_or(0))).small()) + .spacing(10) + .align_items(Alignment::Center) + .width(Length::Fill), + ) + .push( + text(&format!("{} BTC", coin.amount.to_btc())) + .bold() + .width(Length::Shrink), + ) + .align_items(Alignment::Center) + .spacing(20), + ) + .padding(10) + .on_press(Message::CreateSpend(CreateSpendMessage::SelectCoin(i))) + .style(button::Style::TransparentBorder), + ) + .style(card::SimpleCardStyle) + .into() +} diff --git a/gui/src/ui/component/card.rs b/gui/src/ui/component/card.rs index dccc26ee..672cd83a 100644 --- a/gui/src/ui/component/card.rs +++ b/gui/src/ui/component/card.rs @@ -17,6 +17,32 @@ impl widget::container::StyleSheet for SimpleCardStyle { } } +/// display an error card with the message and the error in a tooltip. +pub fn warning<'a, T: 'a>(message: &str) -> widget::Container<'a, T> { + container( + row() + .spacing(20) + .align_items(iced::Alignment::Center) + .push(icon::warning_octagon_icon().color(color::WARNING)) + .push(text(message).color(color::WARNING)), + ) + .padding(15) + .style(WarningCardStyle) +} + +pub struct WarningCardStyle; +impl widget::container::StyleSheet for WarningCardStyle { + fn style(&self) -> widget::container::Style { + widget::container::Style { + border_radius: 10.0, + border_color: color::WARNING, + border_width: 1.5, + background: color::FOREGROUND.into(), + ..widget::container::Style::default() + } + } +} + /// display an error card with the message and the error in a tooltip. pub fn error<'a, T: 'a>(message: &str, error: &str) -> widget::Container<'a, T> { container( From 063786fe5485e4f5a857ed9c45f3fbec3fc5f69a Mon Sep 17 00:00:00 2001 From: edouard Date: Fri, 28 Oct 2022 15:36:31 +0200 Subject: [PATCH 6/8] Create spend transaction --- gui/Cargo.lock | 1 + gui/Cargo.toml | 1 + gui/src/app/error.rs | 8 + gui/src/app/message.rs | 13 +- gui/src/app/mod.rs | 6 +- gui/src/app/state/mod.rs | 13 +- gui/src/app/state/spend/detail.rs | 368 ++++++++++++++++++++++++++++++ gui/src/app/state/spend/mod.rs | 63 +++-- gui/src/app/state/spend/step.rs | 185 ++++++++++++--- gui/src/app/view/coins.rs | 11 +- gui/src/app/view/message.rs | 10 +- gui/src/app/view/mod.rs | 32 ++- gui/src/app/view/spend/detail.rs | 327 ++++++++++++++++++++++++++ gui/src/app/view/spend/mod.rs | 26 ++- gui/src/app/view/spend/step.rs | 10 +- gui/src/app/view/warning.rs | 1 + gui/src/daemon/client/mod.rs | 41 +++- gui/src/daemon/embedded.rs | 56 ++++- gui/src/daemon/mod.rs | 39 +++- gui/src/daemon/model.rs | 64 +++++- gui/src/loader.rs | 3 +- gui/src/ui/component/badge.rs | 14 ++ gui/src/ui/component/button.rs | 38 +++ 23 files changed, 1244 insertions(+), 86 deletions(-) create mode 100644 gui/src/app/state/spend/detail.rs create mode 100644 gui/src/app/view/spend/detail.rs diff --git a/gui/Cargo.lock b/gui/Cargo.lock index ec0ec9d5..6ee0a23a 100644 --- a/gui/Cargo.lock +++ b/gui/Cargo.lock @@ -1619,6 +1619,7 @@ version = "0.0.1" dependencies = [ "async-hwi", "backtrace", + "base64", "chrono", "dirs", "fern", diff --git a/gui/Cargo.toml b/gui/Cargo.toml index a8b34dcd..d1b71997 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -17,6 +17,7 @@ path = "src/main.rs" async-hwi = { git = "https://github.com/revault/async-hwi", branch = "master" } minisafe = { git = "https://github.com/revault/minisafe", branch = "master", default-features = false } backtrace = "0.3" +base64 = "0.13" iced = { version = "0.4", default-features= false, features = ["tokio", "wgpu", "svg", "qr_code", "pure"] } iced_native = "0.5" diff --git a/gui/src/app/error.rs b/gui/src/app/error.rs index db6cab3a..095ddf5f 100644 --- a/gui/src/app/error.rs +++ b/gui/src/app/error.rs @@ -8,6 +8,7 @@ pub enum Error { Config(String), Daemon(DaemonError), Unexpected(String), + HardwareWallet(async_hwi::Error), } impl std::fmt::Display for Error { @@ -35,6 +36,7 @@ impl std::fmt::Display for Error { } }, Self::Unexpected(e) => write!(f, "Unexpected error: {}", e), + Self::HardwareWallet(e) => write!(f, "{}", e), } } } @@ -50,3 +52,9 @@ impl From for Error { Error::Daemon(error) } } + +impl From for Error { + fn from(error: async_hwi::Error) -> Self { + Error::HardwareWallet(error) + } +} diff --git a/gui/src/app/message.rs b/gui/src/app/message.rs index 8d2eb808..490fa2d6 100644 --- a/gui/src/app/message.rs +++ b/gui/src/app/message.rs @@ -1,8 +1,15 @@ -use minisafe::{config::Config as DaemonConfig, miniscript::bitcoin::Address}; +use minisafe::{ + config::Config as DaemonConfig, + miniscript::bitcoin::{ + util::{bip32::Fingerprint, psbt::Psbt}, + Address, + }, +}; use crate::{ app::{error::Error, view}, daemon::model::*, + hw::HardwareWallet, }; #[derive(Debug)] @@ -16,4 +23,8 @@ pub enum Message { ReceiveAddress(Result), Coins(Result, Error>), SpendTxs(Result, Error>), + Psbt(Result), + Signed(Result<(Psbt, Fingerprint), Error>), + Updated(Result<(), Error>), + ConnectedHardwareWallets(Vec), } diff --git a/gui/src/app/mod.rs b/gui/src/app/mod.rs index 0c50c2b9..f9338f06 100644 --- a/gui/src/app/mod.rs +++ b/gui/src/app/mod.rs @@ -65,8 +65,10 @@ impl App { menu::Menu::Home => Home::new(&self.cache.coins).into(), menu::Menu::Coins => CoinsPanel::new(&self.cache.coins).into(), menu::Menu::Receive => ReceivePanel::default().into(), - menu::Menu::Spend => SpendPanel::new(&self.cache.coins, &self.cache.spend_txs).into(), - menu::Menu::CreateSpendTx => CreateSpendPanel::new(&self.cache.coins).into(), + menu::Menu::Spend => SpendPanel::new(self.config.clone(), &self.cache.spend_txs).into(), + menu::Menu::CreateSpendTx => { + CreateSpendPanel::new(self.config.clone(), &self.cache.coins).into() + } }; self.state.load(self.daemon.clone()) } diff --git a/gui/src/app/state/mod.rs b/gui/src/app/state/mod.rs index f6f12043..b60737b6 100644 --- a/gui/src/app/state/mod.rs +++ b/gui/src/app/state/mod.rs @@ -38,7 +38,18 @@ pub struct Home { impl Home { pub fn new(coins: &[Coin]) -> Self { Self { - balance: Amount::from_sat(coins.iter().map(|coin| coin.amount.to_sat()).sum()), + balance: Amount::from_sat( + coins + .iter() + .map(|coin| { + if coin.spend_info.is_none() { + coin.amount.to_sat() + } else { + 0 + } + }) + .sum(), + ), } } } diff --git a/gui/src/app/state/spend/detail.rs b/gui/src/app/state/spend/detail.rs new file mode 100644 index 00000000..e3388ae2 --- /dev/null +++ b/gui/src/app/state/spend/detail.rs @@ -0,0 +1,368 @@ +use std::sync::Arc; + +use iced::pure::Element; +use iced::Command; +use minisafe::miniscript::bitcoin::util::{bip32::Fingerprint, psbt::Psbt}; + +use crate::{ + app::{ + cache::Cache, config::Config, error::Error, message::Message, view, view::spend::detail, + }, + daemon::{ + model::{SpendStatus, SpendTx}, + Daemon, + }, + hw::{list_hardware_wallets, HardwareWallet}, +}; + +trait Action { + fn warning(&self) -> Option<&Error> { + None + } + fn updated(&self) -> bool { + false + } + fn load(&self, _daemon: Arc) -> Command { + Command::none() + } + fn update( + &mut self, + _daemon: Arc, + _cache: &Cache, + _message: Message, + _tx: &mut SpendTx, + ) -> Command { + Command::none() + } + fn view(&self) -> Element; +} + +pub struct SpendTxState { + config: Config, + tx: SpendTx, + saved: bool, + action: Box, +} + +impl SpendTxState { + pub fn new(config: Config, tx: SpendTx, saved: bool) -> Self { + Self { + action: choose_action(&config, saved, &tx), + config, + tx, + saved, + } + } + + pub fn load(&self, daemon: Arc) -> Command { + self.action.load(daemon) + } + + pub fn update( + &mut self, + daemon: Arc, + cache: &Cache, + message: Message, + ) -> Command { + let cmd = match &message { + Message::View(view::Message::Spend(msg)) => match msg { + view::SpendTxMessage::Cancel => { + self.action = choose_action(&self.config, self.saved, &self.tx); + self.action.load(daemon.clone()) + } + view::SpendTxMessage::Delete => { + self.action = Box::new(DeleteAction::default()); + self.action.load(daemon.clone()) + } + _ => self + .action + .update(daemon.clone(), cache, message, &mut self.tx), + }, + _ => self + .action + .update(daemon.clone(), cache, message, &mut self.tx), + }; + if self.action.updated() { + self.saved = true; + self.action = choose_action(&self.config, self.saved, &self.tx); + self.action.load(daemon) + } else { + cmd + } + } + + pub fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> { + detail::spend_view( + self.action.warning(), + &self.tx, + self.action.view(), + self.saved, + ) + } +} + +fn choose_action(config: &Config, saved: bool, tx: &SpendTx) -> Box { + if saved { + match tx.status { + SpendStatus::Deprecated | SpendStatus::Broadcasted => { + return Box::new(NoAction::default()); + } + _ => {} + } + + if !tx.psbt.inputs.first().unwrap().partial_sigs.is_empty() { + return Box::new(BroadcastAction::default()); + } else { + return Box::new(SignAction::new(config.clone())); + } + } + Box::new(SaveAction::default()) +} + +#[derive(Default)] +pub struct SaveAction { + saved: bool, + error: Option, +} + +impl Action for SaveAction { + fn warning(&self) -> Option<&Error> { + self.error.as_ref() + } + + fn updated(&self) -> bool { + self.saved + } + + fn update( + &mut self, + daemon: Arc, + _cache: &Cache, + message: Message, + tx: &mut SpendTx, + ) -> Command { + match message { + Message::View(view::Message::Spend(view::SpendTxMessage::Confirm)) => { + let daemon = daemon.clone(); + let psbt = tx.psbt.clone(); + return Command::perform( + async move { daemon.update_spend_tx(&psbt).map_err(|e| e.into()) }, + Message::Updated, + ); + } + Message::Updated(res) => match res { + Ok(()) => self.saved = true, + Err(e) => self.error = Some(e), + }, + _ => {} + } + Command::none() + } + fn view(&self) -> Element { + detail::save_action(self.saved) + } +} + +#[derive(Default)] +pub struct BroadcastAction { + broadcasted: bool, + error: Option, +} + +impl Action for BroadcastAction { + fn warning(&self) -> Option<&Error> { + self.error.as_ref() + } + fn update( + &mut self, + daemon: Arc, + _cache: &Cache, + message: Message, + tx: &mut SpendTx, + ) -> Command { + match message { + Message::View(view::Message::Spend(view::SpendTxMessage::Confirm)) => { + let daemon = daemon.clone(); + let psbt = tx.psbt.clone(); + self.error = None; + return Command::perform( + async move { + daemon + .broadcast_spend_tx(&psbt.unsigned_tx.txid()) + .map_err(|e| e.into()) + }, + Message::Updated, + ); + } + Message::Updated(res) => match res { + Ok(()) => self.broadcasted = true, + Err(e) => self.error = Some(e), + }, + _ => {} + } + Command::none() + } + fn view(&self) -> Element { + detail::broadcast_action(self.broadcasted) + } +} + +#[derive(Default)] +pub struct DeleteAction { + deleted: bool, + error: Option, +} + +impl Action for DeleteAction { + fn warning(&self) -> Option<&Error> { + self.error.as_ref() + } + + fn update( + &mut self, + daemon: Arc, + _cache: &Cache, + message: Message, + tx: &mut SpendTx, + ) -> Command { + match message { + Message::View(view::Message::Spend(view::SpendTxMessage::Confirm)) => { + let daemon = daemon.clone(); + let psbt = tx.psbt.clone(); + self.error = None; + return Command::perform( + async move { + daemon + .delete_spend_tx(&psbt.unsigned_tx.txid()) + .map_err(|e| e.into()) + }, + Message::Updated, + ); + } + Message::Updated(res) => match res { + Ok(()) => self.deleted = true, + Err(e) => self.error = Some(e), + }, + _ => {} + } + Command::none() + } + fn view(&self) -> Element { + detail::delete_action(self.deleted) + } +} + +pub struct SignAction { + config: Config, + chosen_hw: Option, + processing: bool, + hws: Vec, + error: Option, + signed: Vec, + updated: bool, +} + +impl SignAction { + pub fn new(config: Config) -> Self { + Self { + config, + chosen_hw: None, + processing: false, + hws: Vec::new(), + error: None, + signed: Vec::new(), + updated: false, + } + } +} + +impl Action for SignAction { + fn warning(&self) -> Option<&Error> { + self.error.as_ref() + } + + fn updated(&self) -> bool { + self.updated + } + + fn load(&self, daemon: Arc) -> Command { + let config = self.config.clone(); + let desc = daemon.config().main_descriptor.to_string(); + Command::perform( + list_hws(config, "Minisafe".to_string(), desc), + Message::ConnectedHardwareWallets, + ) + } + fn update( + &mut self, + daemon: Arc, + _cache: &Cache, + message: Message, + tx: &mut SpendTx, + ) -> Command { + match message { + Message::View(view::Message::Spend(view::SpendTxMessage::SelectHardwareWallet(i))) => { + if let Some(hw) = self.hws.get(i) { + let device = hw.device.clone(); + self.chosen_hw = Some(i); + self.processing = true; + let psbt = tx.psbt.clone(); + return Command::perform( + sign_psbt(device, hw.fingerprint, psbt), + Message::Signed, + ); + } + } + Message::Signed(res) => match res { + Err(e) => self.error = Some(e), + Ok((psbt, fingerprint)) => { + self.error = None; + self.signed.push(fingerprint); + let daemon = daemon.clone(); + tx.psbt = psbt.clone(); + return Command::perform( + async move { daemon.update_spend_tx(&psbt).map_err(|e| e.into()) }, + Message::Updated, + ); + } + }, + Message::Updated(res) => match res { + Ok(()) => self.updated = true, + Err(e) => self.error = Some(e), + }, + Message::ConnectedHardwareWallets(hws) => { + self.hws = hws; + } + Message::View(view::Message::Reload) => { + return self.load(daemon); + } + _ => {} + }; + Command::none() + } + fn view(&self) -> Element { + view::spend::detail::sign_action(&self.hws, self.processing, self.chosen_hw, &self.signed) + } +} + +async fn list_hws(config: Config, wallet_name: String, descriptor: String) -> Vec { + list_hardware_wallets(&config.hardware_wallets, Some((&wallet_name, &descriptor))).await +} + +async fn sign_psbt( + hw: std::sync::Arc, + fingerprint: Fingerprint, + mut psbt: Psbt, +) -> Result<(Psbt, Fingerprint), Error> { + hw.sign_tx(&mut psbt).await.map_err(Error::from)?; + Ok((psbt, fingerprint)) +} + +#[derive(Default)] +pub struct NoAction {} + +impl Action for NoAction { + fn view(&self) -> Element { + iced::pure::column().into() + } +} diff --git a/gui/src/app/state/spend/mod.rs b/gui/src/app/state/spend/mod.rs index 983d044f..6c4418e3 100644 --- a/gui/src/app/state/spend/mod.rs +++ b/gui/src/app/state/spend/mod.rs @@ -1,3 +1,4 @@ +mod detail; mod step; use std::sync::Arc; @@ -5,7 +6,7 @@ use iced::{pure::Element, Command}; use super::{redirect, State}; use crate::{ - app::{cache::Cache, error::Error, menu::Menu, message::Message, view}, + app::{cache::Cache, config::Config, error::Error, menu::Menu, message::Message, view}, daemon::{ model::{Coin, SpendTx}, Daemon, @@ -13,14 +14,16 @@ use crate::{ }; pub struct SpendPanel { - selected_tx: Option, + config: Config, + selected_tx: Option, spend_txs: Vec, warning: Option, } impl SpendPanel { - pub fn new(_coins: &[Coin], spend_txs: &[SpendTx]) -> Self { + pub fn new(config: Config, spend_txs: &[SpendTx]) -> Self { Self { + config, spend_txs: spend_txs.to_vec(), warning: None, selected_tx: None, @@ -30,18 +33,22 @@ impl SpendPanel { impl State for SpendPanel { fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { - view::dashboard( - &Menu::Spend, - cache, - self.warning.as_ref(), - view::spend::spend_view(&self.spend_txs), - ) + if let Some(tx) = &self.selected_tx { + tx.view(cache) + } else { + view::dashboard( + &Menu::Spend, + cache, + self.warning.as_ref(), + view::spend::spend_view(&self.spend_txs), + ) + } } fn update( &mut self, - _daemon: Arc, - _cache: &Cache, + daemon: Arc, + cache: &Cache, message: Message, ) -> Command { match message { @@ -52,10 +59,25 @@ impl State for SpendPanel { self.spend_txs = txs; } }, - Message::View(view::Message::Select(i)) => { - self.selected_tx = Some(i); + Message::View(view::Message::Close) => { + if self.selected_tx.is_some() { + self.selected_tx = None; + return self.load(daemon); + } + } + Message::View(view::Message::Select(i)) => { + if let Some(tx) = self.spend_txs.get(i) { + let tx = detail::SpendTxState::new(self.config.clone(), tx.clone(), true); + let cmd = tx.load(daemon); + self.selected_tx = Some(tx); + return cmd; + } + } + _ => { + if let Some(tx) = &mut self.selected_tx { + return tx.update(daemon, cache, message); + } } - _ => {} } Command::none() } @@ -63,12 +85,7 @@ impl State for SpendPanel { fn load(&self, daemon: Arc) -> Command { let daemon = daemon.clone(); Command::perform( - async move { - daemon - .list_spend_txs() - .map(|res| res.spend_txs) - .map_err(|e| e.into()) - }, + async move { daemon.list_spend_transactions().map_err(|e| e.into()) }, Message::SpendTxs, ) } @@ -87,7 +104,7 @@ pub struct CreateSpendPanel { } impl CreateSpendPanel { - pub fn new(coins: &[Coin]) -> Self { + pub fn new(config: Config, coins: &[Coin]) -> Self { Self { draft: step::TransactionDraft::default(), current: 0, @@ -95,6 +112,7 @@ impl CreateSpendPanel { Box::new(step::ChooseRecipients::default()), Box::new(step::ChooseCoins::new(coins.to_vec())), Box::new(step::ChooseFeerate::default()), + Box::new(step::SaveSpend::new(config)), ], } } @@ -120,8 +138,9 @@ impl State for CreateSpendPanel { step.apply(&mut self.draft); } - if self.steps.get(self.current + 1).is_some() { + if let Some(step) = self.steps.get_mut(self.current + 1) { self.current += 1; + step.load(&self.draft); } } diff --git a/gui/src/app/state/spend/step.rs b/gui/src/app/state/spend/step.rs index 26bee254..b70f1d7b 100644 --- a/gui/src/app/state/spend/step.rs +++ b/gui/src/app/state/spend/step.rs @@ -2,20 +2,27 @@ use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; -use iced::pure::{column, Element}; +use iced::pure::Element; use iced::Command; -use minisafe::miniscript::bitcoin::{util::psbt::Psbt, Address, Amount, Denomination, OutPoint}; +use minisafe::miniscript::bitcoin::{ + util::psbt::Psbt, Address, Amount, Denomination, OutPoint, Script, +}; use crate::{ - app::{cache::Cache, error::Error, menu::Menu, message::Message, view}, - daemon::{model::Coin, Daemon}, + app::{ + cache::Cache, config::Config, error::Error, message::Message, state::spend::detail, view, + }, + daemon::{ + model::{Coin, SpendTx}, + Daemon, + }, ui::component::form, }; -#[derive(Default)] +#[derive(Default, Clone)] pub struct TransactionDraft { - inputs: Vec, - outputs: HashMap, + inputs: Vec, + outputs: HashMap, feerate: u64, generated: Option, } @@ -29,8 +36,8 @@ pub trait Step { draft: &TransactionDraft, message: Message, ) -> Command; - - fn apply(&self, draft: &mut TransactionDraft); + fn apply(&self, _draft: &mut TransactionDraft) {} + fn load(&mut self, _draft: &TransactionDraft) {} } pub struct ChooseRecipients { @@ -72,11 +79,11 @@ impl Step for ChooseRecipients { } fn apply(&self, draft: &mut TransactionDraft) { - let mut outputs: HashMap = HashMap::new(); + let mut outputs: HashMap = HashMap::new(); for recipient in &self.recipients { outputs.insert( Address::from_str(&recipient.address.value).expect("Checked before"), - Amount::from_sat(recipient.amount().expect("Checked before")), + recipient.amount().expect("Checked before"), ); } draft.outputs = outputs; @@ -168,29 +175,56 @@ impl Recipient { #[derive(Default)] pub struct ChooseFeerate { feerate: form::Value, + generated: Option, + warning: Option, } impl Step for ChooseFeerate { fn update( &mut self, - _daemon: Arc, + daemon: Arc, _cache: &Cache, - _draft: &TransactionDraft, + draft: &TransactionDraft, message: Message, ) -> Command { - if let Message::View(view::Message::CreateSpend(view::CreateSpendMessage::FeerateEdited( - s, - ))) = message - { - if s.parse::().is_ok() { - self.feerate.value = s; - self.feerate.valid = true; - } else if s.is_empty() { - self.feerate.value = "".to_string(); - self.feerate.valid = true; - } else { - self.feerate.valid = false; + match message { + Message::View(view::Message::CreateSpend(view::CreateSpendMessage::FeerateEdited( + s, + ))) => { + if s.parse::().is_ok() { + self.feerate.value = s; + self.feerate.valid = true; + } else if s.is_empty() { + self.feerate.value = "".to_string(); + self.feerate.valid = true; + } else { + self.feerate.valid = false; + } + self.warning = None; } + Message::View(view::Message::CreateSpend(view::CreateSpendMessage::Generate)) => { + let inputs: Vec = draft.inputs.iter().map(|c| c.outpoint).collect(); + let outputs = draft.outputs.clone(); + let feerate_vb = self.feerate.value.parse::().unwrap_or(0); + self.warning = None; + return Command::perform( + async move { + daemon + .create_spend_tx(&inputs, &outputs, feerate_vb) + .map(|res| res.psbt) + .map_err(|e| e.into()) + }, + Message::Psbt, + ); + } + Message::Psbt(res) => match res { + Ok(psbt) => { + self.generated = Some(psbt); + return Command::perform(async {}, |_| Message::View(view::Message::Next)); + } + Err(e) => self.warning = Some(e), + }, + _ => {} } Command::none() @@ -198,12 +232,14 @@ impl Step for ChooseFeerate { fn apply(&self, draft: &mut TransactionDraft) { draft.feerate = self.feerate.value.parse::().expect("Checked before"); + draft.generated = self.generated.clone(); } fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> { view::spend::step::choose_feerate_view( &self.feerate, self.feerate.valid && !self.feerate.value.is_empty(), + self.warning.as_ref(), ) } } @@ -219,7 +255,16 @@ pub struct ChooseCoins { impl ChooseCoins { pub fn new(coins: Vec) -> Self { Self { - coins: coins.into_iter().map(|c| (c, false)).collect(), + coins: coins + .into_iter() + .filter_map(|c| { + if c.spend_info.is_none() { + Some((c, false)) + } else { + None + } + }) + .collect(), is_valid: false, total_needed: None, } @@ -227,11 +272,17 @@ impl ChooseCoins { } impl Step for ChooseCoins { + fn load(&mut self, draft: &TransactionDraft) { + self.total_needed = Some(Amount::from_sat( + draft.outputs.values().fold(0, |acc, a| acc + *a), + )); + } + fn update( &mut self, _daemon: Arc, _cache: &Cache, - draft: &TransactionDraft, + _draft: &TransactionDraft, message: Message, ) -> Command { if let Message::View(view::Message::CreateSpend(view::CreateSpendMessage::SelectCoin(i))) = @@ -241,19 +292,18 @@ impl Step for ChooseCoins { coin.1 = !coin.1; } - let total_needed = draft - .outputs - .values() - .fold(Amount::from_sat(0), |acc, a| acc + *a); - self.is_valid = self .coins .iter() - .filter_map(|(coin, selected)| if *selected { Some(coin.amount) } else { None }) - .sum::() - > total_needed; - - self.total_needed = Some(total_needed); + .filter_map(|(coin, selected)| { + if *selected { + Some(coin.amount.to_sat()) + } else { + None + } + }) + .sum::() + > self.total_needed.map(|a| a.to_sat()).unwrap_or(0); } Command::none() @@ -263,7 +313,7 @@ impl Step for ChooseCoins { draft.inputs = self .coins .iter() - .filter_map(|(coin, selected)| if *selected { Some(coin.outpoint) } else { None }) + .filter_map(|(coin, selected)| if *selected { Some(*coin) } else { None }) .collect(); } @@ -271,3 +321,62 @@ impl Step for ChooseCoins { view::spend::step::choose_coins_view(&self.coins, self.total_needed.as_ref(), self.is_valid) } } + +pub struct SaveSpend { + config: Config, + spend: Option, +} + +impl SaveSpend { + pub fn new(config: Config) -> Self { + Self { + config, + spend: None, + } + } +} + +impl Step for SaveSpend { + fn load(&mut self, draft: &TransactionDraft) { + let outputs_script_pubkeys: Vec