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/cache.rs b/gui/src/app/cache.rs index 39ede414..693fc8ca 100644 --- a/gui/src/app/cache.rs +++ b/gui/src/app/cache.rs @@ -1,7 +1,20 @@ -use crate::daemon::model::Coin; +use crate::daemon::model::{Coin, SpendTx}; +use minisafe::miniscript::bitcoin::Network; -#[derive(Default)] pub struct Cache { + pub network: Network, pub blockheight: i32, pub coins: Vec, + pub spend_txs: Vec, +} + +impl std::default::Default for Cache { + fn default() -> Self { + Self { + network: Network::Bitcoin, + blockheight: 0, + coins: Vec::new(), + spend_txs: Vec::new(), + } + } } 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/menu.rs b/gui/src/app/menu.rs index 8f1ba2f8..65baad12 100644 --- a/gui/src/app/menu.rs +++ b/gui/src/app/menu.rs @@ -2,6 +2,8 @@ pub enum Menu { Home, Receive, + Spend, Settings, Coins, + CreateSpendTx, } diff --git a/gui/src/app/message.rs b/gui/src/app/message.rs index 9393c34e..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)] @@ -15,4 +22,9 @@ pub enum Message { BlockHeight(Result), 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 53fc656e..f9338f06 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, CreateSpendPanel, Home, ReceivePanel, SpendPanel, State}; use crate::{ app::{cache::Cache, error::Error, menu::Menu}, @@ -65,6 +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.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()) } @@ -102,6 +106,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/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..b60737b6 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::{CreateSpendPanel, SpendPanel}; pub trait State { fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message>; @@ -36,14 +38,30 @@ 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(), + ), } } } 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 +101,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( @@ -133,6 +152,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/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/state/spend/detail.rs b/gui/src/app/state/spend/detail.rs new file mode 100644 index 00000000..a1e01de8 --- /dev/null +++ b/gui/src/app/state/spend/detail.rs @@ -0,0 +1,369 @@ +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, + cache.network, + ) + } +} + +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 new file mode 100644 index 00000000..a8c4d607 --- /dev/null +++ b/gui/src/app/state/spend/mod.rs @@ -0,0 +1,178 @@ +mod detail; +mod step; +use std::sync::Arc; + +use iced::{pure::Element, Command}; + +use super::{redirect, State}; +use crate::{ + app::{cache::Cache, config::Config, error::Error, menu::Menu, message::Message, view}, + daemon::{ + model::{Coin, SpendTx}, + Daemon, + }, +}; + +pub struct SpendPanel { + config: Config, + selected_tx: Option, + spend_txs: Vec, + warning: Option, +} + +impl SpendPanel { + pub fn new(config: Config, spend_txs: &[SpendTx]) -> Self { + Self { + config, + 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> { + 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, + 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::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() + } + + fn load(&self, daemon: Arc) -> Command { + let daemon = daemon.clone(); + Command::perform( + async move { daemon.list_spend_transactions().map_err(|e| e.into()) }, + Message::SpendTxs, + ) + } +} + +impl From for Box { + fn from(s: SpendPanel) -> Box { + Box::new(s) + } +} + +pub struct CreateSpendPanel { + draft: step::TransactionDraft, + current: usize, + steps: Vec>, +} + +impl CreateSpendPanel { + pub fn new(config: Config, 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()), + Box::new(step::SaveSpend::new(config)), + ], + } + } +} + +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 matches!(message, Message::View(view::Message::Next)) { + if let Some(step) = self.steps.get(self.current) { + step.apply(&mut self.draft); + } + + if let Some(step) = self.steps.get_mut(self.current + 1) { + self.current += 1; + step.load(&self.draft); + } + } + + if matches!(message, Message::View(view::Message::Previous)) + && 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, &self.draft, 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..0467cbef --- /dev/null +++ b/gui/src/app/state/spend/step.rs @@ -0,0 +1,381 @@ +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::Arc; + +use iced::pure::Element; +use iced::Command; +use minisafe::miniscript::bitcoin::{ + util::psbt::Psbt, Address, Amount, Denomination, OutPoint, Script, +}; + +use crate::{ + app::{ + cache::Cache, config::Config, error::Error, message::Message, state::spend::detail, view, + }, + daemon::{ + model::{Coin, SpendTx}, + Daemon, + }, + ui::component::form, +}; + +#[derive(Default, Clone)] +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, + draft: &TransactionDraft, + message: Message, + ) -> Command; + fn apply(&self, _draft: &mut TransactionDraft) {} + fn load(&mut self, _draft: &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, + _draft: &TransactionDraft, + message: Message, + ) -> Command { + if let Message::View(view::Message::CreateSpend(msg)) = message { + match &msg { + view::CreateSpendMessage::AddRecipient => { + self.recipients.push(Recipient::default()); + } + view::CreateSpendMessage::DeleteRecipient(i) => { + self.recipients.remove(*i); + } + view::CreateSpendMessage::RecipientEdited(i, _, _) => { + self.recipients.get_mut(*i).unwrap().update(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"), + 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) + } +} + +#[derive(Default)] +pub struct ChooseFeerate { + feerate: form::Value, + generated: Option, + warning: Option, +} + +impl Step for ChooseFeerate { + fn update( + &mut self, + daemon: Arc, + _cache: &Cache, + draft: &TransactionDraft, + message: Message, + ) -> Command { + 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() + } + + 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(), + ) + } +} + +#[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() + .filter_map(|c| { + if c.spend_info.is_none() { + Some((c, false)) + } else { + None + } + }) + .collect(), + is_valid: false, + total_needed: None, + } + } +} + +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, + 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; + } + + self.is_valid = self + .coins + .iter() + .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() + } + + fn apply(&self, draft: &mut TransactionDraft) { + draft.inputs = self + .coins + .iter() + .filter_map(|(coin, selected)| if *selected { Some(*coin) } 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) + } +} + +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