feat: enable coin control for recovery spends
The regular spend panel has been updated to allow for recovery spends. A recovery spend will use the same panel type as a regular spend, with an extra step added for selecting the recovery path.
This commit is contained in:
parent
c2f964d5b3
commit
c7bbbd854c
@ -30,8 +30,7 @@ pub use config::Config;
|
||||
pub use message::Message;
|
||||
|
||||
use state::{
|
||||
CoinsPanel, CreateSpendPanel, Home, PsbtsPanel, ReceivePanel, RecoveryPanel, State,
|
||||
TransactionsPanel,
|
||||
CoinsPanel, CreateSpendPanel, Home, PsbtsPanel, ReceivePanel, State, TransactionsPanel,
|
||||
};
|
||||
use wallet::{sync_status, SyncStatus};
|
||||
|
||||
@ -49,7 +48,7 @@ struct Panels {
|
||||
coins: CoinsPanel,
|
||||
transactions: TransactionsPanel,
|
||||
psbts: PsbtsPanel,
|
||||
recovery: RecoveryPanel,
|
||||
recovery: CreateSpendPanel,
|
||||
receive: ReceivePanel,
|
||||
create_spend: CreateSpendPanel,
|
||||
settings: SettingsState,
|
||||
@ -90,7 +89,7 @@ impl Panels {
|
||||
coins: CoinsPanel::new(&cache.coins, wallet.main_descriptor.first_timelock_value()),
|
||||
transactions: TransactionsPanel::new(wallet.clone()),
|
||||
psbts: PsbtsPanel::new(wallet.clone()),
|
||||
recovery: RecoveryPanel::new(wallet.clone(), &cache.coins, cache.blockheight),
|
||||
recovery: new_recovery_panel(wallet.clone(), cache),
|
||||
receive: ReceivePanel::new(data_dir.clone(), wallet.clone()),
|
||||
create_spend: CreateSpendPanel::new(
|
||||
wallet.clone(),
|
||||
@ -237,7 +236,7 @@ impl App {
|
||||
}
|
||||
menu::Menu::CreateSpendTx => {
|
||||
// redo the process of spending only if user want to start a new one.
|
||||
if !self.panels.create_spend.is_first_step() {
|
||||
if !self.panels.create_spend.keep_state() {
|
||||
self.panels.create_spend = CreateSpendPanel::new(
|
||||
self.wallet.clone(),
|
||||
&self.cache.coins,
|
||||
@ -246,6 +245,11 @@ impl App {
|
||||
);
|
||||
}
|
||||
}
|
||||
menu::Menu::Recovery => {
|
||||
if !self.panels.recovery.keep_state() {
|
||||
self.panels.recovery = new_recovery_panel(self.wallet.clone(), &self.cache);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
|
||||
@ -417,3 +421,12 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_recovery_panel(wallet: Arc<Wallet>, cache: &Cache) -> CreateSpendPanel {
|
||||
CreateSpendPanel::new_recovery(
|
||||
wallet,
|
||||
&cache.coins,
|
||||
cache.blockheight as u32,
|
||||
cache.network,
|
||||
)
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@ mod label;
|
||||
mod psbt;
|
||||
mod psbts;
|
||||
mod receive;
|
||||
mod recovery;
|
||||
mod settings;
|
||||
mod spend;
|
||||
mod transactions;
|
||||
@ -38,7 +37,6 @@ pub use coins::CoinsPanel;
|
||||
use label::LabelsEdited;
|
||||
pub use psbts::PsbtsPanel;
|
||||
pub use receive::ReceivePanel;
|
||||
pub use recovery::RecoveryPanel;
|
||||
pub use settings::SettingsState;
|
||||
pub use spend::CreateSpendPanel;
|
||||
pub use transactions::TransactionsPanel;
|
||||
|
||||
@ -1,268 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use iced::Task;
|
||||
|
||||
use liana::miniscript::bitcoin::{
|
||||
bip32::{DerivationPath, Fingerprint},
|
||||
secp256k1,
|
||||
};
|
||||
use liana_ui::{component::form, widget::Element};
|
||||
use lianad::commands::CoinStatus;
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
cache::Cache,
|
||||
error::Error,
|
||||
menu::Menu,
|
||||
message::Message,
|
||||
state::psbt,
|
||||
state::{redirect, State},
|
||||
view,
|
||||
wallet::Wallet,
|
||||
},
|
||||
daemon::{
|
||||
model::{remaining_sequence, Coin, SpendTx},
|
||||
Daemon,
|
||||
},
|
||||
};
|
||||
|
||||
use liana::miniscript::bitcoin::{Address, Amount};
|
||||
|
||||
pub struct RecoveryPanel {
|
||||
wallet: Arc<Wallet>,
|
||||
recovery_paths: Vec<RecoveryPath>,
|
||||
selected_path: Option<usize>,
|
||||
warning: Option<Error>,
|
||||
feerate: form::Value<String>,
|
||||
recipient: form::Value<String>,
|
||||
generated: Option<psbt::PsbtState>,
|
||||
}
|
||||
|
||||
impl RecoveryPanel {
|
||||
pub fn new(wallet: Arc<Wallet>, coins: &[Coin], blockheight: i32) -> Self {
|
||||
Self {
|
||||
recovery_paths: recovery_paths(&wallet, coins, blockheight),
|
||||
wallet,
|
||||
selected_path: None,
|
||||
warning: None,
|
||||
feerate: form::Value::default(),
|
||||
recipient: form::Value::default(),
|
||||
generated: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State for RecoveryPanel {
|
||||
fn subscription(&self) -> iced::Subscription<Message> {
|
||||
if let Some(psbt) = &self.generated {
|
||||
psbt.subscription()
|
||||
} else {
|
||||
iced::Subscription::none()
|
||||
}
|
||||
}
|
||||
|
||||
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
|
||||
if let Some(generated) = &self.generated {
|
||||
generated.view(cache)
|
||||
} else {
|
||||
view::recovery::recovery(
|
||||
cache,
|
||||
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,
|
||||
self.warning.as_ref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
cache: &Cache,
|
||||
message: Message,
|
||||
) -> Task<Message> {
|
||||
match message {
|
||||
Message::Coins(res) => match res {
|
||||
Err(e) => self.warning = Some(e),
|
||||
Ok(coins) => {
|
||||
self.warning = None;
|
||||
self.recovery_paths = recovery_paths(&self.wallet, &coins, cache.blockheight);
|
||||
}
|
||||
},
|
||||
Message::Recovery(res) => match res {
|
||||
Ok(tx) => {
|
||||
self.generated = Some(psbt::PsbtState::new(self.wallet.clone(), tx, false))
|
||||
}
|
||||
Err(e) => self.warning = Some(e),
|
||||
},
|
||||
Message::View(msg) => match msg {
|
||||
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) {
|
||||
self.recipient.valid = address.is_valid_for_network(cache.network);
|
||||
} else {
|
||||
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 =
|
||||
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;
|
||||
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);
|
||||
let network = cache.network;
|
||||
return Task::perform(
|
||||
async move {
|
||||
let psbt = daemon
|
||||
.create_recovery(address, &[], feerate_vb, sequence)
|
||||
.await?;
|
||||
let outpoints: Vec<_> = psbt
|
||||
.unsigned_tx
|
||||
.input
|
||||
.iter()
|
||||
.map(|txin| txin.previous_output)
|
||||
.collect();
|
||||
let coins = daemon
|
||||
.list_coins(&[], &outpoints)
|
||||
.await
|
||||
.map(|res| res.coins)?;
|
||||
Ok(SpendTx::new(
|
||||
None,
|
||||
psbt,
|
||||
coins,
|
||||
&desc,
|
||||
&secp256k1::Secp256k1::verification_only(),
|
||||
network,
|
||||
))
|
||||
},
|
||||
Message::Recovery,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
if let Some(generated) = &mut self.generated {
|
||||
return generated.update(daemon, cache, Message::View(msg));
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
if let Some(generated) = &mut self.generated {
|
||||
return generated.update(daemon, cache, message);
|
||||
}
|
||||
}
|
||||
};
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn reload(
|
||||
&mut self,
|
||||
daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
wallet: Arc<Wallet>,
|
||||
) -> Task<Message> {
|
||||
let daemon = daemon.clone();
|
||||
self.wallet = wallet;
|
||||
self.selected_path = None;
|
||||
self.warning = None;
|
||||
self.feerate = form::Value::default();
|
||||
self.recipient = form::Value::default();
|
||||
self.generated = None;
|
||||
Task::perform(
|
||||
async move {
|
||||
daemon
|
||||
.list_coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[])
|
||||
.await
|
||||
.map(|res| res.coins)
|
||||
.map_err(|e| e.into())
|
||||
},
|
||||
Message::Coins,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RecoveryPanel> for Box<dyn State> {
|
||||
fn from(s: RecoveryPanel) -> Box<dyn State> {
|
||||
Box::new(s)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RecoveryPath {
|
||||
threshold: usize,
|
||||
sequence: u16,
|
||||
origins: Vec<(Fingerprint, HashSet<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.block_height.is_some() // only confirmed coins are included in a recovery transaction
|
||||
&& 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()
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
mod step;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::convert::TryInto;
|
||||
use std::sync::Arc;
|
||||
|
||||
use iced::Task;
|
||||
@ -22,21 +23,66 @@ pub struct CreateSpendPanel {
|
||||
draft: step::TransactionDraft,
|
||||
current: usize,
|
||||
steps: Vec<Box<dyn step::Step>>,
|
||||
/// All coins that may be required by any of the steps in the panel.
|
||||
/// Additional filtering should be performed by individual steps.
|
||||
coins: Vec<Coin>,
|
||||
tip_height: i32,
|
||||
}
|
||||
|
||||
impl CreateSpendPanel {
|
||||
/// Create a new instance to be used for a primary path spend.
|
||||
pub fn new(wallet: Arc<Wallet>, coins: &[Coin], blockheight: u32, network: Network) -> Self {
|
||||
let descriptor = wallet.main_descriptor.clone();
|
||||
Self {
|
||||
draft: step::TransactionDraft::new(network),
|
||||
draft: step::TransactionDraft::new(network, None),
|
||||
current: 0,
|
||||
steps: vec![
|
||||
Box::new(
|
||||
step::DefineSpend::new(network, descriptor, coins)
|
||||
step::DefineSpend::new(network, descriptor, coins, blockheight, None, true)
|
||||
.with_coins_sorted(blockheight),
|
||||
),
|
||||
Box::new(step::SaveSpend::new(wallet)),
|
||||
],
|
||||
coins: coins.to_vec(),
|
||||
tip_height: blockheight.try_into().expect("i32 by consensus"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new instance to be used for a recovery spend.
|
||||
///
|
||||
/// By default, the wallet's first timelock value is used for `DefineSpend`.
|
||||
pub fn new_recovery(
|
||||
wallet: Arc<Wallet>,
|
||||
coins: &[Coin],
|
||||
blockheight: u32,
|
||||
network: Network,
|
||||
) -> Self {
|
||||
let descriptor = wallet.main_descriptor.clone();
|
||||
let timelock = descriptor.first_timelock_value();
|
||||
Self {
|
||||
draft: step::TransactionDraft::new(network, Some(timelock)),
|
||||
current: 0,
|
||||
steps: vec![
|
||||
Box::new(step::SelectRecoveryPath::new(
|
||||
wallet.clone(),
|
||||
coins,
|
||||
blockheight.try_into().expect("i32 by consensus"),
|
||||
)),
|
||||
Box::new(
|
||||
step::DefineSpend::new(
|
||||
network,
|
||||
descriptor,
|
||||
coins,
|
||||
blockheight,
|
||||
Some(timelock), // the recovery timelock must always be set to a value
|
||||
false,
|
||||
)
|
||||
.with_coins_sorted(blockheight),
|
||||
),
|
||||
Box::new(step::SaveSpend::new(wallet)),
|
||||
],
|
||||
coins: coins.to_vec(),
|
||||
tip_height: blockheight.try_into().expect("i32 by consensus"),
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,22 +95,30 @@ impl CreateSpendPanel {
|
||||
) -> Self {
|
||||
let descriptor = wallet.main_descriptor.clone();
|
||||
Self {
|
||||
draft: step::TransactionDraft::new(network),
|
||||
draft: step::TransactionDraft::new(network, None),
|
||||
current: 0,
|
||||
steps: vec![
|
||||
Box::new(
|
||||
step::DefineSpend::new(network, descriptor, coins)
|
||||
step::DefineSpend::new(network, descriptor, coins, blockheight, None, true)
|
||||
.with_preselected_coins(preselected_coins)
|
||||
.with_coins_sorted(blockheight)
|
||||
.self_send(),
|
||||
),
|
||||
Box::new(step::SaveSpend::new(wallet)),
|
||||
],
|
||||
coins: coins.to_vec(),
|
||||
tip_height: blockheight.try_into().expect("i32 by consensus"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_first_step(&self) -> bool {
|
||||
self.current == 0
|
||||
pub fn keep_state(&self) -> bool {
|
||||
if self.draft.is_recovery() {
|
||||
// For recovery spend, retain the state if user is on the first two steps
|
||||
// (choosing recovery path and defining spend)
|
||||
self.current < 2
|
||||
} else {
|
||||
self.current == 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,17 +152,29 @@ impl State for CreateSpendPanel {
|
||||
|
||||
if let Some(step) = self.steps.get_mut(self.current + 1) {
|
||||
self.current += 1;
|
||||
step.load(&self.draft);
|
||||
step.load(&self.coins, self.tip_height, &self.draft);
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(message, Message::View(view::Message::Previous)) {
|
||||
let previous = self.current.saturating_sub(1);
|
||||
if self.steps.get(previous).is_some() {
|
||||
if let Some(step) = self.steps.get_mut(previous) {
|
||||
self.current = previous;
|
||||
// For recovery spends, ensure all steps use the latest coins and tip height.
|
||||
// TODO: consider doing this for all spend kinds, not just recovery.
|
||||
if self.draft.is_recovery() {
|
||||
step.load(&self.coins, self.tip_height, &self.draft);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Message::CoinsTipHeight(Ok(coins), Ok(tip)) = &message {
|
||||
// Save the coins and tip for use in the `load()` method.
|
||||
self.coins = coins.clone();
|
||||
self.tip_height = *tip;
|
||||
// We still send this message to the current step below to update the values directly.
|
||||
}
|
||||
|
||||
if let Some(step) = self.steps.get_mut(self.current) {
|
||||
return step.update(daemon, cache, message);
|
||||
}
|
||||
@ -123,13 +189,20 @@ impl State for CreateSpendPanel {
|
||||
) -> Task<Message> {
|
||||
let daemon1 = daemon.clone();
|
||||
let daemon2 = daemon.clone();
|
||||
let coin_statuses_1 = if self.draft.is_recovery() {
|
||||
// only confirmed coins can be included in a recovery spend.
|
||||
vec![CoinStatus::Confirmed]
|
||||
} else {
|
||||
vec![CoinStatus::Unconfirmed, CoinStatus::Confirmed]
|
||||
};
|
||||
let coin_statuses_2 = coin_statuses_1.clone();
|
||||
Task::batch(vec![
|
||||
Task::perform(
|
||||
async move {
|
||||
(
|
||||
daemon1
|
||||
.clone()
|
||||
.list_coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[])
|
||||
.list_coins(&coin_statuses_1, &[])
|
||||
.await
|
||||
.map(|res| res.coins)
|
||||
.map_err(|e| e.into()),
|
||||
@ -145,7 +218,7 @@ impl State for CreateSpendPanel {
|
||||
Task::perform(
|
||||
async move {
|
||||
let coins = daemon
|
||||
.list_coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[])
|
||||
.list_coins(&coin_statuses_2, &[])
|
||||
.await
|
||||
.map(|res| res.coins)
|
||||
.map_err(Error::from)?;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
use std::{
|
||||
cmp::Ordering,
|
||||
collections::{HashMap, HashSet},
|
||||
convert::TryInto,
|
||||
iter::FromIterator,
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
@ -10,7 +11,10 @@ use iced::{Subscription, Task};
|
||||
use liana::{
|
||||
descriptors::LianaDescriptor,
|
||||
miniscript::bitcoin::{
|
||||
address, psbt::Psbt, secp256k1, Address, Amount, Denomination, Network, OutPoint,
|
||||
address,
|
||||
bip32::{DerivationPath, Fingerprint},
|
||||
psbt::Psbt,
|
||||
secp256k1, Address, Amount, Denomination, Network, OutPoint,
|
||||
},
|
||||
spend::{SpendCreationError, MAX_FEERATE},
|
||||
};
|
||||
@ -37,10 +41,16 @@ pub struct TransactionDraft {
|
||||
generated: Option<(Psbt, Vec<String>)>,
|
||||
batch_label: Option<String>,
|
||||
labels: HashMap<String, String>,
|
||||
/// The timelock of the recovery path to use for spending.
|
||||
///
|
||||
/// If the primary path will be used, this value will remain as `None`.
|
||||
/// Otherwise, its value should always be set to a recovery path,
|
||||
/// which may change from one to another.
|
||||
recovery_timelock: Option<u16>,
|
||||
}
|
||||
|
||||
impl TransactionDraft {
|
||||
pub fn new(network: Network) -> Self {
|
||||
pub fn new(network: Network, recovery_timelock: Option<u16>) -> Self {
|
||||
Self {
|
||||
network,
|
||||
inputs: Vec::new(),
|
||||
@ -48,8 +58,13 @@ impl TransactionDraft {
|
||||
generated: None,
|
||||
batch_label: None,
|
||||
labels: HashMap::new(),
|
||||
recovery_timelock,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_recovery(&self) -> bool {
|
||||
self.recovery_timelock.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Step {
|
||||
@ -62,7 +77,7 @@ pub trait Step {
|
||||
) -> Task<Message>;
|
||||
fn apply(&self, _draft: &mut TransactionDraft) {}
|
||||
fn interrupt(&mut self) {}
|
||||
fn load(&mut self, _draft: &TransactionDraft) {}
|
||||
fn load(&mut self, _coins: &[Coin], _tip_height: i32, _draft: &TransactionDraft) {}
|
||||
fn subscription(&self) -> Subscription<Message> {
|
||||
Subscription::none()
|
||||
}
|
||||
@ -71,19 +86,41 @@ pub trait Step {
|
||||
/// Filter the coins that should be available for selection.
|
||||
///
|
||||
/// `selected` will be `None` if the coins are being filtered for the first time.
|
||||
/// In that case, no coins will be selected.
|
||||
fn filter_coins(coins: &[Coin], selected: Option<HashSet<OutPoint>>) -> Vec<(Coin, bool)> {
|
||||
/// In that case, all suitable coins will be selected for a recovery spend,
|
||||
/// while for a primary path spend, no coins will be selected.
|
||||
fn filter_coins(
|
||||
coins: &[Coin],
|
||||
recovery_timelock: Option<u16>,
|
||||
tip_height: i32,
|
||||
selected: Option<HashSet<OutPoint>>,
|
||||
) -> Vec<(Coin, bool)> {
|
||||
coins
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
if c.spend_info.is_none() && !c.is_immature {
|
||||
Some((
|
||||
c.clone(),
|
||||
selected
|
||||
.as_ref()
|
||||
.map(|sel| sel.contains(&c.outpoint))
|
||||
.unwrap_or(false),
|
||||
))
|
||||
if let Some(recovery_timelock) = recovery_timelock {
|
||||
c.block_height
|
||||
.filter(|bh| {
|
||||
tip_height + 1 >= bh + <u16 as Into<i32>>::into(recovery_timelock)
|
||||
})
|
||||
.map(|_| {
|
||||
(
|
||||
c.clone(),
|
||||
selected
|
||||
.as_ref()
|
||||
.map(|sel| sel.contains(&c.outpoint))
|
||||
.unwrap_or(true),
|
||||
)
|
||||
})
|
||||
} else {
|
||||
Some((
|
||||
c.clone(),
|
||||
selected
|
||||
.as_ref()
|
||||
.map(|sel| sel.contains(&c.outpoint))
|
||||
.unwrap_or(false),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@ -97,7 +134,7 @@ pub struct DefineSpend {
|
||||
/// receive the max amount.
|
||||
send_max_to_recipient: Option<usize>,
|
||||
/// Will be `true` if coins for spend were manually selected by user.
|
||||
/// Otherwise, will be `false` (including for self-send).
|
||||
/// Otherwise, will be `false` (including for self-send & recovery).
|
||||
is_user_coin_selection: bool,
|
||||
is_valid: bool,
|
||||
is_duplicate: bool,
|
||||
@ -105,6 +142,13 @@ pub struct DefineSpend {
|
||||
network: Network,
|
||||
descriptor: LianaDescriptor,
|
||||
curve: secp256k1::Secp256k1<secp256k1::VerifyOnly>,
|
||||
/// Leave as `None` for a primary path spend. Otherwise, this is the timelock
|
||||
/// corresponding to the recovery path to use for the spend.
|
||||
///
|
||||
/// For a recovery path spend, this value can change from one timelock to another, but
|
||||
/// it must never be set to `None`.
|
||||
recovery_timelock: Option<u16>,
|
||||
tip_height: u32,
|
||||
coins: Vec<(Coin, bool)>,
|
||||
coins_labels: HashMap<String, String>,
|
||||
batch_label: form::Value<String>,
|
||||
@ -113,29 +157,48 @@ pub struct DefineSpend {
|
||||
fee_amount: Option<Amount>,
|
||||
generated: Option<(Psbt, Vec<String>)>,
|
||||
warning: Option<Error>,
|
||||
/// Whether this is the first step of the spend creation.
|
||||
/// Required in order to know whether the user can navigate to a previous step.
|
||||
is_first_step: bool,
|
||||
}
|
||||
|
||||
impl DefineSpend {
|
||||
pub fn new(network: Network, descriptor: LianaDescriptor, coins: &[Coin]) -> Self {
|
||||
let coins = filter_coins(coins, None);
|
||||
pub fn new(
|
||||
network: Network,
|
||||
descriptor: LianaDescriptor,
|
||||
coins: &[Coin],
|
||||
tip_height: u32,
|
||||
recovery_timelock: Option<u16>,
|
||||
is_first_step: bool,
|
||||
) -> Self {
|
||||
let coins = filter_coins(
|
||||
coins,
|
||||
recovery_timelock,
|
||||
tip_height.try_into().expect("i32 by consensus"),
|
||||
None,
|
||||
);
|
||||
|
||||
Self {
|
||||
network,
|
||||
descriptor,
|
||||
curve: secp256k1::Secp256k1::verification_only(),
|
||||
recovery_timelock,
|
||||
tip_height,
|
||||
generated: None,
|
||||
coins,
|
||||
coins_labels: HashMap::new(),
|
||||
batch_label: form::Value::default(),
|
||||
recipients: vec![Recipient::default()],
|
||||
send_max_to_recipient: None,
|
||||
is_user_coin_selection: false, // Start with auto-selection until user edits selection.
|
||||
recipients: vec![Recipient::new(recovery_timelock.is_some())],
|
||||
// For recovery, send max to the (single) recipient.
|
||||
send_max_to_recipient: recovery_timelock.map(|_| 0),
|
||||
is_user_coin_selection: false,
|
||||
is_valid: false,
|
||||
is_duplicate: false,
|
||||
feerate: form::Value::default(),
|
||||
fee_amount: None,
|
||||
amount_left_to_select: None,
|
||||
warning: None,
|
||||
is_first_step,
|
||||
}
|
||||
}
|
||||
|
||||
@ -175,9 +238,11 @@ impl DefineSpend {
|
||||
|
||||
/// This is used for calculating a coin's remaining sequence.
|
||||
///
|
||||
/// It returns the descriptor's first timelock.
|
||||
/// Use the first timelock if this is a primary path spend and otherwise the same
|
||||
/// timelock as used for the recovery.
|
||||
pub fn timelock(&self) -> u16 {
|
||||
self.descriptor.first_timelock_value()
|
||||
self.recovery_timelock
|
||||
.unwrap_or_else(|| self.descriptor.first_timelock_value())
|
||||
}
|
||||
|
||||
// If `is_redraft`, the validation of recipients will take into account
|
||||
@ -235,6 +300,9 @@ impl DefineSpend {
|
||||
return;
|
||||
}
|
||||
let is_self_transfer = self.recipients.is_empty();
|
||||
// Define the destinations for a primary path spend from all non-max recipients.
|
||||
// TODO: Set this variable only in the non-recovery case. For now, we use it later below
|
||||
// for setting `amount_left_to_select`, which is only required in the non-recovery case.
|
||||
let destinations: HashMap<Address<address::NetworkUnchecked>, u64> = self
|
||||
.recipients
|
||||
.iter()
|
||||
@ -266,7 +334,14 @@ impl DefineSpend {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let outpoints = if self.is_user_coin_selection || is_self_transfer {
|
||||
let outpoints = if self.is_user_coin_selection
|
||||
|| is_self_transfer
|
||||
|| self.recovery_timelock.is_some()
|
||||
{
|
||||
// If user has edited selection, or otherwise for self-transfers and recovery spends, we pass the outpoints list.
|
||||
// (We could also in principle pass an empty outpoints list for a recovery spend if all should
|
||||
// be selected, i.e. not user edited, but this way we don't need to worry about checking selected
|
||||
// coins match those used in the recovery).
|
||||
let outpoints: Vec<_> = self
|
||||
.coins
|
||||
.iter()
|
||||
@ -291,6 +366,7 @@ impl DefineSpend {
|
||||
// Simply set the amount left to select as the total destination value. Note this
|
||||
// doesn't take account of the fee, but passing an empty list to `create_spend_tx`
|
||||
// would use auto-selection and so we settle for this approximation.
|
||||
// Note that for a recovery, the amount left to select is ignored by the view.
|
||||
self.amount_left_to_select = Some(Amount::from_sat(destinations.values().sum()));
|
||||
self.fee_amount = None;
|
||||
return;
|
||||
@ -307,10 +383,10 @@ impl DefineSpend {
|
||||
};
|
||||
|
||||
// If sending the max to a recipient, use that recipient's address as the
|
||||
// change address.
|
||||
// Otherwise, use a fixed change address from the user's own wallet so that
|
||||
// we don't increment the change index.
|
||||
let change_address = if let Some((_, recipient)) = &recipient_with_max {
|
||||
// change/recovery address.
|
||||
// Otherwise, for a primary path spend, use a fixed change address from the user's
|
||||
// own wallet so that we don't increment the change index.
|
||||
let max_address = if let Some((_, recipient)) = &recipient_with_max {
|
||||
Address::from_str(&recipient.address.value)
|
||||
.expect("Checked before")
|
||||
.as_unchecked()
|
||||
@ -325,20 +401,34 @@ impl DefineSpend {
|
||||
};
|
||||
|
||||
let feerate_vb = self.feerate.value.parse::<u64>().expect("Checked before");
|
||||
let recovery_timelock = self.recovery_timelock;
|
||||
match tokio::runtime::Handle::current().block_on(async {
|
||||
daemon
|
||||
.create_spend_tx(
|
||||
&outpoints,
|
||||
&destinations,
|
||||
feerate_vb,
|
||||
Some(change_address.clone()),
|
||||
)
|
||||
.await
|
||||
// If recovery timelock is set, create a recovery transaction. Otherwise, a regular spend.
|
||||
if let Some(reco_tl) = recovery_timelock {
|
||||
daemon
|
||||
.create_recovery(max_address.clone(), &outpoints, feerate_vb, Some(reco_tl))
|
||||
.await
|
||||
// Map the PSBT to `CreateSpendResult` result. We only need the PSBT below.
|
||||
.map(|psbt| CreateSpendResult::Success {
|
||||
psbt,
|
||||
warnings: vec![],
|
||||
})
|
||||
} else {
|
||||
daemon
|
||||
.create_spend_tx(
|
||||
&outpoints,
|
||||
&destinations,
|
||||
feerate_vb,
|
||||
Some(max_address.clone()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}) {
|
||||
Ok(CreateSpendResult::Success { psbt, .. }) => {
|
||||
self.warning = None;
|
||||
self.fee_amount = Some(psbt.fee().expect("Valid fees"));
|
||||
if !self.is_user_coin_selection {
|
||||
// Update selected coins for auto-selection (non-recovery case).
|
||||
if !self.is_user_coin_selection && self.recovery_timelock.is_none() {
|
||||
let selected_coins: Vec<OutPoint> = psbt
|
||||
.unsigned_tx
|
||||
.input
|
||||
@ -360,8 +450,7 @@ impl DefineSpend {
|
||||
.output
|
||||
.iter()
|
||||
.find(|o| {
|
||||
o.script_pubkey
|
||||
== change_address.clone().assume_checked().script_pubkey()
|
||||
o.script_pubkey == max_address.clone().assume_checked().script_pubkey()
|
||||
})
|
||||
.map(|change_output| change_output.value.to_btc())
|
||||
.unwrap_or(0.0)
|
||||
@ -375,7 +464,9 @@ impl DefineSpend {
|
||||
Ok(CreateSpendResult::InsufficientFunds { missing }) => {
|
||||
self.fee_amount = None;
|
||||
self.amount_left_to_select = Some(Amount::from_sat(missing));
|
||||
if !self.is_user_coin_selection {
|
||||
// To be sure, we exclude recovery transactions here, although they
|
||||
// can't currently reach this part of code.
|
||||
if !self.is_user_coin_selection && self.recovery_timelock.is_none() {
|
||||
// The missing amount is based on all candidates for coin selection
|
||||
// being used, which are all owned coins.
|
||||
for (coin, selected) in &mut self.coins {
|
||||
@ -411,6 +502,46 @@ impl DefineSpend {
|
||||
}
|
||||
|
||||
impl Step for DefineSpend {
|
||||
fn load(&mut self, coins: &[Coin], tip_height: i32, draft: &TransactionDraft) {
|
||||
self.tip_height = tip_height as u32;
|
||||
match (self.recovery_timelock, draft.recovery_timelock) {
|
||||
(Some(old_tl), Some(new_tl)) => {
|
||||
if old_tl != new_tl {
|
||||
// If the timelock has changed, reinitialise this step.
|
||||
let new = Self::new(
|
||||
self.network,
|
||||
self.descriptor.clone(),
|
||||
coins,
|
||||
tip_height as u32,
|
||||
Some(new_tl),
|
||||
self.is_first_step,
|
||||
);
|
||||
*self = new;
|
||||
return;
|
||||
} else {
|
||||
// If the timelock has not changed, we keep the existing coins selection if it has already been edited
|
||||
// by the user or otherwise if the form values are valid. The form values being valid means a redraft has
|
||||
// been performed and so we keep the currently selected coins in order that the recipient amount displayed
|
||||
// matches the selection.
|
||||
let selected = (self.is_user_coin_selection
|
||||
|| self.form_values_are_valid(true))
|
||||
.then_some(
|
||||
self.coins
|
||||
.iter()
|
||||
.filter_map(|(coin, sel)| sel.then_some(coin.outpoint))
|
||||
.collect(),
|
||||
);
|
||||
self.coins = filter_coins(coins, self.recovery_timelock, tip_height, selected);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// We don't handle the case of a recovery timelock being added or removed:
|
||||
// A spend is either primary or recovery and cannot change from one to the other.
|
||||
}
|
||||
}
|
||||
self.check_valid();
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
@ -433,6 +564,9 @@ impl Step for DefineSpend {
|
||||
.map(|(c, _)| c.clone())
|
||||
.collect::<Vec<ListCoinsEntry>>()
|
||||
.as_slice(),
|
||||
self.tip_height,
|
||||
self.recovery_timelock,
|
||||
self.is_first_step,
|
||||
);
|
||||
return Task::none();
|
||||
}
|
||||
@ -495,35 +629,61 @@ impl Step for DefineSpend {
|
||||
.collect();
|
||||
let mut outputs: HashMap<Address<address::NetworkUnchecked>, u64> =
|
||||
HashMap::new();
|
||||
for recipient in &self.recipients {
|
||||
outputs.insert(
|
||||
Address::from_str(&recipient.address.value)
|
||||
.expect("Checked before"),
|
||||
recipient.amount().expect("Checked before"),
|
||||
);
|
||||
}
|
||||
let feerate_vb = self.feerate.value.parse::<u64>().unwrap_or(0);
|
||||
self.warning = None;
|
||||
return Task::perform(
|
||||
async move {
|
||||
daemon
|
||||
.create_spend_tx(&inputs, &outputs, feerate_vb, None)
|
||||
.await
|
||||
.map_err(|e| e.into())
|
||||
.and_then(|res| match res {
|
||||
CreateSpendResult::Success { psbt, warnings } => {
|
||||
Ok((psbt, warnings))
|
||||
}
|
||||
CreateSpendResult::InsufficientFunds { missing } => {
|
||||
Err(SpendCreationError::CoinSelection(
|
||||
liana::spend::InsufficientFunds { missing },
|
||||
)
|
||||
.into())
|
||||
}
|
||||
})
|
||||
},
|
||||
Message::Psbt,
|
||||
);
|
||||
if let Some(reco_tl) = self.recovery_timelock {
|
||||
let recovery_address = Address::from_str(
|
||||
&self
|
||||
.recipients
|
||||
.first()
|
||||
.expect("recovery spend has a recipient")
|
||||
.address
|
||||
.value,
|
||||
)
|
||||
.expect("Checked before");
|
||||
return Task::perform(
|
||||
async move {
|
||||
daemon
|
||||
.create_recovery(
|
||||
recovery_address,
|
||||
&inputs,
|
||||
feerate_vb,
|
||||
Some(reco_tl),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.into())
|
||||
.map(|psbt| (psbt, vec![]))
|
||||
},
|
||||
Message::Psbt,
|
||||
);
|
||||
} else {
|
||||
for recipient in &self.recipients {
|
||||
let address = Address::from_str(&recipient.address.value)
|
||||
.expect("Checked before");
|
||||
outputs
|
||||
.insert(address, recipient.amount().expect("Checked before"));
|
||||
}
|
||||
return Task::perform(
|
||||
async move {
|
||||
daemon
|
||||
.create_spend_tx(&inputs, &outputs, feerate_vb, None)
|
||||
.await
|
||||
.map_err(|e| e.into())
|
||||
.and_then(|res| match res {
|
||||
CreateSpendResult::Success { psbt, warnings } => {
|
||||
Ok((psbt, warnings))
|
||||
}
|
||||
CreateSpendResult::InsufficientFunds { missing } => {
|
||||
Err(SpendCreationError::CoinSelection(
|
||||
liana::spend::InsufficientFunds { missing },
|
||||
)
|
||||
.into())
|
||||
}
|
||||
})
|
||||
},
|
||||
Message::Psbt,
|
||||
);
|
||||
}
|
||||
}
|
||||
view::CreateSpendMessage::SelectCoin(i) => {
|
||||
if let Some(coin) = self.coins.get_mut(i) {
|
||||
@ -568,15 +728,21 @@ impl Step for DefineSpend {
|
||||
},
|
||||
Message::CoinsTipHeight(res_coins, res_tip) => match (res_coins, res_tip) {
|
||||
(Ok(coins), Ok(tip)) => {
|
||||
let selected: HashSet<OutPoint> =
|
||||
HashSet::from_iter(self.coins.iter().filter_map(|(c, selected)| {
|
||||
if *selected {
|
||||
Some(c.outpoint)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}));
|
||||
self.coins = filter_coins(&coins, Some(selected));
|
||||
self.tip_height = tip as u32;
|
||||
// If it's a recovery spend and user has never edited the selection, then we won't pass
|
||||
// any selection and will let `filter_coins` select all by default.
|
||||
let selected = (self.recovery_timelock.is_none()
|
||||
|| self.is_user_coin_selection)
|
||||
.then_some(HashSet::from_iter(self.coins.iter().filter_map(
|
||||
|(c, selected)| {
|
||||
if *selected {
|
||||
Some(c.outpoint)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
)));
|
||||
self.coins = filter_coins(&coins, self.recovery_timelock, tip, selected);
|
||||
self.sort_coins(tip as u32);
|
||||
// In case some selected coins are not spendable anymore and
|
||||
// new coins make more sense to be selected. A redraft is triggered
|
||||
@ -647,6 +813,7 @@ impl Step for DefineSpend {
|
||||
self.is_valid,
|
||||
self.is_duplicate,
|
||||
self.timelock(),
|
||||
self.recovery_timelock,
|
||||
&self.coins,
|
||||
&self.coins_labels,
|
||||
&self.batch_label,
|
||||
@ -654,6 +821,7 @@ impl Step for DefineSpend {
|
||||
&self.feerate,
|
||||
self.fee_amount.as_ref(),
|
||||
self.warning.as_ref(),
|
||||
self.is_first_step,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -663,9 +831,17 @@ struct Recipient {
|
||||
label: form::Value<String>,
|
||||
address: form::Value<String>,
|
||||
amount: form::Value<String>,
|
||||
is_recovery: bool,
|
||||
}
|
||||
|
||||
impl Recipient {
|
||||
fn new(is_recovery: bool) -> Self {
|
||||
Self {
|
||||
is_recovery,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn amount(&self) -> Result<u64, Error> {
|
||||
if self.amount.value.is_empty() {
|
||||
return Err(Error::Unexpected("Amount should be non-zero".to_string()));
|
||||
@ -738,7 +914,14 @@ impl Recipient {
|
||||
}
|
||||
|
||||
fn view(&self, i: usize, is_max_selected: bool) -> Element<view::CreateSpendMessage> {
|
||||
view::spend::recipient_view(i, &self.address, &self.amount, &self.label, is_max_selected)
|
||||
view::spend::recipient_view(
|
||||
i,
|
||||
&self.address,
|
||||
&self.amount,
|
||||
&self.label,
|
||||
is_max_selected,
|
||||
self.is_recovery,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -759,7 +942,7 @@ impl SaveSpend {
|
||||
}
|
||||
|
||||
impl Step for SaveSpend {
|
||||
fn load(&mut self, draft: &TransactionDraft) {
|
||||
fn load(&mut self, _coins: &[Coin], _tip_height: i32, draft: &TransactionDraft) {
|
||||
let (psbt, warnings) = draft.generated.clone().unwrap();
|
||||
let mut tx = SpendTx::new(
|
||||
None,
|
||||
@ -839,3 +1022,149 @@ impl Step for SaveSpend {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SelectRecoveryPath {
|
||||
wallet: Arc<Wallet>,
|
||||
recovery_paths: Vec<RecoveryPath>,
|
||||
selected_path: Option<usize>,
|
||||
warning: Option<Error>,
|
||||
}
|
||||
|
||||
impl SelectRecoveryPath {
|
||||
pub fn new(wallet: Arc<Wallet>, coins: &[Coin], tip_height: i32) -> Self {
|
||||
Self {
|
||||
recovery_paths: recovery_paths(&wallet, coins, tip_height),
|
||||
wallet,
|
||||
selected_path: None,
|
||||
warning: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_from_coins_and_tip_height(&mut self, coins: &[Coin], tip_height: i32) {
|
||||
self.warning = None;
|
||||
// Update the available recovery paths, maintaining any selected path.
|
||||
let selected_seq = self.selected_path.and_then(|selected| {
|
||||
self.recovery_paths
|
||||
.get(selected)
|
||||
.map(|reco_path| reco_path.sequence)
|
||||
});
|
||||
self.recovery_paths = recovery_paths(&self.wallet, coins, tip_height);
|
||||
self.selected_path = selected_seq.and_then(|seq| {
|
||||
self.recovery_paths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(i, path)| (path.sequence == seq).then_some(i))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Step for SelectRecoveryPath {
|
||||
fn load(&mut self, coins: &[Coin], tip_height: i32, _draft: &TransactionDraft) {
|
||||
self.load_from_coins_and_tip_height(coins, tip_height);
|
||||
}
|
||||
|
||||
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
|
||||
view::recovery::recovery(
|
||||
cache,
|
||||
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.warning.as_ref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
_daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
_cache: &Cache,
|
||||
message: Message,
|
||||
) -> Task<Message> {
|
||||
match message {
|
||||
Message::CoinsTipHeight(res_coins, res_tip) => match (res_coins, res_tip) {
|
||||
(Ok(coins), Ok(tip)) => {
|
||||
self.load_from_coins_and_tip_height(&coins, tip);
|
||||
}
|
||||
(Err(e), _) | (Ok(_), Err(e)) => {
|
||||
self.warning = Some(e);
|
||||
}
|
||||
},
|
||||
Message::View(view::Message::CreateSpend(view::CreateSpendMessage::SelectPath(
|
||||
index,
|
||||
))) => {
|
||||
if Some(index) == self.selected_path {
|
||||
self.selected_path = None;
|
||||
} else {
|
||||
self.selected_path = Some(index);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn apply(&self, draft: &mut TransactionDraft) {
|
||||
if let Some(selected_path) = self.selected_path {
|
||||
if let Some(path) = self.recovery_paths.get(selected_path) {
|
||||
draft.recovery_timelock = Some(path.sequence);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RecoveryPath {
|
||||
threshold: usize,
|
||||
sequence: u16,
|
||||
origins: Vec<(Fingerprint, HashSet<DerivationPath>)>,
|
||||
total_amount: Amount,
|
||||
number_of_coins: usize,
|
||||
}
|
||||
|
||||
fn recovery_paths(wallet: &Wallet, coins: &[Coin], tip_height: i32) -> Vec<RecoveryPath> {
|
||||
wallet
|
||||
.main_descriptor
|
||||
.policy()
|
||||
.recovery_paths()
|
||||
.iter()
|
||||
.map(|(&sequence, path)| {
|
||||
let (number_of_coins, total_amount) = coins
|
||||
.iter()
|
||||
.filter(|coin| {
|
||||
coin.block_height.is_some() // only confirmed coins are included in a recovery transaction
|
||||
&& coin.spend_info.is_none()
|
||||
&& remaining_sequence(coin, tip_height 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()
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ use liana::miniscript::bitcoin::{
|
||||
};
|
||||
|
||||
use liana_ui::{
|
||||
component::{amount::*, button, form, text::*},
|
||||
component::{amount::*, button, text::*},
|
||||
theme,
|
||||
widget::*,
|
||||
};
|
||||
@ -31,8 +31,6 @@ pub fn recovery<'a>(
|
||||
cache: &'a Cache,
|
||||
recovery_paths: Vec<Element<'a, Message>>,
|
||||
selected_path: Option<usize>,
|
||||
feerate: &form::Value<String>,
|
||||
address: &'a form::Value<String>,
|
||||
warning: Option<&Error>,
|
||||
) -> Element<'a, Message> {
|
||||
let no_recovery_paths = recovery_paths.is_empty();
|
||||
@ -43,38 +41,6 @@ pub fn recovery<'a>(
|
||||
Column::new()
|
||||
.push(Container::new(h3("Recovery")).width(Length::Fill))
|
||||
.push(Space::with_height(Length::Fixed(20.0)))
|
||||
.push(
|
||||
Row::new()
|
||||
.spacing(20)
|
||||
.align_y(Alignment::Center)
|
||||
.push(text("Destination").bold())
|
||||
.push(
|
||||
Container::new(
|
||||
form::Form::new_trimmed("Address", address, move |msg| {
|
||||
Message::CreateSpend(CreateSpendMessage::RecipientEdited(
|
||||
0, "address", msg,
|
||||
))
|
||||
})
|
||||
.warning("Invalid Bitcoin address")
|
||||
.size(P1_SIZE)
|
||||
.padding(10),
|
||||
)
|
||||
.max_width(500)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(text("Feerate").bold())
|
||||
.push(
|
||||
Container::new(
|
||||
form::Form::new_trimmed("42 (sats/vbyte)", feerate, move |msg| {
|
||||
Message::CreateSpend(CreateSpendMessage::FeerateEdited(msg))
|
||||
})
|
||||
.warning("Invalid feerate")
|
||||
.size(P1_SIZE)
|
||||
.padding(10),
|
||||
)
|
||||
.width(Length::Fixed(200.0)),
|
||||
),
|
||||
)
|
||||
.push(if no_recovery_paths {
|
||||
Container::new(text("No recovery path is currently available"))
|
||||
} else {
|
||||
@ -97,18 +63,9 @@ pub fn recovery<'a>(
|
||||
Row::new()
|
||||
.push(Space::with_width(Length::Fill))
|
||||
.push(
|
||||
if feerate.valid
|
||||
&& !feerate.value.is_empty()
|
||||
&& address.valid
|
||||
&& !address.value.is_empty()
|
||||
&& selected_path.is_some()
|
||||
{
|
||||
button::secondary(None, "Next")
|
||||
.on_press(Message::Next)
|
||||
.width(Length::Fixed(200.0))
|
||||
} else {
|
||||
button::secondary(None, "Next").width(Length::Fixed(200.0))
|
||||
},
|
||||
button::secondary(None, "Next")
|
||||
.on_press_maybe(selected_path.map(|_| Message::Next))
|
||||
.width(Length::Fixed(200.0)),
|
||||
)
|
||||
.spacing(20)
|
||||
.align_y(Alignment::Center),
|
||||
|
||||
@ -39,13 +39,26 @@ pub fn spend_view<'a>(
|
||||
network: Network,
|
||||
warning: Option<&Error>,
|
||||
) -> Element<'a, Message> {
|
||||
let is_recovery = tx
|
||||
.psbt
|
||||
.unsigned_tx
|
||||
.input
|
||||
.iter()
|
||||
.any(|txin| txin.sequence.is_relative_lock_time());
|
||||
dashboard(
|
||||
&Menu::CreateSpendTx,
|
||||
if is_recovery {
|
||||
&Menu::Recovery
|
||||
} else {
|
||||
&Menu::CreateSpendTx
|
||||
},
|
||||
cache,
|
||||
warning,
|
||||
Column::new()
|
||||
.spacing(20)
|
||||
.push(Container::new(h3("Send")).width(Length::Fill))
|
||||
.push(
|
||||
Container::new(h3(if is_recovery { "Recovery" } else { "Send" }))
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(psbt::spend_header(tx, labels_editing))
|
||||
.push_maybe(if spend_warnings.is_empty() || saved {
|
||||
None
|
||||
@ -114,6 +127,7 @@ pub fn create_spend_tx<'a>(
|
||||
is_valid: bool,
|
||||
duplicate: bool,
|
||||
timelock: u16,
|
||||
recovery_timelock: Option<u16>,
|
||||
coins: &[(Coin, bool)],
|
||||
coins_labels: &'a HashMap<String, String>,
|
||||
batch_label: &form::Value<String>,
|
||||
@ -121,14 +135,21 @@ pub fn create_spend_tx<'a>(
|
||||
feerate: &form::Value<String>,
|
||||
fee_amount: Option<&Amount>,
|
||||
error: Option<&Error>,
|
||||
is_first_step: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let is_self_send = recipients.is_empty();
|
||||
dashboard(
|
||||
&Menu::CreateSpendTx,
|
||||
if recovery_timelock.is_some() {
|
||||
&Menu::Recovery
|
||||
} else {
|
||||
&Menu::CreateSpendTx
|
||||
},
|
||||
cache,
|
||||
error,
|
||||
Column::new()
|
||||
.push(h3(if is_self_send {
|
||||
.push(h3(if recovery_timelock.is_some() {
|
||||
"Recovery"
|
||||
} else if is_self_send {
|
||||
"Self-transfer"
|
||||
} else {
|
||||
"Send"
|
||||
@ -162,7 +183,8 @@ pub fn create_spend_tx<'a>(
|
||||
None
|
||||
})
|
||||
.push(Space::with_width(Length::Fill))
|
||||
.push_maybe(if is_self_send {
|
||||
.push_maybe(if is_self_send || recovery_timelock.is_some() {
|
||||
// Recipients cannot be added for self-send (zero recipients) and recovery (exactly one recipient).
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
@ -208,7 +230,7 @@ pub fn create_spend_tx<'a>(
|
||||
Row::new()
|
||||
.align_y(Alignment::Center)
|
||||
.push(p1_bold("Coins selection").width(Length::Fill))
|
||||
.push(if is_self_send {
|
||||
.push(if is_self_send || recovery_timelock.is_some() {
|
||||
Row::new()
|
||||
.spacing(5)
|
||||
.push(amount_with_size(
|
||||
@ -291,6 +313,13 @@ pub fn create_spend_tx<'a>(
|
||||
Row::new()
|
||||
.spacing(20)
|
||||
.align_y(Alignment::Center)
|
||||
.push_maybe(
|
||||
(!is_first_step).then_some(
|
||||
button::secondary(None, "< Previous")
|
||||
.width(Length::Fixed(150.0))
|
||||
.on_press(Message::Previous),
|
||||
),
|
||||
)
|
||||
.push(Space::with_width(Length::Fill))
|
||||
.push(
|
||||
button::secondary(None, "Clear")
|
||||
@ -301,7 +330,9 @@ pub fn create_spend_tx<'a>(
|
||||
if is_valid
|
||||
&& !duplicate
|
||||
&& error.is_none()
|
||||
&& (is_self_send || Some(&Amount::from_sat(0)) == amount_left)
|
||||
&& (is_self_send
|
||||
|| recovery_timelock.is_some()
|
||||
|| Some(&Amount::from_sat(0)) == amount_left)
|
||||
{
|
||||
button::secondary(None, "Next")
|
||||
.on_press(Message::CreateSpend(CreateSpendMessage::Generate))
|
||||
@ -322,16 +353,20 @@ pub fn recipient_view<'a>(
|
||||
amount: &'a form::Value<String>,
|
||||
label: &'a form::Value<String>,
|
||||
is_max_selected: bool,
|
||||
is_recovery: bool,
|
||||
) -> Element<'a, CreateSpendMessage> {
|
||||
Container::new(
|
||||
Column::new()
|
||||
.spacing(10)
|
||||
.push(
|
||||
Row::new().push(Space::with_width(Length::Fill)).push(
|
||||
Button::new(icon::cross_icon())
|
||||
.style(theme::button::transparent)
|
||||
.on_press(CreateSpendMessage::DeleteRecipient(index))
|
||||
.width(Length::Shrink),
|
||||
.push_maybe(
|
||||
// Recipient for recovery cannot be deleted.
|
||||
(!is_recovery).then_some(
|
||||
Row::new().push(Space::with_width(Length::Fill)).push(
|
||||
Button::new(icon::cross_icon())
|
||||
.style(theme::button::transparent)
|
||||
.on_press(CreateSpendMessage::DeleteRecipient(index))
|
||||
.width(Length::Shrink),
|
||||
),
|
||||
),
|
||||
)
|
||||
.push(
|
||||
@ -409,13 +444,16 @@ pub fn recipient_view<'a>(
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.push(tooltip::Tooltip::new(
|
||||
checkbox("MAX", is_max_selected)
|
||||
.on_toggle(move |_| CreateSpendMessage::SendMaxToRecipient(index)),
|
||||
// Add spaces at end so that text is padded at screen edge.
|
||||
"Total amount remaining after paying fee and any other recipients ",
|
||||
tooltip::Position::Bottom,
|
||||
))
|
||||
.push_maybe(
|
||||
// The MAX option cannot be edited for recovery recipients.
|
||||
(!is_recovery).then_some(tooltip::Tooltip::new(
|
||||
checkbox("MAX", is_max_selected)
|
||||
.on_toggle(move |_| CreateSpendMessage::SendMaxToRecipient(index)),
|
||||
// Add spaces at end so that text is padded at screen edge.
|
||||
"Total amount remaining after paying fee and any other recipients ",
|
||||
tooltip::Position::Bottom,
|
||||
)),
|
||||
)
|
||||
.width(Length::Fill),
|
||||
),
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user