diff --git a/gui/Cargo.lock b/gui/Cargo.lock index cf42e3a5..0a06a23f 100644 --- a/gui/Cargo.lock +++ b/gui/Cargo.lock @@ -1640,7 +1640,7 @@ dependencies = [ [[package]] name = "liana" version = "0.0.1" -source = "git+https://github.com/revault/liana?branch=master#d385e99726733851da9101557cf2dfcbe4c2a2a0" +source = "git+https://github.com/revault/liana?branch=master#ad1f0e20b1a4c46007839cc20200e348eb041275" dependencies = [ "backtrace", "base64", diff --git a/gui/src/app/menu.rs b/gui/src/app/menu.rs index 65baad12..f373a132 100644 --- a/gui/src/app/menu.rs +++ b/gui/src/app/menu.rs @@ -6,4 +6,5 @@ pub enum Menu { Settings, Coins, CreateSpendTx, + Recovery, } diff --git a/gui/src/app/mod.rs b/gui/src/app/mod.rs index 8ac1a17b..610e1f61 100644 --- a/gui/src/app/mod.rs +++ b/gui/src/app/mod.rs @@ -20,7 +20,7 @@ pub use liana::config::Config as DaemonConfig; pub use config::Config; pub use message::Message; -use state::{CoinsPanel, CreateSpendPanel, Home, ReceivePanel, SpendPanel, State}; +use state::{CoinsPanel, CreateSpendPanel, Home, ReceivePanel, RecoveryPanel, SpendPanel, State}; use crate::{ app::{cache::Cache, error::Error, menu::Menu}, @@ -69,6 +69,13 @@ impl App { self.daemon.config().main_descriptor.timelock_value(), ) .into(), + menu::Menu::Recovery => RecoveryPanel::new( + self.config.clone(), + &self.cache.coins, + self.daemon.config().main_descriptor.timelock_value(), + self.cache.blockheight as u32, + ) + .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( diff --git a/gui/src/app/state/mod.rs b/gui/src/app/state/mod.rs index 094f3abd..6e0c46f1 100644 --- a/gui/src/app/state/mod.rs +++ b/gui/src/app/state/mod.rs @@ -1,4 +1,5 @@ mod coins; +mod recovery; mod settings; mod spend; @@ -17,6 +18,7 @@ use crate::daemon::{ Daemon, }; pub use coins::CoinsPanel; +pub use recovery::RecoveryPanel; pub use settings::SettingsState; pub use spend::{CreateSpendPanel, SpendPanel}; diff --git a/gui/src/app/state/recovery.rs b/gui/src/app/state/recovery.rs new file mode 100644 index 00000000..295f3bc1 --- /dev/null +++ b/gui/src/app/state/recovery.rs @@ -0,0 +1,230 @@ +use std::str::FromStr; +use std::sync::Arc; + +use iced::{Command, Element}; + +use crate::{ + app::{ + cache::Cache, + config::Config, + error::Error, + menu::Menu, + message::Message, + state::{redirect, State}, + view, + }, + daemon::{ + model::{remaining_sequence, Coin}, + Daemon, + }, + hw::{list_hardware_wallets, HardwareWallet}, + ui::component::form, +}; + +use liana::miniscript::bitcoin::{util::psbt::Psbt, Address, Amount, Network}; + +pub struct RecoveryPanel { + config: Config, + locked_coins: (usize, Amount), + recoverable_coins: (usize, Amount), + warning: Option, + feerate: form::Value, + recipient: form::Value, + generated: Option, + hws: Vec, + selected_hw: Option, + signed: bool, + /// timelock value to pass for the heir to consume a coin. + timelock: u32, +} + +impl RecoveryPanel { + pub fn new(config: Config, coins: &[Coin], timelock: u32, blockheight: u32) -> Self { + let mut locked_coins = (0, Amount::from_sat(0)); + let mut recoverable_coins = (0, Amount::from_sat(0)); + for coin in coins { + if coin.spend_info.is_none() { + if remaining_sequence(coin, blockheight, timelock) != 0 { + locked_coins.0 += 1; + locked_coins.1 += coin.amount; + } else { + recoverable_coins.0 += 1; + recoverable_coins.1 += coin.amount; + } + } + } + Self { + config, + locked_coins, + recoverable_coins, + warning: None, + feerate: form::Value::default(), + recipient: form::Value::default(), + generated: None, + timelock, + hws: Vec::new(), + selected_hw: None, + signed: false, + } + } +} + +impl State for RecoveryPanel { + fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> { + view::modal( + false, + self.warning.as_ref(), + view::recovery::recovery( + &self.locked_coins, + &self.recoverable_coins, + &self.feerate, + &self.recipient, + self.generated.as_ref(), + &self.hws, + self.selected_hw, + self.signed, + ), + None::>, + ) + } + + fn update( + &mut self, + daemon: Arc, + cache: &Cache, + message: Message, + ) -> Command { + match message { + Message::Coins(res) => match res { + Err(e) => self.warning = Some(e), + Ok(coins) => { + self.warning = None; + self.locked_coins = (0, Amount::from_sat(0)); + self.recoverable_coins = (0, Amount::from_sat(0)); + for coin in coins { + if coin.spend_info.is_none() { + if remaining_sequence(&coin, cache.blockheight as u32, self.timelock) + != 0 + { + self.locked_coins.0 += 1; + self.locked_coins.1 += coin.amount; + } else { + self.recoverable_coins.0 += 1; + self.recoverable_coins.1 += coin.amount; + } + } + } + } + }, + Message::ConnectedHardwareWallets(hws) => { + self.hws = hws; + } + Message::Psbt(res) => match res { + Ok(psbt) => self.generated = Some(psbt), + Err(e) => self.warning = Some(e), + }, + Message::Updated(res) => match res { + Err(e) => self.warning = Some(e), + Ok(()) => { + self.warning = None; + self.signed = true; + } + }, + Message::View(msg) => match msg { + view::Message::Reload => return self.load(daemon), + view::Message::Close => return redirect(Menu::Settings), + view::Message::Previous => self.generated = None, + view::Message::CreateSpend(view::CreateSpendMessage::RecipientEdited( + _, + "address", + address, + )) => { + self.recipient.value = address; + if let Ok(address) = Address::from_str(&self.recipient.value) { + if cache.network == Network::Bitcoin { + self.recipient.valid = address.network == Network::Bitcoin; + } else { + self.recipient.valid = address.network == Network::Testnet; + } + } else { + self.recipient.valid = false; + } + } + view::Message::CreateSpend(view::CreateSpendMessage::FeerateEdited(feerate)) => { + self.feerate.value = feerate; + self.feerate.valid = + self.feerate.value.parse::().is_ok() && self.feerate.value != "0"; + } + view::Message::Next => { + let address = Address::from_str(&self.recipient.value).expect("Checked before"); + let feerate_vb = self.feerate.value.parse::().expect("Checked before"); + self.warning = None; + return Command::perform( + async move { + daemon + .create_recovery(address, feerate_vb) + .map_err(|e| e.into()) + }, + Message::Psbt, + ); + } + view::Message::Spend(view::SpendTxMessage::SelectHardwareWallet(i)) => { + if let Some(hw) = self.hws.get(i) { + let device = hw.device.clone(); + self.selected_hw = Some(i); + let psbt = self.generated.clone().unwrap(); + return Command::perform( + send_funds(daemon, device, psbt), + Message::Updated, + ); + } + } + _ => {} + }, + _ => {} + }; + Command::none() + } + + fn load(&self, daemon: Arc) -> Command { + let config = self.config.clone(); + let desc = daemon.config().main_descriptor.to_string(); + let daemon = daemon.clone(); + Command::batch(vec![ + Command::perform( + async move { + daemon + .list_coins() + .map(|res| res.coins) + .map_err(|e| e.into()) + }, + Message::Coins, + ), + Command::perform( + list_hws(config, "Liana".to_string(), desc), + Message::ConnectedHardwareWallets, + ), + ]) + } +} + +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 send_funds( + daemon: Arc, + hw: std::sync::Arc, + mut psbt: Psbt, +) -> Result<(), Error> { + hw.sign_tx(&mut psbt).await.map_err(Error::from)?; + daemon.update_spend_tx(&psbt)?; + daemon.broadcast_spend_tx(&psbt.unsigned_tx.txid())?; + Ok(()) +} + +impl From for Box { + fn from(s: RecoveryPanel) -> Box { + Box::new(s) + } +} diff --git a/gui/src/app/view/hw.rs b/gui/src/app/view/hw.rs new file mode 100644 index 00000000..72cef21a --- /dev/null +++ b/gui/src/app/view/hw.rs @@ -0,0 +1,70 @@ +use iced::{ + widget::{Button, Column, Container, Row}, + Alignment, Element, Length, +}; + +use crate::{ + app::view::message::*, + hw::HardwareWallet, + ui::{ + color, + component::{ + button, card, + text::{text, Text}, + }, + icon, + util::Collection, + }, +}; + +pub fn hw_list_view<'a>( + i: usize, + hw: &HardwareWallet, + chosen: bool, + processing: bool, + signed: bool, +) -> Element<'a, Message> { + let mut bttn = Button::new( + Row::new() + .push( + Column::new() + .push(text(format!("{}", hw.kind)).bold()) + .push(text(format!("fingerprint: {}", hw.fingerprint)).small()) + .spacing(5) + .width(Length::Fill), + ) + .push_maybe(if chosen && processing { + Some( + Column::new() + .push(text("Processing...")) + .push(text("Please check your device").small()), + ) + } else { + None + }) + .push_maybe(if signed { + Some( + Column::new().push( + Row::new() + .spacing(5) + .push(icon::circle_check_icon().style(color::SUCCESS)) + .push(text("Signed").style(color::SUCCESS)), + ), + ) + } else { + None + }) + .align_items(Alignment::Center) + .width(Length::Fill), + ) + .padding(10) + .style(button::Style::Border.into()) + .width(Length::Fill); + if !processing { + bttn = bttn.on_press(Message::Spend(SpendTxMessage::SelectHardwareWallet(i))); + } + Container::new(bttn) + .width(Length::Fill) + .style(card::SimpleCardStyle) + .into() +} diff --git a/gui/src/app/view/mod.rs b/gui/src/app/view/mod.rs index 781441a0..627faa5c 100644 --- a/gui/src/app/view/mod.rs +++ b/gui/src/app/view/mod.rs @@ -3,7 +3,9 @@ mod warning; pub mod coins; pub mod home; +pub mod hw; pub mod receive; +pub mod recovery; pub mod settings; pub mod spend; diff --git a/gui/src/app/view/recovery.rs b/gui/src/app/view/recovery.rs new file mode 100644 index 00000000..3a7aeb3a --- /dev/null +++ b/gui/src/app/view/recovery.rs @@ -0,0 +1,202 @@ +use iced::{ + widget::{Button, Column, Container, Row, Space}, + Alignment, Element, Length, +}; + +use liana::miniscript::bitcoin::{util::psbt::Psbt, Amount}; + +use crate::{ + app::view::{ + hw::hw_list_view, + message::{CreateSpendMessage, Message}, + }, + hw::HardwareWallet, + ui::{ + component::{button, card, form, text::*}, + icon, + util::Collection, + }, +}; + +#[allow(clippy::too_many_arguments)] +pub fn recovery<'a>( + locked_coins: &(usize, Amount), + recoverable_coins: &(usize, Amount), + feerate: &form::Value, + address: &'a form::Value, + generated: Option<&Psbt>, + hws: &[HardwareWallet], + chosen_hw: Option, + done: bool, +) -> Element<'a, Message> { + Column::new() + .push(Space::with_height(Length::Units(100))) + .push( + Container::new(icon::recovery_icon().width(Length::Units(100)).size(50)) + .width(Length::Fill) + .center_x(), + ) + .push(text("Recover the funds").size(50).bold()) + .push( + Container::new(Row::new().push(text(format!( + "{} ({} coins) are recoverable at the current blockheight", + recoverable_coins.1, recoverable_coins.0 + )))) + .center_x(), + ) + .push_maybe(if *locked_coins != (0, Amount::from_sat(0)) { + Some( + Container::new(Row::new().push(text(format!( + "{} ({} coins) have their recovery path not available at the current blockheight", + locked_coins.1, locked_coins.0 + )))) + .center_x(), + ) + } else { + None + }) + .push(Space::with_height(Length::Units(20))) + .push( + if let Some(psbt) = generated { + if done { + Column::new() + .spacing(20) + .align_items(Alignment::Center) + .push(text("Funds were sweeped")) + .push(card::simple( + Column::new() + .push( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push(text(format!("{}", Amount::from_sat(psbt.unsigned_tx.output[0].value))).small().bold()) + .push(text(" to ").small()) + .push(text(&address.value).small().bold()) + ) + .push( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push(text(format!("Txid: {}", psbt.unsigned_tx.txid())).small()) + .push(Button::new(icon::clipboard_icon().small()) + .on_press(Message::Clipboard(psbt.unsigned_tx.txid().to_string())) + .style(button::Style::Border.into())) + ) + .push(Row::new().push(text(format!("Fees: {}", recoverable_coins.1 - Amount::from_sat(psbt.unsigned_tx.output[0].value))).small())) + )) + } else { + Column::new() + .spacing(20) + .align_items(Alignment::Center) + .push_maybe(if chosen_hw.is_none() { + Some(button::border(None, "< Previous").on_press(Message::Previous)) + } else { + None + } + ) + .push(text("2/2").bold()) + .push(text("Sign the transaction to sweep the funds").bold()) + .push(card::simple( + Column::new() + .push( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push(text(format!("{}", Amount::from_sat(psbt.unsigned_tx.output[0].value))).small().bold()) + .push(text(" to ").small()) + .push(text(&address.value).small().bold()) + ) + .push( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push(text(format!("Txid: {}", psbt.unsigned_tx.txid())).small()) + .push(Button::new(icon::clipboard_icon().small()) + .on_press(Message::Clipboard(psbt.unsigned_tx.txid().to_string())) + .style(button::Style::Border.into())) + ) + .push(Row::new().push(text(format!("Fees: {}", recoverable_coins.1 - Amount::from_sat(psbt.unsigned_tx.output[0].value))).small())) + ) + ) + .push(if !hws.is_empty() { + Column::new() + .push( + Row::new() + .align_items(Alignment::Center) + .push(text("Select hardware wallet to sign with:").bold().width(Length::Fill)) + .push_maybe( + if chosen_hw.is_none() { + Some(button::border(None, "Refresh").on_press(Message::Reload)) + } else { + None + } + ) + ) + .spacing(10) + .push( + hws.iter() + .enumerate() + .fold(Column::new().spacing(10), |col, (i, hw)| { + col.push(hw_list_view( + i, + hw, + Some(i) == chosen_hw, + chosen_hw.is_some(), + false, + )) + }), + ) + .max_width(500) + } else { + Column::new() + .push( + Column::new() + .spacing(20) + .width(Length::Fill) + .push("Please connect a hardware wallet") + .push(button::primary(None, "Refresh").on_press(Message::Reload)) + .align_items(Alignment::Center), + ) + .width(Length::Fill) + }) + } + } else { + Column::new() + .push(text("1/2").bold()) + .push(text("Enter destination address and feerate:").bold()) + .push( + Container::new( + form::Form::new("Feerate (sat/vbyte)", feerate, move |msg| { + Message::CreateSpend(CreateSpendMessage::FeerateEdited(msg)) + }) + .warning("Please enter correct feerate (sat/vbyte)") + .size(20) + .padding(10), + ) + .width(Length::Units(250)), + ) + .push( + Container::new( + form::Form::new("Address", address, move |msg| { + Message::CreateSpend(CreateSpendMessage::RecipientEdited(0, "address", msg)) + }) + .warning("Please enter correct bitcoin address") + .size(20) + .padding(10), + ) + .width(Length::Units(250)), + ) + .push(if feerate.valid && !feerate.value.is_empty() && address.valid && !address.value.is_empty() { + button::primary(None, "Next").on_press(Message::Next).width(Length::Units(200)) + } else { + button::primary(None, "Next") + .width(Length::Units(200)) + }) + .spacing(20) + .align_items(Alignment::Center) + } + ) + .align_items(Alignment::Center) + .spacing(20) + .into() +} diff --git a/gui/src/app/view/settings.rs b/gui/src/app/view/settings.rs index abde7e65..b5c9dfcd 100644 --- a/gui/src/app/view/settings.rs +++ b/gui/src/app/view/settings.rs @@ -2,7 +2,7 @@ use std::str::FromStr; use iced::{ alignment, - widget::{self, Column, Container, ProgressBar, Row}, + widget::{self, Column, Container, ProgressBar, Row, Space}, Alignment, Element, Length, }; @@ -32,7 +32,29 @@ pub fn list<'a>( &Menu::Settings, cache, warning, - widget::Column::with_children(settings).spacing(20), + widget::Column::with_children(settings) + .spacing(20) + .push(card::simple( + Column::new() + .push( + Row::new() + .push(badge::Badge::new(icon::recovery_icon())) + .push(text("Recovery").bold()) + .padding(10) + .spacing(20) + .align_items(Alignment::Center) + .width(Length::Fill), + ) + .push(separation().width(Length::Fill)) + .push(Space::with_height(Length::Units(10))) + .push(text("In case of loss of the main key, the recovery key can move the funds after a certain time.")) + .push(Space::with_height(Length::Units(10))) + .push( + Row::new() + .push(Space::with_width(Length::Fill)) + .push(button::primary(None, "Recover funds").on_press(Message::Menu(Menu::Recovery))), + ), + )), ) } @@ -114,7 +136,7 @@ pub fn bitcoind_edit<'a>( .push( Row::new() .push(badge::Badge::new(icon::bitcoin_icon())) - .push(text("Bitcoind")) + .push(text("Bitcoind").bold()) .padding(10) .spacing(20) .align_items(Alignment::Center) @@ -201,7 +223,7 @@ pub fn bitcoind<'a>( .push( Row::new() .push(badge::Badge::new(icon::bitcoin_icon())) - .push(text("Bitcoind")) + .push(text("Bitcoind").bold()) .push(is_running_label(is_running)) .spacing(20) .align_items(Alignment::Center) @@ -261,7 +283,7 @@ pub fn rescan<'a>( .push( Row::new() .push(badge::Badge::new(icon::block_icon())) - .push(text("Rescan blockchain").width(Length::Fill)) + .push(text("Rescan blockchain").bold().width(Length::Fill)) .push_maybe(if success { Some(text("Rescan was successful").style(color::SUCCESS)) } else { diff --git a/gui/src/app/view/spend/detail.rs b/gui/src/app/view/spend/detail.rs index a5417d8e..bc0329e0 100644 --- a/gui/src/app/view/spend/detail.rs +++ b/gui/src/app/view/spend/detail.rs @@ -8,12 +8,11 @@ use liana::miniscript::bitcoin::{util::bip32::Fingerprint, Address, Amount, Netw use crate::{ app::{ error::Error, - view::{message::*, warning::warn}, + view::{hw::hw_list_view, message::*, warning::warn}, }, daemon::model::{Coin, SpendStatus, SpendTx}, hw::HardwareWallet, ui::{ - color, component::{ badge, button, card, collapse::Collapse, @@ -515,55 +514,3 @@ pub fn sign_action<'a>( .width(Length::Fill) .into() } - -fn hw_list_view<'a>( - i: usize, - hw: &HardwareWallet, - chosen: bool, - processing: bool, - signed: bool, -) -> Element<'a, Message> { - let mut bttn = Button::new( - Row::new() - .push( - Column::new() - .push(text(format!("{}", hw.kind)).bold()) - .push(text(format!("fingerprint: {}", hw.fingerprint)).small()) - .spacing(5) - .width(Length::Fill), - ) - .push_maybe(if chosen && processing { - Some( - Column::new() - .push(text("Processing...")) - .push(text("Please check your device").small()), - ) - } else { - None - }) - .push_maybe(if signed { - Some( - Column::new().push( - Row::new() - .spacing(5) - .push(icon::circle_check_icon().style(color::SUCCESS)) - .push(text("Signed").style(color::SUCCESS)), - ), - ) - } else { - None - }) - .align_items(Alignment::Center) - .width(Length::Fill), - ) - .padding(10) - .style(button::Style::Border.into()) - .width(Length::Fill); - if !processing { - bttn = bttn.on_press(Message::Spend(SpendTxMessage::SelectHardwareWallet(i))); - } - Container::new(bttn) - .width(Length::Fill) - .style(card::SimpleCardStyle) - .into() -} diff --git a/gui/src/daemon/client/mod.rs b/gui/src/daemon/client/mod.rs index 8da41cbf..739ce05d 100644 --- a/gui/src/daemon/client/mod.rs +++ b/gui/src/daemon/client/mod.rs @@ -134,6 +134,14 @@ impl Daemon for Lianad { fn list_txs(&self, txids: &[Txid]) -> Result { self.call("listtransactions", Some(vec![txids])) } + + fn create_recovery(&self, address: Address, feerate_vb: u64) -> Result { + let res: CreateSpendResult = self.call( + "createrecovery", + Some(vec![json!(address), json!(feerate_vb)]), + )?; + Ok(res.psbt) + } } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/gui/src/daemon/embedded.rs b/gui/src/daemon/embedded.rs index ab75e9d5..76c8491f 100644 --- a/gui/src/daemon/embedded.rs +++ b/gui/src/daemon/embedded.rs @@ -193,4 +193,16 @@ impl Daemon for EmbeddedDaemon { .start_rescan(t) .map_err(|e| DaemonError::Unexpected(e.to_string())) } + + fn create_recovery(&self, address: Address, feerate_vb: u64) -> Result { + self.handle + .as_ref() + .ok_or(DaemonError::NoAnswer)? + .read() + .unwrap() + .control + .create_recovery(address, feerate_vb) + .map_err(|e| DaemonError::Unexpected(e.to_string())) + .map(|res| res.psbt) + } } diff --git a/gui/src/daemon/mod.rs b/gui/src/daemon/mod.rs index 41c68cdf..bcf687bc 100644 --- a/gui/src/daemon/mod.rs +++ b/gui/src/daemon/mod.rs @@ -68,6 +68,7 @@ pub trait Daemon: Debug { _end: u32, _limit: u64, ) -> Result; + fn create_recovery(&self, address: Address, feerate_vb: u64) -> Result; fn list_txs(&self, txid: &[Txid]) -> Result; fn list_spend_transactions(&self) -> Result, DaemonError> { diff --git a/gui/src/ui/icon.rs b/gui/src/ui/icon.rs index 7f3b9163..fc7d7c5f 100644 --- a/gui/src/ui/icon.rs +++ b/gui/src/ui/icon.rs @@ -13,6 +13,10 @@ fn icon(unicode: char) -> Text<'static> { .size(20) } +pub fn recovery_icon() -> Text<'static> { + icon('\u{F467}') +} + pub fn plug_icon() -> Text<'static> { icon('\u{F4F6}') }