gui: add path selection to recovery panel
This commit is contained in:
parent
a42eb6d36a
commit
ce23bcf498
@ -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(),
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ pub enum CreateSpendMessage {
|
||||
SelectCoin(usize),
|
||||
RecipientEdited(usize, &'static str, String),
|
||||
FeerateEdited(String),
|
||||
SelectPath(usize),
|
||||
Generate,
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user