From 063786fe5485e4f5a857ed9c45f3fbec3fc5f69a Mon Sep 17 00:00:00 2001 From: edouard Date: Fri, 28 Oct 2022 15:36:31 +0200 Subject: [PATCH] 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