gui: add path selection to recovery panel

This commit is contained in:
edouard 2023-03-31 15:01:25 +02:00
parent a42eb6d36a
commit ce23bcf498
7 changed files with 207 additions and 72 deletions

View File

@ -77,8 +77,7 @@ impl App {
menu::Menu::Recovery => RecoveryPanel::new(
self.wallet.clone(),
&self.cache.coins,
self.wallet.main_descriptor.first_timelock_value(),
self.cache.blockheight as u32,
self.cache.blockheight,
)
.into(),
menu::Menu::Receive => ReceivePanel::default().into(),

View File

@ -3,6 +3,7 @@ use std::sync::Arc;
use iced::Command;
use liana::miniscript::bitcoin::util::bip32::{DerivationPath, Fingerprint};
use liana_ui::{component::form, widget::Element};
use crate::{
@ -26,41 +27,24 @@ use liana::miniscript::bitcoin::{Address, Amount};
pub struct RecoveryPanel {
wallet: Arc<Wallet>,
locked_coins: (usize, Amount),
recoverable_coins: (usize, Amount),
recovery_paths: Vec<RecoveryPath>,
selected_path: Option<usize>,
warning: Option<Error>,
feerate: form::Value<String>,
recipient: form::Value<String>,
generated: Option<detail::SpendTxState>,
/// timelock value to pass for the heir to consume a coin.
timelock: u16,
}
impl RecoveryPanel {
pub fn new(wallet: Arc<Wallet>, coins: &[Coin], timelock: u16, 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() {
// recoverable coins are coins that can be recoverable next block.
if remaining_sequence(coin, blockheight, timelock) > 1 {
locked_coins.0 += 1;
locked_coins.1 += coin.amount;
} else {
recoverable_coins.0 += 1;
recoverable_coins.1 += coin.amount;
}
}
}
pub fn new(wallet: Arc<Wallet>, coins: &[Coin], blockheight: i32) -> Self {
Self {
recovery_paths: recovery_paths(&wallet, coins, blockheight),
wallet,
locked_coins,
recoverable_coins,
selected_path: None,
warning: None,
feerate: form::Value::default(),
recipient: form::Value::default(),
generated: None,
timelock,
}
}
}
@ -74,8 +58,26 @@ impl State for RecoveryPanel {
false,
self.warning.as_ref(),
view::recovery::recovery(
&self.locked_coins,
&self.recoverable_coins,
self.recovery_paths
.iter()
.enumerate()
.filter_map(|(i, path)| {
if path.number_of_coins > 0 {
Some(view::recovery::recovery_path_view(
i,
path.threshold,
&path.origins,
path.total_amount,
path.number_of_coins,
&self.wallet.keys_aliases,
self.selected_path == Some(i),
))
} else {
None
}
})
.collect(),
self.selected_path,
&self.feerate,
&self.recipient,
),
@ -95,22 +97,7 @@ impl State for RecoveryPanel {
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() {
// recoverable coins are coins that can be recoverable next block.
if remaining_sequence(&coin, cache.blockheight as u32, self.timelock)
> 1
{
self.locked_coins.0 += 1;
self.locked_coins.1 += coin.amount;
} else {
self.recoverable_coins.0 += 1;
self.recoverable_coins.1 += coin.amount;
}
}
}
self.recovery_paths = recovery_paths(&self.wallet, &coins, cache.blockheight);
}
},
Message::Recovery(res) => match res {
@ -134,6 +121,13 @@ impl State for RecoveryPanel {
self.recipient.valid = false;
}
}
view::Message::CreateSpend(view::CreateSpendMessage::SelectPath(index)) => {
if Some(index) == self.selected_path {
self.selected_path = None;
} else {
self.selected_path = Some(index);
}
}
view::Message::CreateSpend(view::CreateSpendMessage::FeerateEdited(feerate)) => {
self.feerate.value = feerate;
self.feerate.valid =
@ -144,9 +138,13 @@ impl State for RecoveryPanel {
let feerate_vb = self.feerate.value.parse::<u64>().expect("Checked before");
self.warning = None;
let desc = self.wallet.main_descriptor.clone();
let sequence = self
.recovery_paths
.get(self.selected_path.expect("A path must be selected"))
.map(|p| p.sequence);
return Command::perform(
async move {
let psbt = daemon.create_recovery(address, feerate_vb)?;
let psbt = daemon.create_recovery(address, feerate_vb, sequence)?;
let coins = daemon.list_coins().map(|res| res.coins)?;
let coins = coins
.iter()
@ -198,3 +196,43 @@ impl From<RecoveryPanel> for Box<dyn State> {
Box::new(s)
}
}
pub struct RecoveryPath {
threshold: usize,
sequence: u16,
origins: Vec<(Fingerprint, DerivationPath)>,
total_amount: Amount,
number_of_coins: usize,
}
fn recovery_paths(wallet: &Wallet, coins: &[Coin], blockheight: i32) -> Vec<RecoveryPath> {
wallet
.main_descriptor
.policy()
.recovery_paths()
.iter()
.map(|(&sequence, path)| {
let (number_of_coins, total_amount) = coins
.iter()
.filter(|coin| {
coin.spend_info.is_none()
&& remaining_sequence(coin, blockheight as u32, sequence) <= 1
})
.fold(
(0, Amount::from_sat(0)),
|(number_of_coins, total_amount), coin| {
(number_of_coins + 1, total_amount + coin.amount)
},
);
let (threshold, origins) = path.thresh_origins();
RecoveryPath {
total_amount,
number_of_coins,
sequence,
threshold,
origins: origins.into_iter().collect(),
}
})
.collect()
}

View File

@ -24,6 +24,7 @@ pub enum CreateSpendMessage {
SelectCoin(usize),
RecipientEdited(usize, &'static str, String),
FeerateEdited(String),
SelectPath(usize),
Generate,
}

View File

@ -1,20 +1,30 @@
use iced::{widget::Space, Alignment, Length};
use std::collections::HashMap;
use liana::miniscript::bitcoin::Amount;
use iced::{
widget::{tooltip, Space},
Alignment, Length,
};
use liana::miniscript::bitcoin::{
util::bip32::{DerivationPath, Fingerprint},
Amount,
};
use liana_ui::{
component::{button, form, text::*},
icon,
util::Collection,
icon, theme,
widget::*,
};
use crate::app::view::message::{CreateSpendMessage, Message};
use crate::app::view::{
message::{CreateSpendMessage, Message},
util::amount,
};
#[allow(clippy::too_many_arguments)]
pub fn recovery<'a>(
locked_coins: &(usize, Amount),
recoverable_coins: &(usize, Amount),
recovery_paths: Vec<Element<'a, Message>>,
selected_path: Option<usize>,
feerate: &form::Value<String>,
address: &'a form::Value<String>,
) -> Element<'a, Message> {
@ -30,23 +40,17 @@ pub fn recovery<'a>(
.spacing(1),
)
.push(
Container::new(Row::new().push(text(format!(
"{} ({} coins) will be spendable through the recovery path in the next block",
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) are not yet spendable through the recovery path",
locked_coins.1, locked_coins.0
))))
.center_x(),
Container::new(
Column::new()
.spacing(10)
.push(text(format!(
"{} recovery paths are available or will be available next block, select one:",
recovery_paths.len()
)))
.push(Column::with_children(recovery_paths).spacing(10)),
)
} else {
None
})
.padding(20),
)
.push(Space::with_height(Length::Units(20)))
.push(
Column::new()
@ -80,7 +84,7 @@ pub fn recovery<'a>(
&& !feerate.value.is_empty()
&& address.valid
&& !address.value.is_empty()
&& recoverable_coins.0 != 0
&& selected_path.is_some()
{
button::primary(None, "Next")
.on_press(Message::Next)
@ -96,3 +100,81 @@ pub fn recovery<'a>(
.spacing(20)
.into()
}
pub fn recovery_path_view<'a>(
index: usize,
threshold: usize,
origins: &'a [(Fingerprint, DerivationPath)],
total_amount: Amount,
number_of_coins: usize,
key_aliases: &'a HashMap<Fingerprint, String>,
selected: bool,
) -> Element<'a, Message> {
Container::new(
Button::new(
Row::new()
.push(if selected {
icon::square_check_icon()
} else {
icon::square_icon()
})
.push(
Column::new()
.push(
Row::new()
.align_items(Alignment::Center)
.spacing(10)
.push(
text(format!(
"{} signature{} from",
threshold,
if threshold > 1 { "s" } else { "" }
))
.bold(),
)
.push(origins.iter().fold(
Row::new().align_items(Alignment::Center).spacing(5),
|row, (fg, _)| {
row.push(if let Some(alias) = key_aliases.get(fg) {
Container::new(
tooltip::Tooltip::new(
Container::new(text(alias)).padding(3).style(
theme::Container::Pill(theme::Pill::Simple),
),
fg.to_string(),
tooltip::Position::Bottom,
)
.style(theme::Container::Card(theme::Card::Simple)),
)
} else {
Container::new(text(fg.to_string()))
.padding(3)
.style(theme::Container::Pill(theme::Pill::Simple))
})
},
)),
)
.push(
Row::new()
.spacing(5)
.push(text("can recover"))
.push(text(format!(
"{} coin{} totalling",
number_of_coins,
if number_of_coins > 0 { "s" } else { "" }
)))
.push(amount(&total_amount)),
),
)
.align_items(Alignment::Center)
.spacing(20),
)
.padding(10)
.width(Length::Fill)
.on_press(Message::CreateSpend(CreateSpendMessage::SelectPath(index)))
.style(theme::Button::TransparentBorder),
)
.style(theme::Container::Card(theme::Card::Simple))
.width(Length::Fill)
.into()
}

View File

@ -134,10 +134,15 @@ impl<C: Client + Debug> Daemon for Lianad<C> {
self.call("listtransactions", Some(vec![txids]))
}
fn create_recovery(&self, address: Address, feerate_vb: u64) -> Result<Psbt, DaemonError> {
fn create_recovery(
&self,
address: Address,
feerate_vb: u64,
sequence: Option<u16>,
) -> Result<Psbt, DaemonError> {
let res: CreateSpendResult = self.call(
"createrecovery",
Some(vec![json!(address), json!(feerate_vb)]),
Some(vec![json!(address), json!(feerate_vb), json!(sequence)]),
)?;
Ok(res.psbt)
}

View File

@ -194,14 +194,19 @@ impl Daemon for EmbeddedDaemon {
.map_err(|e| DaemonError::Unexpected(e.to_string()))
}
fn create_recovery(&self, address: Address, feerate_vb: u64) -> Result<Psbt, DaemonError> {
fn create_recovery(
&self,
address: Address,
feerate_vb: u64,
sequence: Option<u16>,
) -> Result<Psbt, DaemonError> {
self.handle
.as_ref()
.ok_or(DaemonError::NoAnswer)?
.read()
.unwrap()
.control
.create_recovery(address, feerate_vb, None)
.create_recovery(address, feerate_vb, sequence)
.map_err(|e| DaemonError::Unexpected(e.to_string()))
.map(|res| res.psbt)
}

View File

@ -68,7 +68,12 @@ 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 create_recovery(
&self,
address: Address,
feerate_vb: u64,
sequence: Option<u16>,
) -> Result<Psbt, DaemonError>;
fn list_txs(&self, txid: &[Txid]) -> Result<model::ListTransactionsResult, DaemonError>;
fn list_spend_transactions(&self) -> Result<Vec<model::SpendTx>, DaemonError> {