Merge #205: gui: add recovery feature

f43d99f0fbd851d9817b3668b26953f81c77c1f9 gui: add panel recovery (edouard)
f2facdf1641eaa0241201dee23e13dec63e534cc gui: add create_recovery command to client (edouard)
f4afed4d6a14ad9ea515a69ebf0c584106423075 bump liana master (edouard)

Pull request description:

  After #182, this fixes #180.

ACKs for top commit:
  edouardparis:
    Self-ACK f43d99f0fbd851d9817b3668b26953f81c77c1f9

Tree-SHA512: 49fa2582b5ef405b3ac79ce621792918afe00b0f6fd64bce626f84c11fc97c2b96459f3daf424e625eda31842f85982b0bf76da71afd36bd848e9a4d8fe96c27
This commit is contained in:
edouard 2022-12-14 11:37:52 +01:00
commit a4299877eb
No known key found for this signature in database
GPG Key ID: E65F7A089C20DC8F
14 changed files with 569 additions and 61 deletions

2
gui/Cargo.lock generated
View File

@ -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",

View File

@ -6,4 +6,5 @@ pub enum Menu {
Settings,
Coins,
CreateSpendTx,
Recovery,
}

View File

@ -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(

View File

@ -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};

View File

@ -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<Error>,
feerate: form::Value<String>,
recipient: form::Value<String>,
generated: Option<Psbt>,
hws: Vec<HardwareWallet>,
selected_hw: Option<usize>,
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::<Element<view::Message>>,
)
}
fn update(
&mut self,
daemon: Arc<dyn Daemon + Sync + Send>,
cache: &Cache,
message: Message,
) -> Command<Message> {
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::<u64>().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::<u64>().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<dyn Daemon + Sync + Send>) -> Command<Message> {
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<HardwareWallet> {
list_hardware_wallets(&config.hardware_wallets, Some((&wallet_name, &descriptor))).await
}
async fn send_funds(
daemon: Arc<dyn Daemon + Sync + Send>,
hw: std::sync::Arc<dyn async_hwi::HWI + Send + Sync>,
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<RecoveryPanel> for Box<dyn State> {
fn from(s: RecoveryPanel) -> Box<dyn State> {
Box::new(s)
}
}

70
gui/src/app/view/hw.rs Normal file
View File

@ -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()
}

View File

@ -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;

View File

@ -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<String>,
address: &'a form::Value<String>,
generated: Option<&Psbt>,
hws: &[HardwareWallet],
chosen_hw: Option<usize>,
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()
}

View File

@ -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 {

View File

@ -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()
}

View File

@ -134,6 +134,14 @@ impl<C: Client + Debug> Daemon for Lianad<C> {
fn list_txs(&self, txids: &[Txid]) -> Result<ListTransactionsResult, DaemonError> {
self.call("listtransactions", Some(vec![txids]))
}
fn create_recovery(&self, address: Address, feerate_vb: u64) -> Result<Psbt, DaemonError> {
let res: CreateSpendResult = self.call(
"createrecovery",
Some(vec![json!(address), json!(feerate_vb)]),
)?;
Ok(res.psbt)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]

View File

@ -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<Psbt, DaemonError> {
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)
}
}

View File

@ -68,6 +68,7 @@ pub trait Daemon: Debug {
_end: u32,
_limit: u64,
) -> Result<model::ListTransactionsResult, DaemonError>;
fn create_recovery(&self, address: Address, feerate_vb: u64) -> Result<Psbt, DaemonError>;
fn list_txs(&self, txid: &[Txid]) -> Result<model::ListTransactionsResult, DaemonError>;
fn list_spend_transactions(&self) -> Result<Vec<model::SpendTx>, DaemonError> {

View File

@ -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}')
}