Merge #465: gui: merge spend creation steps
e4da8e9be7f9a5d4d08ce5996e3b789f9dc69143 gui: swap transactions and psbts menus (edouard) b4c7d1af505195bb4f1eb8a1864ab0c7c8e6e884 gui: refac psbt view (edouard) 330f7e65bb0bf48d8ede73bbb2295aee99f1eed0 gui: add module psbt (edouard) 6b2c9a61e035e5c02b55807a2a275bb5d1cb9cfc gui: merge spend creation steps (edouard) Pull request description: close #443 ACKs for top commit: edouardparis: Self-ACK e4da8e9be7f9a5d4d08ce5996e3b789f9dc69143 Tree-SHA512: 9e6675934bbf7bb8c7562343f1cfa0547347db49bf111112316d81539d3f41cb26c4e9ccffc00a701fbe0f1d94c7bbef046777d71c7b760d92b2972c073be9e3
This commit is contained in:
commit
be71694153
@ -1,4 +1,5 @@
|
||||
mod coins;
|
||||
mod psbt;
|
||||
mod psbts;
|
||||
mod recovery;
|
||||
mod settings;
|
||||
|
||||
@ -21,7 +21,6 @@ use crate::{
|
||||
error::Error,
|
||||
message::Message,
|
||||
view,
|
||||
view::spend::detail,
|
||||
wallet::{Wallet, WalletError},
|
||||
},
|
||||
daemon::{
|
||||
@ -31,7 +30,7 @@ use crate::{
|
||||
hw::{list_hardware_wallets, HardwareWallet},
|
||||
};
|
||||
|
||||
trait Action {
|
||||
pub trait Action {
|
||||
fn warning(&self) -> Option<&Error> {
|
||||
None
|
||||
}
|
||||
@ -49,15 +48,15 @@ trait Action {
|
||||
fn view(&self) -> Element<view::Message>;
|
||||
}
|
||||
|
||||
pub struct SpendTxState {
|
||||
wallet: Arc<Wallet>,
|
||||
desc_policy: LianaPolicy,
|
||||
tx: SpendTx,
|
||||
saved: bool,
|
||||
action: Option<Box<dyn Action>>,
|
||||
pub struct PsbtState {
|
||||
pub wallet: Arc<Wallet>,
|
||||
pub desc_policy: LianaPolicy,
|
||||
pub tx: SpendTx,
|
||||
pub saved: bool,
|
||||
pub action: Option<Box<dyn Action>>,
|
||||
}
|
||||
|
||||
impl SpendTxState {
|
||||
impl PsbtState {
|
||||
pub fn new(wallet: Arc<Wallet>, tx: SpendTx, saved: bool) -> Self {
|
||||
Self {
|
||||
desc_policy: wallet.main_descriptor.policy(),
|
||||
@ -130,7 +129,8 @@ impl SpendTxState {
|
||||
}
|
||||
|
||||
pub fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
|
||||
let content = detail::spend_view(
|
||||
let content = view::psbt::psbt_view(
|
||||
cache,
|
||||
&self.tx,
|
||||
self.saved,
|
||||
&self.desc_policy,
|
||||
@ -178,7 +178,7 @@ impl Action for SaveAction {
|
||||
Command::none()
|
||||
}
|
||||
fn view(&self) -> Element<view::Message> {
|
||||
detail::save_action(self.error.as_ref(), self.saved)
|
||||
view::psbt::save_action(self.error.as_ref(), self.saved)
|
||||
}
|
||||
}
|
||||
|
||||
@ -221,7 +221,7 @@ impl Action for BroadcastAction {
|
||||
Command::none()
|
||||
}
|
||||
fn view(&self) -> Element<view::Message> {
|
||||
detail::broadcast_action(self.error.as_ref(), self.broadcast)
|
||||
view::psbt::broadcast_action(self.error.as_ref(), self.broadcast)
|
||||
}
|
||||
}
|
||||
|
||||
@ -261,7 +261,7 @@ impl Action for DeleteAction {
|
||||
Command::none()
|
||||
}
|
||||
fn view(&self) -> Element<view::Message> {
|
||||
detail::delete_action(self.error.as_ref(), self.deleted)
|
||||
view::psbt::delete_action(self.error.as_ref(), self.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
@ -376,7 +376,7 @@ impl Action for SignAction {
|
||||
Command::none()
|
||||
}
|
||||
fn view(&self) -> Element<view::Message> {
|
||||
view::spend::detail::sign_action(
|
||||
view::psbt::sign_action(
|
||||
self.error.as_ref(),
|
||||
&self.hws,
|
||||
self.wallet.signer.as_ref().map(|s| s.fingerprint()),
|
||||
@ -439,9 +439,9 @@ impl UpdateAction {
|
||||
impl Action for UpdateAction {
|
||||
fn view(&self) -> Element<view::Message> {
|
||||
if self.success {
|
||||
view::spend::detail::update_spend_success_view()
|
||||
view::psbt::update_spend_success_view()
|
||||
} else {
|
||||
view::spend::detail::update_spend_view(
|
||||
view::psbt::update_spend_view(
|
||||
self.psbt.clone(),
|
||||
&self.updated,
|
||||
self.error.as_ref(),
|
||||
@ -8,7 +8,7 @@ use liana_ui::{
|
||||
widget::Element,
|
||||
};
|
||||
|
||||
use super::{spend::detail, State};
|
||||
use super::{psbt, State};
|
||||
use crate::{
|
||||
app::{cache::Cache, error::Error, menu::Menu, message::Message, view, wallet::Wallet},
|
||||
daemon::{model::SpendTx, Daemon},
|
||||
@ -16,7 +16,7 @@ use crate::{
|
||||
|
||||
pub struct PsbtsPanel {
|
||||
wallet: Arc<Wallet>,
|
||||
selected_tx: Option<detail::SpendTxState>,
|
||||
selected_tx: Option<psbt::PsbtState>,
|
||||
spend_txs: Vec<SpendTx>,
|
||||
warning: Option<Error>,
|
||||
import_tx: Option<ImportPsbtModal>,
|
||||
@ -90,7 +90,7 @@ impl State for PsbtsPanel {
|
||||
}
|
||||
Message::View(view::Message::Select(i)) => {
|
||||
if let Some(tx) = self.spend_txs.get(i) {
|
||||
let tx = detail::SpendTxState::new(self.wallet.clone(), tx.clone(), true);
|
||||
let tx = psbt::PsbtState::new(self.wallet.clone(), tx.clone(), true);
|
||||
let cmd = tx.load(daemon);
|
||||
self.selected_tx = Some(tx);
|
||||
return cmd;
|
||||
|
||||
@ -12,7 +12,7 @@ use crate::{
|
||||
error::Error,
|
||||
menu::Menu,
|
||||
message::Message,
|
||||
state::spend::detail,
|
||||
state::psbt,
|
||||
state::{redirect, State},
|
||||
view,
|
||||
wallet::Wallet,
|
||||
@ -32,7 +32,7 @@ pub struct RecoveryPanel {
|
||||
warning: Option<Error>,
|
||||
feerate: form::Value<String>,
|
||||
recipient: form::Value<String>,
|
||||
generated: Option<detail::SpendTxState>,
|
||||
generated: Option<psbt::PsbtState>,
|
||||
}
|
||||
|
||||
impl RecoveryPanel {
|
||||
@ -102,7 +102,7 @@ impl State for RecoveryPanel {
|
||||
},
|
||||
Message::Recovery(res) => match res {
|
||||
Ok(tx) => {
|
||||
self.generated = Some(detail::SpendTxState::new(self.wallet.clone(), tx, false))
|
||||
self.generated = Some(psbt::PsbtState::new(self.wallet.clone(), tx, false))
|
||||
}
|
||||
Err(e) => self.warning = Some(e),
|
||||
},
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
pub mod detail;
|
||||
mod step;
|
||||
use std::sync::Arc;
|
||||
|
||||
@ -26,8 +25,7 @@ impl CreateSpendPanel {
|
||||
draft: step::TransactionDraft::default(),
|
||||
current: 0,
|
||||
steps: vec![
|
||||
Box::new(step::ChooseRecipients::new(coins)),
|
||||
Box::new(step::ChooseCoins::new(
|
||||
Box::new(step::DefineSpend::new(
|
||||
descriptor,
|
||||
coins.to_vec(),
|
||||
timelock,
|
||||
@ -72,7 +70,7 @@ impl State for CreateSpendPanel {
|
||||
}
|
||||
|
||||
if let Some(step) = self.steps.get_mut(self.current) {
|
||||
return step.update(daemon, cache, &self.draft, message);
|
||||
return step.update(daemon, cache, message);
|
||||
}
|
||||
|
||||
Command::none()
|
||||
|
||||
@ -10,12 +10,13 @@ use liana::{
|
||||
},
|
||||
};
|
||||
|
||||
use liana_ui::{component::form, widget::Element};
|
||||
use liana_ui::{
|
||||
component::{form, modal},
|
||||
widget::Element,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
cache::Cache, error::Error, message::Message, state::spend::detail, view, wallet::Wallet,
|
||||
},
|
||||
app::{cache::Cache, error::Error, message::Message, state::psbt, view, wallet::Wallet},
|
||||
daemon::{
|
||||
model::{remaining_sequence, Coin, SpendTx},
|
||||
Daemon,
|
||||
@ -28,7 +29,6 @@ const DUST_OUTPUT_SATS: u64 = 5_000;
|
||||
#[derive(Default, Clone)]
|
||||
pub struct TransactionDraft {
|
||||
inputs: Vec<Coin>,
|
||||
outputs: HashMap<Address, u64>,
|
||||
generated: Option<Psbt>,
|
||||
}
|
||||
|
||||
@ -38,36 +38,77 @@ pub trait Step {
|
||||
&mut self,
|
||||
daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
cache: &Cache,
|
||||
draft: &TransactionDraft,
|
||||
message: Message,
|
||||
) -> Command<Message>;
|
||||
fn apply(&self, _draft: &mut TransactionDraft) {}
|
||||
fn load(&mut self, _draft: &TransactionDraft) {}
|
||||
}
|
||||
|
||||
pub struct ChooseRecipients {
|
||||
pub struct DefineSpend {
|
||||
balance_available: Amount,
|
||||
recipients: Vec<Recipient>,
|
||||
is_valid: bool,
|
||||
is_duplicate: bool,
|
||||
|
||||
descriptor: LianaDescriptor,
|
||||
timelock: u16,
|
||||
coins: Vec<(Coin, bool)>,
|
||||
amount_left_to_select: Option<Amount>,
|
||||
feerate: form::Value<String>,
|
||||
generated: Option<Psbt>,
|
||||
warning: Option<Error>,
|
||||
}
|
||||
|
||||
impl ChooseRecipients {
|
||||
pub fn new(coins: &[Coin]) -> Self {
|
||||
impl DefineSpend {
|
||||
pub fn new(
|
||||
descriptor: LianaDescriptor,
|
||||
coins: Vec<Coin>,
|
||||
timelock: u16,
|
||||
blockheight: u32,
|
||||
) -> Self {
|
||||
let balance_available = coins
|
||||
.iter()
|
||||
.filter_map(|coin| {
|
||||
if coin.spend_info.is_none() {
|
||||
Some(coin.amount)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.sum();
|
||||
let mut coins: Vec<(Coin, bool)> = coins
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
if c.spend_info.is_none() {
|
||||
Some((c, false))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
coins.sort_by(|(a, _), (b, _)| {
|
||||
if remaining_sequence(a, blockheight, timelock)
|
||||
== remaining_sequence(b, blockheight, timelock)
|
||||
{
|
||||
// bigger amount first
|
||||
b.amount.cmp(&a.amount)
|
||||
} else {
|
||||
// smallest blockheight (remaining_sequence) first
|
||||
a.block_height.cmp(&b.block_height)
|
||||
}
|
||||
});
|
||||
Self {
|
||||
balance_available: coins
|
||||
.iter()
|
||||
.filter_map(|coin| {
|
||||
if coin.spend_info.is_none() {
|
||||
Some(coin.amount)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.sum(),
|
||||
balance_available,
|
||||
descriptor,
|
||||
timelock,
|
||||
generated: None,
|
||||
coins,
|
||||
recipients: vec![Recipient::default()],
|
||||
is_valid: false,
|
||||
is_duplicate: false,
|
||||
feerate: form::Value::default(),
|
||||
amount_left_to_select: None,
|
||||
warning: None,
|
||||
}
|
||||
}
|
||||
fn check_valid(&mut self) {
|
||||
@ -84,51 +125,170 @@ impl ChooseRecipients {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn amount_left_to_select(&mut self) {
|
||||
// We need the feerate in order to compute the required amount of BTC to
|
||||
// select. Return early if we don't to not do unnecessary computation.
|
||||
let feerate = match self.feerate.value.parse::<u64>() {
|
||||
Ok(f) => f,
|
||||
Err(_) => {
|
||||
self.amount_left_to_select = None;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// The coins to be included in this transaction.
|
||||
let selected_coins: Vec<_> = self
|
||||
.coins
|
||||
.iter()
|
||||
.filter_map(|(c, selected)| if *selected { Some(c) } else { None })
|
||||
.collect();
|
||||
|
||||
// A dummy representation of the transaction that will be computed, for
|
||||
// the purpose of computing its size in order to anticipate the fees needed.
|
||||
// NOTE: we make the conservative estimation a change output will always be
|
||||
// needed.
|
||||
let tx_template = bitcoin::Transaction {
|
||||
version: 2,
|
||||
lock_time: bitcoin::PackedLockTime(0),
|
||||
input: selected_coins
|
||||
.iter()
|
||||
.map(|_| bitcoin::TxIn::default())
|
||||
.collect(),
|
||||
output: self
|
||||
.recipients
|
||||
.iter()
|
||||
.filter_map(|recipient| {
|
||||
if recipient.valid() {
|
||||
Some(bitcoin::TxOut {
|
||||
script_pubkey: Address::from_str(&recipient.address.value)
|
||||
.unwrap()
|
||||
.script_pubkey(),
|
||||
value: recipient.amount().unwrap(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
// nValue size + scriptPubKey CompactSize + OP_0 + PUSH32 + <wit program>
|
||||
const CHANGE_TXO_SIZE: usize = 8 + 1 + 1 + 1 + 32;
|
||||
let satisfaction_vsize = self.descriptor.max_sat_weight() / 4;
|
||||
let transaction_size =
|
||||
tx_template.vsize() + satisfaction_vsize * tx_template.input.len() + CHANGE_TXO_SIZE;
|
||||
|
||||
// Now the calculation of the amount left to be selected by the user is a simple
|
||||
// substraction between the value needed by the transaction to be created and the
|
||||
// value that was selected already.
|
||||
let selected_amount = selected_coins.iter().map(|c| c.amount.to_sat()).sum();
|
||||
let output_sum: u64 = tx_template.output.iter().map(|o| o.value).sum();
|
||||
let needed_amount: u64 = transaction_size as u64 * feerate + output_sum;
|
||||
self.amount_left_to_select = Some(Amount::from_sat(
|
||||
needed_amount.saturating_sub(selected_amount),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
impl Step for ChooseRecipients {
|
||||
impl Step for DefineSpend {
|
||||
fn update(
|
||||
&mut self,
|
||||
_daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
cache: &Cache,
|
||||
_draft: &TransactionDraft,
|
||||
message: Message,
|
||||
) -> Command<Message> {
|
||||
if let Message::View(view::Message::CreateSpend(msg)) = message {
|
||||
match &msg {
|
||||
match msg {
|
||||
view::CreateSpendMessage::AddRecipient => {
|
||||
self.recipients.push(Recipient::default());
|
||||
}
|
||||
view::CreateSpendMessage::DeleteRecipient(i) => {
|
||||
self.recipients.remove(*i);
|
||||
self.recipients.remove(i);
|
||||
}
|
||||
view::CreateSpendMessage::RecipientEdited(i, _, _) => {
|
||||
self.recipients
|
||||
.get_mut(*i)
|
||||
.get_mut(i)
|
||||
.unwrap()
|
||||
.update(cache.network, msg);
|
||||
}
|
||||
|
||||
view::CreateSpendMessage::FeerateEdited(s) => {
|
||||
if s.parse::<u64>().is_ok() {
|
||||
self.feerate.value = s;
|
||||
self.feerate.valid = true;
|
||||
self.amount_left_to_select();
|
||||
} else if s.is_empty() {
|
||||
self.feerate.value = "".to_string();
|
||||
self.feerate.valid = true;
|
||||
self.amount_left_to_select = None;
|
||||
} else {
|
||||
self.feerate.valid = false;
|
||||
self.amount_left_to_select = None;
|
||||
}
|
||||
self.warning = None;
|
||||
}
|
||||
view::CreateSpendMessage::Generate => {
|
||||
let inputs: Vec<OutPoint> = self
|
||||
.coins
|
||||
.iter()
|
||||
.filter_map(
|
||||
|(coin, selected)| if *selected { Some(coin.outpoint) } else { None },
|
||||
)
|
||||
.collect();
|
||||
let mut outputs: HashMap<Address, 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 Command::perform(
|
||||
async move {
|
||||
daemon
|
||||
.create_spend_tx(&inputs, &outputs, feerate_vb)
|
||||
.map(|res| res.psbt)
|
||||
.map_err(|e| e.into())
|
||||
},
|
||||
Message::Psbt,
|
||||
);
|
||||
}
|
||||
view::CreateSpendMessage::SelectCoin(i) => {
|
||||
if let Some(coin) = self.coins.get_mut(i) {
|
||||
coin.1 = !coin.1;
|
||||
self.amount_left_to_select();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
self.check_valid();
|
||||
Command::none()
|
||||
} else {
|
||||
if let Message::Psbt(res) = message {
|
||||
match res {
|
||||
Ok(psbt) => {
|
||||
self.generated = Some(psbt);
|
||||
return Command::perform(async {}, |_| Message::View(view::Message::Next));
|
||||
}
|
||||
Err(e) => self.warning = Some(e),
|
||||
}
|
||||
}
|
||||
Command::none()
|
||||
}
|
||||
Command::none()
|
||||
}
|
||||
|
||||
fn apply(&self, draft: &mut TransactionDraft) {
|
||||
let mut outputs: HashMap<Address, u64> = HashMap::new();
|
||||
for recipient in &self.recipients {
|
||||
outputs.insert(
|
||||
Address::from_str(&recipient.address.value).expect("Checked before"),
|
||||
recipient.amount().expect("Checked before"),
|
||||
);
|
||||
}
|
||||
draft.outputs = outputs;
|
||||
draft.inputs = self
|
||||
.coins
|
||||
.iter()
|
||||
.filter_map(|(coin, selected)| if *selected { Some(*coin) } else { None })
|
||||
.collect();
|
||||
draft.generated = self.generated.clone();
|
||||
}
|
||||
|
||||
fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> {
|
||||
view::spend::step::choose_recipients_view(
|
||||
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
|
||||
view::spend::create_spend_tx(
|
||||
cache,
|
||||
&self.balance_available,
|
||||
self.recipients
|
||||
.iter()
|
||||
@ -143,6 +303,11 @@ impl Step for ChooseRecipients {
|
||||
),
|
||||
self.is_valid,
|
||||
self.is_duplicate,
|
||||
self.timelock,
|
||||
&self.coins,
|
||||
self.amount_left_to_select.as_ref(),
|
||||
&self.feerate,
|
||||
self.warning.as_ref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -218,218 +383,13 @@ impl Recipient {
|
||||
}
|
||||
|
||||
fn view(&self, i: usize) -> Element<view::CreateSpendMessage> {
|
||||
view::spend::step::recipient_view(i, &self.address, &self.amount)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ChooseCoins {
|
||||
descriptor: LianaDescriptor,
|
||||
timelock: u16,
|
||||
coins: Vec<(Coin, bool)>,
|
||||
recipients: Vec<(Address, Amount)>,
|
||||
|
||||
amount_left_to_select: Option<Amount>,
|
||||
feerate: form::Value<String>,
|
||||
generated: Option<Psbt>,
|
||||
warning: Option<Error>,
|
||||
}
|
||||
|
||||
impl ChooseCoins {
|
||||
pub fn new(
|
||||
descriptor: LianaDescriptor,
|
||||
coins: Vec<Coin>,
|
||||
timelock: u16,
|
||||
blockheight: u32,
|
||||
) -> Self {
|
||||
let mut coins: Vec<(Coin, bool)> = coins
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
if c.spend_info.is_none() {
|
||||
Some((c, false))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
coins.sort_by(|(a, _), (b, _)| {
|
||||
if remaining_sequence(a, blockheight, timelock)
|
||||
== remaining_sequence(b, blockheight, timelock)
|
||||
{
|
||||
// bigger amount first
|
||||
b.amount.cmp(&a.amount)
|
||||
} else {
|
||||
// smallest blockheight (remaining_sequence) first
|
||||
a.block_height.cmp(&b.block_height)
|
||||
}
|
||||
});
|
||||
Self {
|
||||
descriptor,
|
||||
timelock,
|
||||
coins,
|
||||
recipients: Vec::new(),
|
||||
feerate: form::Value::default(),
|
||||
generated: None,
|
||||
warning: None,
|
||||
amount_left_to_select: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn amount_left_to_select(&mut self) {
|
||||
// We need the feerate in order to compute the required amount of BTC to
|
||||
// select. Return early if we don't to not do unnecessary computation.
|
||||
let feerate = match self.feerate.value.parse::<u64>() {
|
||||
Ok(f) => f,
|
||||
Err(_) => {
|
||||
self.amount_left_to_select = None;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// The coins to be included in this transaction.
|
||||
let selected_coins: Vec<_> = self
|
||||
.coins
|
||||
.iter()
|
||||
.filter_map(|(c, selected)| if *selected { Some(c) } else { None })
|
||||
.collect();
|
||||
|
||||
// A dummy representation of the transaction that will be computed, for
|
||||
// the purpose of computing its size in order to anticipate the fees needed.
|
||||
// NOTE: we make the conservative estimation a change output will always be
|
||||
// needed.
|
||||
let tx_template = bitcoin::Transaction {
|
||||
version: 2,
|
||||
lock_time: bitcoin::PackedLockTime(0),
|
||||
input: selected_coins
|
||||
.iter()
|
||||
.map(|_| bitcoin::TxIn::default())
|
||||
.collect(),
|
||||
output: self
|
||||
.recipients
|
||||
.iter()
|
||||
.map(|(addr, amount)| bitcoin::TxOut {
|
||||
script_pubkey: addr.script_pubkey(),
|
||||
value: amount.to_sat(),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
// nValue size + scriptPubKey CompactSize + OP_0 + PUSH32 + <wit program>
|
||||
const CHANGE_TXO_SIZE: usize = 8 + 1 + 1 + 1 + 32;
|
||||
let satisfaction_vsize = self.descriptor.max_sat_weight() / 4;
|
||||
let transaction_size =
|
||||
tx_template.vsize() + satisfaction_vsize * tx_template.input.len() + CHANGE_TXO_SIZE;
|
||||
|
||||
// Now the calculation of the amount left to be selected by the user is a simple
|
||||
// substraction between the value needed by the transaction to be created and the
|
||||
// value that was selected already.
|
||||
let selected_amount = selected_coins.iter().map(|c| c.amount.to_sat()).sum();
|
||||
let output_sum: u64 = tx_template.output.iter().map(|o| o.value).sum();
|
||||
let needed_amount: u64 = transaction_size as u64 * feerate + output_sum;
|
||||
self.amount_left_to_select = Some(Amount::from_sat(
|
||||
needed_amount.saturating_sub(selected_amount),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
impl Step for ChooseCoins {
|
||||
fn load(&mut self, draft: &TransactionDraft) {
|
||||
self.warning = None;
|
||||
self.recipients = draft
|
||||
.outputs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Amount::from_sat(*v)))
|
||||
.collect();
|
||||
self.amount_left_to_select();
|
||||
}
|
||||
|
||||
fn apply(&self, draft: &mut TransactionDraft) {
|
||||
draft.inputs = self
|
||||
.coins
|
||||
.iter()
|
||||
.filter_map(|(coin, selected)| if *selected { Some(*coin) } else { None })
|
||||
.collect();
|
||||
draft.generated = self.generated.clone();
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
_cache: &Cache,
|
||||
draft: &TransactionDraft,
|
||||
message: Message,
|
||||
) -> Command<Message> {
|
||||
match message {
|
||||
Message::View(view::Message::CreateSpend(view::CreateSpendMessage::FeerateEdited(
|
||||
s,
|
||||
))) => {
|
||||
if s.parse::<u64>().is_ok() {
|
||||
self.feerate.value = s;
|
||||
self.feerate.valid = true;
|
||||
self.amount_left_to_select();
|
||||
} else if s.is_empty() {
|
||||
self.feerate.value = "".to_string();
|
||||
self.feerate.valid = true;
|
||||
self.amount_left_to_select = None;
|
||||
} else {
|
||||
self.feerate.valid = false;
|
||||
self.amount_left_to_select = None;
|
||||
}
|
||||
self.warning = None;
|
||||
}
|
||||
Message::View(view::Message::CreateSpend(view::CreateSpendMessage::Generate)) => {
|
||||
let inputs: Vec<OutPoint> = self
|
||||
.coins
|
||||
.iter()
|
||||
.filter_map(
|
||||
|(coin, selected)| if *selected { Some(coin.outpoint) } else { None },
|
||||
)
|
||||
.collect();
|
||||
let outputs = draft.outputs.clone();
|
||||
let feerate_vb = self.feerate.value.parse::<u64>().unwrap_or(0);
|
||||
self.warning = None;
|
||||
return Command::perform(
|
||||
async move {
|
||||
daemon
|
||||
.create_spend_tx(&inputs, &outputs, feerate_vb)
|
||||
.map(|res| res.psbt)
|
||||
.map_err(|e| e.into())
|
||||
},
|
||||
Message::Psbt,
|
||||
);
|
||||
}
|
||||
Message::Psbt(res) => match res {
|
||||
Ok(psbt) => {
|
||||
self.generated = Some(psbt);
|
||||
return Command::perform(async {}, |_| Message::View(view::Message::Next));
|
||||
}
|
||||
Err(e) => self.warning = Some(e),
|
||||
},
|
||||
Message::View(view::Message::CreateSpend(view::CreateSpendMessage::SelectCoin(i))) => {
|
||||
if let Some(coin) = self.coins.get_mut(i) {
|
||||
coin.1 = !coin.1;
|
||||
self.amount_left_to_select();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Command::none()
|
||||
}
|
||||
|
||||
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
|
||||
view::spend::step::choose_coins_view(
|
||||
cache,
|
||||
self.timelock,
|
||||
&self.coins,
|
||||
self.amount_left_to_select.as_ref(),
|
||||
&self.feerate,
|
||||
self.warning.as_ref(),
|
||||
)
|
||||
view::spend::recipient_view(i, &self.address, &self.amount)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SaveSpend {
|
||||
wallet: Arc<Wallet>,
|
||||
spend: Option<detail::SpendTxState>,
|
||||
spend: Option<psbt::PsbtState>,
|
||||
}
|
||||
|
||||
impl SaveSpend {
|
||||
@ -449,7 +409,7 @@ impl Step for SaveSpend {
|
||||
.main_descriptor
|
||||
.partial_spend_info(&psbt)
|
||||
.unwrap();
|
||||
self.spend = Some(detail::SpendTxState::new(
|
||||
self.spend = Some(psbt::PsbtState::new(
|
||||
self.wallet.clone(),
|
||||
SpendTx::new(None, psbt, draft.inputs.clone(), sigs),
|
||||
false,
|
||||
@ -460,7 +420,6 @@ impl Step for SaveSpend {
|
||||
&mut self,
|
||||
daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
cache: &Cache,
|
||||
_draft: &TransactionDraft,
|
||||
message: Message,
|
||||
) -> Command<Message> {
|
||||
if let Some(spend) = &mut self.spend {
|
||||
@ -471,6 +430,21 @@ impl Step for SaveSpend {
|
||||
}
|
||||
|
||||
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
|
||||
self.spend.as_ref().unwrap().view(cache)
|
||||
let spend = self.spend.as_ref().unwrap();
|
||||
let content = view::spend::spend_view(
|
||||
cache,
|
||||
&spend.tx,
|
||||
spend.saved,
|
||||
&spend.desc_policy,
|
||||
&spend.wallet.keys_aliases,
|
||||
cache.network,
|
||||
);
|
||||
if let Some(action) = &spend.action {
|
||||
modal::Modal::new(content, action.view())
|
||||
.on_blur(Some(view::Message::Spend(view::SpendTxMessage::Cancel)))
|
||||
.into()
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ mod warning;
|
||||
pub mod coins;
|
||||
pub mod home;
|
||||
pub mod hw;
|
||||
pub mod psbt;
|
||||
pub mod psbts;
|
||||
pub mod receive;
|
||||
pub mod recovery;
|
||||
@ -120,7 +121,7 @@ pub fn sidebar<'a>(menu: &Menu, cache: &'a Cache) -> Container<'a, Message> {
|
||||
.center_x(),
|
||||
)
|
||||
.style(theme::Button::Menu(true))
|
||||
.on_press(Message::Reload)
|
||||
.on_press(Message::Menu(Menu::PSBTs))
|
||||
.width(iced::Length::Fill)
|
||||
} else {
|
||||
Button::new(
|
||||
@ -213,8 +214,8 @@ pub fn sidebar<'a>(menu: &Menu, cache: &'a Cache) -> Container<'a, Message> {
|
||||
.push(spend_button)
|
||||
.push(receive_button)
|
||||
.push(coins_button)
|
||||
.push(psbt_button)
|
||||
.push(transactions_button)
|
||||
.push(psbt_button)
|
||||
.height(Length::Fill),
|
||||
)
|
||||
.push(
|
||||
|
||||
@ -29,22 +29,25 @@ use liana_ui::{
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
cache::Cache,
|
||||
error::Error,
|
||||
view::{hw::hw_list_view, message::*, warning::warn},
|
||||
menu::Menu,
|
||||
view::{dashboard, hw::hw_list_view, message::*, warning::warn},
|
||||
},
|
||||
daemon::model::{Coin, SpendStatus, SpendTx},
|
||||
hw::HardwareWallet,
|
||||
};
|
||||
|
||||
pub fn spend_view<'a>(
|
||||
pub fn psbt_view<'a>(
|
||||
cache: &'a Cache,
|
||||
tx: &'a SpendTx,
|
||||
saved: bool,
|
||||
desc_info: &'a LianaPolicy,
|
||||
key_aliases: &'a HashMap<Fingerprint, String>,
|
||||
network: Network,
|
||||
) -> Element<'a, Message> {
|
||||
spend_modal(
|
||||
saved,
|
||||
dashboard(
|
||||
&Menu::PSBTs,
|
||||
&cache,
|
||||
None,
|
||||
Column::new()
|
||||
.align_items(Alignment::Center)
|
||||
@ -197,7 +200,7 @@ pub fn spend_modal<'a, T: Into<Element<'a, Message>>>(
|
||||
.into()
|
||||
}
|
||||
|
||||
fn spend_header<'a>(tx: &SpendTx) -> Element<'a, Message> {
|
||||
pub fn spend_header<'a>(tx: &SpendTx) -> Element<'a, Message> {
|
||||
Column::new()
|
||||
.spacing(20)
|
||||
.align_items(Alignment::Center)
|
||||
@ -233,7 +236,7 @@ fn spend_header<'a>(tx: &SpendTx) -> Element<'a, Message> {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn spend_overview_view<'a>(
|
||||
pub fn spend_overview_view<'a>(
|
||||
tx: &'a SpendTx,
|
||||
desc_info: &'a LianaPolicy,
|
||||
key_aliases: &'a HashMap<Fingerprint, String>,
|
||||
@ -561,7 +564,7 @@ pub fn inputs_and_outputs_view<'a>(
|
||||
coins
|
||||
.iter()
|
||||
.fold(Column::new(), |col: Column<'a, Message>, coin| {
|
||||
col.push(separation().width(Length::Fill)).push(
|
||||
col.push(
|
||||
Row::new()
|
||||
.padding(15)
|
||||
.align_items(Alignment::Center)
|
||||
@ -641,7 +644,7 @@ pub fn inputs_and_outputs_view<'a>(
|
||||
.enumerate()
|
||||
.fold(Column::new(), |col: Column<'a, Message>, (i, output)| {
|
||||
let addr = Address::from_script(&output.script_pubkey, network).unwrap();
|
||||
col.push(separation().width(Length::Fill)).push(
|
||||
col.push(
|
||||
Column::new()
|
||||
.padding(15)
|
||||
.width(Length::Fill)
|
||||
807
gui/src/app/view/psbt.rs
Normal file
807
gui/src/app/view/psbt.rs
Normal file
@ -0,0 +1,807 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use iced::{
|
||||
widget::{scrollable, tooltip, Space},
|
||||
Alignment, Length,
|
||||
};
|
||||
|
||||
use liana::{
|
||||
descriptors::{LianaPolicy, PathInfo, PathSpendInfo},
|
||||
miniscript::bitcoin::{
|
||||
util::bip32::{DerivationPath, Fingerprint},
|
||||
Address, Amount, Network, Transaction,
|
||||
},
|
||||
};
|
||||
|
||||
use liana_ui::{
|
||||
color,
|
||||
component::{
|
||||
amount::*, badge, button, card, collapse::Collapse, form, hw, separation, text::*,
|
||||
},
|
||||
icon, theme,
|
||||
util::Collection,
|
||||
widget::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
cache::Cache,
|
||||
error::Error,
|
||||
menu::Menu,
|
||||
view::{dashboard, hw::hw_list_view, message::*, warning::warn},
|
||||
},
|
||||
daemon::model::{Coin, SpendStatus, SpendTx},
|
||||
hw::HardwareWallet,
|
||||
};
|
||||
|
||||
pub fn psbt_view<'a>(
|
||||
cache: &'a Cache,
|
||||
tx: &'a SpendTx,
|
||||
saved: bool,
|
||||
desc_info: &'a LianaPolicy,
|
||||
key_aliases: &'a HashMap<Fingerprint, String>,
|
||||
network: Network,
|
||||
) -> Element<'a, Message> {
|
||||
dashboard(
|
||||
&Menu::PSBTs,
|
||||
cache,
|
||||
None,
|
||||
Column::new()
|
||||
.spacing(20)
|
||||
.push(
|
||||
Row::new()
|
||||
.align_items(Alignment::Center)
|
||||
.spacing(10)
|
||||
.push(Container::new(h3("PSBT")).width(Length::Fill))
|
||||
.push_maybe(if !tx.sigs.recovery_paths().is_empty() {
|
||||
Some(badge::recovery())
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.push_maybe(match tx.status {
|
||||
SpendStatus::Deprecated => Some(badge::deprecated()),
|
||||
SpendStatus::Broadcast => Some(badge::unconfirmed()),
|
||||
SpendStatus::Spent => Some(badge::spent()),
|
||||
_ => None,
|
||||
}),
|
||||
)
|
||||
.push(spend_header(tx))
|
||||
.push(spend_overview_view(tx, desc_info, key_aliases))
|
||||
.push(inputs_and_outputs_view(
|
||||
&tx.coins,
|
||||
&tx.psbt.unsigned_tx,
|
||||
network,
|
||||
Some(tx.change_indexes.clone()),
|
||||
None,
|
||||
))
|
||||
.push(if saved {
|
||||
Row::new()
|
||||
.push(
|
||||
button::secondary(None, "Delete")
|
||||
.width(Length::Units(200))
|
||||
.on_press(Message::Spend(SpendTxMessage::Delete)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
} else {
|
||||
Row::new()
|
||||
.push(Space::with_width(Length::Fill))
|
||||
.push(
|
||||
button::secondary(None, "Save")
|
||||
.width(Length::Units(150))
|
||||
.on_press(Message::Spend(SpendTxMessage::Save)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn save_action<'a>(warning: Option<&Error>, saved: bool) -> Element<'a, Message> {
|
||||
if saved {
|
||||
card::simple(text("Transaction is saved"))
|
||||
.width(Length::Units(400))
|
||||
.align_x(iced::alignment::Horizontal::Center)
|
||||
.into()
|
||||
} else {
|
||||
card::simple(
|
||||
Column::new()
|
||||
.spacing(10)
|
||||
.push_maybe(warning.map(|w| warn(Some(w))))
|
||||
.push(text("Save the transaction as draft"))
|
||||
.push(
|
||||
Row::new()
|
||||
.push(Column::new().width(Length::Fill))
|
||||
.push(button::alert(None, "Ignore").on_press(Message::Close))
|
||||
.push(
|
||||
button::primary(None, "Save")
|
||||
.on_press(Message::Spend(SpendTxMessage::Confirm)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.width(Length::Units(400))
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn broadcast_action<'a>(warning: Option<&Error>, saved: bool) -> Element<'a, Message> {
|
||||
if saved {
|
||||
card::simple(text("Transaction is broadcast"))
|
||||
.width(Length::Units(400))
|
||||
.align_x(iced::alignment::Horizontal::Center)
|
||||
.into()
|
||||
} else {
|
||||
card::simple(
|
||||
Column::new()
|
||||
.spacing(10)
|
||||
.push_maybe(warning.map(|w| warn(Some(w))))
|
||||
.push(text("Broadcast the transaction"))
|
||||
.push(
|
||||
Row::new().push(Column::new().width(Length::Fill)).push(
|
||||
button::primary(None, "Broadcast")
|
||||
.on_press(Message::Spend(SpendTxMessage::Confirm)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.width(Length::Units(400))
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_action<'a>(warning: Option<&Error>, deleted: bool) -> Element<'a, Message> {
|
||||
if deleted {
|
||||
card::simple(
|
||||
Column::new()
|
||||
.spacing(20)
|
||||
.align_items(Alignment::Center)
|
||||
.push(text("Transaction is deleted"))
|
||||
.push(button::primary(None, "Go back to drafts").on_press(Message::Close)),
|
||||
)
|
||||
.align_x(iced::alignment::Horizontal::Center)
|
||||
.width(Length::Units(400))
|
||||
.into()
|
||||
} else {
|
||||
card::simple(
|
||||
Column::new()
|
||||
.spacing(10)
|
||||
.push_maybe(warning.map(|w| warn(Some(w))))
|
||||
.push(text("Delete the transaction draft"))
|
||||
.push(
|
||||
Row::new()
|
||||
.push(Column::new().width(Length::Fill))
|
||||
.push(
|
||||
button::transparent(None, "Cancel")
|
||||
.on_press(Message::Spend(SpendTxMessage::Cancel)),
|
||||
)
|
||||
.push(
|
||||
button::alert(None, "Delete")
|
||||
.on_press(Message::Spend(SpendTxMessage::Confirm)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.width(Length::Units(400))
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spend_header<'a>(tx: &SpendTx) -> Element<'a, Message> {
|
||||
Column::new()
|
||||
.spacing(20)
|
||||
.push(
|
||||
Column::new()
|
||||
.push(if tx.is_self_send() {
|
||||
Container::new(h1("Self send"))
|
||||
} else {
|
||||
Container::new(amount_with_size(&tx.spend_amount, H1_SIZE))
|
||||
})
|
||||
.push(
|
||||
Row::new()
|
||||
.align_items(Alignment::Center)
|
||||
.push(h3("Miner fee: ").style(color::GREY_3))
|
||||
.push(amount_with_size(&tx.fee_amount, H3_SIZE)),
|
||||
),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn spend_overview_view<'a>(
|
||||
tx: &'a SpendTx,
|
||||
desc_info: &'a LianaPolicy,
|
||||
key_aliases: &'a HashMap<Fingerprint, String>,
|
||||
) -> Element<'a, Message> {
|
||||
Column::new()
|
||||
.spacing(20)
|
||||
.push(
|
||||
Container::new(
|
||||
Column::new()
|
||||
.push(
|
||||
Column::new()
|
||||
.padding(15)
|
||||
.spacing(10)
|
||||
.push(
|
||||
Row::new()
|
||||
.align_items(Alignment::Center)
|
||||
.push(text("PSBT").bold().width(Length::Fill))
|
||||
.push(
|
||||
Row::new()
|
||||
.spacing(5)
|
||||
.push(
|
||||
button::secondary(
|
||||
Some(icon::clipboard_icon()),
|
||||
"Copy",
|
||||
)
|
||||
.on_press(Message::Clipboard(tx.psbt.to_string())),
|
||||
)
|
||||
.push(
|
||||
button::secondary(
|
||||
Some(icon::import_icon()),
|
||||
"Update",
|
||||
)
|
||||
.on_press(Message::Spend(SpendTxMessage::EditPsbt)),
|
||||
),
|
||||
)
|
||||
.align_items(Alignment::Center),
|
||||
)
|
||||
.push(
|
||||
Row::new()
|
||||
.push(p1_bold("Tx ID").width(Length::Fill))
|
||||
.push(
|
||||
p2_regular(tx.psbt.unsigned_tx.txid().to_string())
|
||||
.style(color::GREY_3),
|
||||
)
|
||||
.push(
|
||||
Button::new(icon::clipboard_icon().style(color::GREY_3))
|
||||
.on_press(Message::Clipboard(
|
||||
tx.psbt.unsigned_tx.txid().to_string(),
|
||||
))
|
||||
.style(theme::Button::TransparentBorder),
|
||||
)
|
||||
.align_items(Alignment::Center),
|
||||
),
|
||||
)
|
||||
.push(signatures(tx, desc_info, key_aliases)),
|
||||
)
|
||||
.style(theme::Container::Card(theme::Card::Simple)),
|
||||
)
|
||||
.push_maybe(if tx.status == SpendStatus::Pending {
|
||||
Some(
|
||||
Row::new()
|
||||
.push(Space::with_width(Length::Fill))
|
||||
.push_maybe(if tx.path_ready().is_none() {
|
||||
Some(
|
||||
button::primary(None, "Sign")
|
||||
.on_press(Message::Spend(SpendTxMessage::Sign))
|
||||
.width(Length::Units(150)),
|
||||
)
|
||||
} else {
|
||||
Some(
|
||||
button::primary(None, "Broadcast")
|
||||
.on_press(Message::Spend(SpendTxMessage::Broadcast))
|
||||
.width(Length::Units(150)),
|
||||
)
|
||||
})
|
||||
.align_items(Alignment::Center)
|
||||
.spacing(20),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn signatures<'a>(
|
||||
tx: &'a SpendTx,
|
||||
desc_info: &'a LianaPolicy,
|
||||
keys_aliases: &'a HashMap<Fingerprint, String>,
|
||||
) -> Element<'a, Message> {
|
||||
Column::new()
|
||||
.push(
|
||||
if let Some(sigs) = tx.path_ready() {
|
||||
Container::new(
|
||||
scrollable(
|
||||
Row::new()
|
||||
.spacing(5)
|
||||
.align_items(Alignment::Center)
|
||||
.spacing(10)
|
||||
.push(p1_bold("Status"))
|
||||
.push(icon::circle_check_icon().style(color::GREEN))
|
||||
.push(text("Ready").bold().style(color::GREEN))
|
||||
.push(text(" signed by"))
|
||||
.push(
|
||||
sigs.signed_pubkeys
|
||||
.keys()
|
||||
.fold(Row::new().spacing(5), |row, value| {
|
||||
row.push(if let Some(alias) = keys_aliases.get(&value.0) {
|
||||
Container::new(
|
||||
tooltip::Tooltip::new(
|
||||
Container::new(text(alias))
|
||||
.padding(10)
|
||||
.style(theme::Container::Pill(theme::Pill::Simple)),
|
||||
value.0.to_string(),
|
||||
tooltip::Position::Bottom,
|
||||
)
|
||||
.style(theme::Container::Card(theme::Card::Simple)),
|
||||
)
|
||||
} else {
|
||||
Container::new(text(value.0.to_string()))
|
||||
.padding(10)
|
||||
.style(theme::Container::Pill(theme::Pill::Simple))
|
||||
})
|
||||
}),
|
||||
)
|
||||
).horizontal_scroll(scrollable::Properties::new().width(2).scroller_width(2))
|
||||
).padding(15)
|
||||
} else{
|
||||
Container::new(
|
||||
Collapse::new(
|
||||
move || {
|
||||
Button::new(
|
||||
Row::new()
|
||||
.align_items(Alignment::Center)
|
||||
.spacing(20)
|
||||
.push(p1_bold("Status"))
|
||||
.push(Row::new()
|
||||
.spacing(5)
|
||||
.align_items(Alignment::Center)
|
||||
.push(icon::circle_cross_icon().style(color::RED))
|
||||
.push(text("Not ready").style(color::RED))
|
||||
.width(Length::Fill)
|
||||
)
|
||||
.push(icon::collapse_icon()),
|
||||
)
|
||||
.padding(15)
|
||||
.width(Length::Fill)
|
||||
.style(theme::Button::TransparentBorder)
|
||||
},
|
||||
move || {
|
||||
Button::new(
|
||||
Row::new()
|
||||
.align_items(Alignment::Center)
|
||||
.spacing(20)
|
||||
.push(p1_bold("Status"))
|
||||
.push(
|
||||
Row::new()
|
||||
.spacing(5)
|
||||
.align_items(Alignment::Center)
|
||||
.push(icon::circle_cross_icon().style(color::RED))
|
||||
.push(text("Not ready").style(color::RED))
|
||||
.width(Length::Fill)
|
||||
)
|
||||
.push(icon::collapsed_icon()),
|
||||
)
|
||||
.padding(15)
|
||||
.width(Length::Fill)
|
||||
.style(theme::Button::TransparentBorder)
|
||||
},
|
||||
move || {
|
||||
Into::<Element<'a, Message>>::into(
|
||||
Column::new()
|
||||
.padding(15)
|
||||
.spacing(10)
|
||||
.push(text(if !tx.sigs.recovery_paths().is_empty() {
|
||||
"Multiple spending paths available. Finalizing this transaction requires either:"
|
||||
} else {
|
||||
"1 spending path available. Finalizing this transaction requires:"
|
||||
}))
|
||||
.push(path_view(
|
||||
desc_info.primary_path(),
|
||||
tx.sigs.primary_path(),
|
||||
keys_aliases,
|
||||
))
|
||||
.push(tx.sigs.recovery_paths().iter().fold(Column::new().spacing(10), |col, (seq, path)| {
|
||||
let keys = &desc_info.recovery_paths()[seq];
|
||||
col.push(path_view(keys, path, keys_aliases))
|
||||
})),
|
||||
)
|
||||
},
|
||||
))})
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn path_view<'a>(
|
||||
path: &'a PathInfo,
|
||||
sigs: &'a PathSpendInfo,
|
||||
key_aliases: &'a HashMap<Fingerprint, String>,
|
||||
) -> Element<'a, Message> {
|
||||
let mut keys: Vec<(Fingerprint, DerivationPath)> =
|
||||
path.thresh_origins().1.into_iter().collect();
|
||||
let missing_signatures = if sigs.sigs_count >= sigs.threshold {
|
||||
0
|
||||
} else {
|
||||
sigs.threshold - sigs.sigs_count
|
||||
};
|
||||
keys.sort();
|
||||
scrollable(
|
||||
Row::new()
|
||||
.align_items(Alignment::Center)
|
||||
.push(
|
||||
Row::new()
|
||||
.push(if sigs.sigs_count >= sigs.threshold {
|
||||
icon::circle_check_icon().style(color::GREEN)
|
||||
} else {
|
||||
icon::circle_cross_icon().style(color::GREY_3)
|
||||
})
|
||||
.push(Space::with_width(Length::Units(20))),
|
||||
)
|
||||
.push(
|
||||
p1_regular(format!(
|
||||
"{} more signature{}",
|
||||
missing_signatures,
|
||||
if missing_signatures > 1 {
|
||||
"s from "
|
||||
} else if missing_signatures == 0 {
|
||||
""
|
||||
} else {
|
||||
" from "
|
||||
}
|
||||
))
|
||||
.style(color::GREY_3),
|
||||
)
|
||||
.push_maybe(if keys.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(keys.iter().fold(Row::new().spacing(5), |row, value| {
|
||||
row.push_maybe(if !sigs.signed_pubkeys.contains_key(value) {
|
||||
Some(if let Some(alias) = key_aliases.get(&value.0) {
|
||||
Container::new(
|
||||
tooltip::Tooltip::new(
|
||||
Container::new(text(alias))
|
||||
.padding(10)
|
||||
.style(theme::Container::Pill(theme::Pill::Simple)),
|
||||
value.0.to_string(),
|
||||
tooltip::Position::Bottom,
|
||||
)
|
||||
.style(theme::Container::Card(theme::Card::Simple)),
|
||||
)
|
||||
} else {
|
||||
Container::new(text(value.0.to_string()))
|
||||
.padding(10)
|
||||
.style(theme::Container::Pill(theme::Pill::Simple))
|
||||
})
|
||||
} else {
|
||||
None
|
||||
})
|
||||
}))
|
||||
})
|
||||
.push_maybe(if sigs.signed_pubkeys.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(p1_regular(", already signed by ").style(color::GREY_3))
|
||||
})
|
||||
.push(
|
||||
sigs.signed_pubkeys
|
||||
.keys()
|
||||
.fold(Row::new().spacing(5), |row, value| {
|
||||
row.push(if let Some(alias) = key_aliases.get(&value.0) {
|
||||
Container::new(
|
||||
tooltip::Tooltip::new(
|
||||
Container::new(text(alias))
|
||||
.padding(10)
|
||||
.style(theme::Container::Pill(theme::Pill::Simple)),
|
||||
value.0.to_string(),
|
||||
tooltip::Position::Bottom,
|
||||
)
|
||||
.style(theme::Container::Card(theme::Card::Simple)),
|
||||
)
|
||||
} else {
|
||||
Container::new(text(value.0.to_string()))
|
||||
.padding(3)
|
||||
.style(theme::Container::Pill(theme::Pill::Simple))
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
.horizontal_scroll(scrollable::Properties::new().width(2).scroller_width(2))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn inputs_and_outputs_view<'a>(
|
||||
coins: &'a [Coin],
|
||||
tx: &'a Transaction,
|
||||
network: Network,
|
||||
change_indexes: Option<Vec<usize>>,
|
||||
receive_indexes: Option<Vec<usize>>,
|
||||
) -> Element<'a, Message> {
|
||||
Column::new()
|
||||
.spacing(20)
|
||||
.push_maybe(if !coins.is_empty() {
|
||||
Some(
|
||||
Container::new(Collapse::new(
|
||||
move || {
|
||||
Button::new(
|
||||
Row::new()
|
||||
.align_items(Alignment::Center)
|
||||
.push(
|
||||
h4_bold(format!(
|
||||
"{} spent coin{}",
|
||||
coins.len(),
|
||||
if coins.len() == 1 { "" } else { "s" }
|
||||
))
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(icon::collapse_icon()),
|
||||
)
|
||||
.padding(20)
|
||||
.width(Length::Fill)
|
||||
.style(theme::Button::TransparentBorder)
|
||||
},
|
||||
move || {
|
||||
Button::new(
|
||||
Row::new()
|
||||
.align_items(Alignment::Center)
|
||||
.push(
|
||||
h4_bold(format!(
|
||||
"{} spent coin{}",
|
||||
coins.len(),
|
||||
if coins.len() == 1 { "" } else { "s" }
|
||||
))
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(icon::collapsed_icon()),
|
||||
)
|
||||
.padding(20)
|
||||
.width(Length::Fill)
|
||||
.style(theme::Button::TransparentBorder)
|
||||
},
|
||||
move || {
|
||||
coins
|
||||
.iter()
|
||||
.fold(
|
||||
Column::new().padding(20),
|
||||
|col: Column<'a, Message>, coin| {
|
||||
col.push(
|
||||
Row::new()
|
||||
.align_items(Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
.push(
|
||||
Row::new()
|
||||
.width(Length::Fill)
|
||||
.align_items(Alignment::Center)
|
||||
.push(p2_regular(coin.outpoint.to_string()).style(color::GREY_3))
|
||||
.push(
|
||||
Button::new(icon::clipboard_icon().style(color::GREY_3))
|
||||
.on_press(Message::Clipboard(
|
||||
coin.outpoint.to_string(),
|
||||
))
|
||||
.style(
|
||||
theme::Button::TransparentBorder,
|
||||
),
|
||||
),
|
||||
)
|
||||
.push(amount(&coin.amount)),
|
||||
)
|
||||
},
|
||||
)
|
||||
.into()
|
||||
},
|
||||
))
|
||||
.style(theme::Container::Card(theme::Card::Simple)),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.push(
|
||||
Container::new(Collapse::new(
|
||||
move || {
|
||||
Button::new(
|
||||
Row::new()
|
||||
.align_items(Alignment::Center)
|
||||
.push(
|
||||
h4_bold(format!(
|
||||
"{} recipient{}",
|
||||
tx.output.len(),
|
||||
if tx.output.len() == 1 { "" } else { "s" }
|
||||
))
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(icon::collapse_icon()),
|
||||
)
|
||||
.padding(20)
|
||||
.width(Length::Fill)
|
||||
.style(theme::Button::TransparentBorder)
|
||||
},
|
||||
move || {
|
||||
Button::new(
|
||||
Row::new()
|
||||
.align_items(Alignment::Center)
|
||||
.push(
|
||||
h4_bold(format!(
|
||||
"{} recipient{}",
|
||||
tx.output.len(),
|
||||
if tx.output.len() == 1 { "" } else { "s" }
|
||||
))
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(icon::collapsed_icon()),
|
||||
)
|
||||
.padding(20)
|
||||
.width(Length::Fill)
|
||||
.style(theme::Button::TransparentBorder)
|
||||
},
|
||||
move || {
|
||||
tx.output
|
||||
.iter()
|
||||
.enumerate()
|
||||
.fold(
|
||||
Column::new().padding(20),
|
||||
|col: Column<'a, Message>, (i, output)| {
|
||||
let addr =
|
||||
Address::from_script(&output.script_pubkey, network).unwrap();
|
||||
col.push(
|
||||
Column::new()
|
||||
.width(Length::Fill)
|
||||
.spacing(5)
|
||||
.push(
|
||||
Row::new()
|
||||
.align_items(Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
.push(
|
||||
Row::new()
|
||||
.align_items(Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
.push(p2_regular(addr.to_string()).style(color::GREY_3))
|
||||
.push(
|
||||
Button::new(icon::clipboard_icon().style(color::GREY_3))
|
||||
.on_press(Message::Clipboard(
|
||||
addr.to_string(),
|
||||
))
|
||||
.style(
|
||||
theme::Button::TransparentBorder,
|
||||
),
|
||||
),
|
||||
)
|
||||
.push(amount(&Amount::from_sat(output.value))),
|
||||
)
|
||||
.push_maybe(if let Some(indexes) = change_indexes.as_ref() {
|
||||
if indexes.contains(&i) {
|
||||
Some(Container::new(text("Change")).padding(5).style(
|
||||
theme::Container::Pill(theme::Pill::Success),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.push_maybe(if let Some(indexes) = receive_indexes.as_ref() {
|
||||
if indexes.contains(&i) {
|
||||
Some(Container::new(text("Deposit")).padding(5).style(
|
||||
theme::Container::Pill(theme::Pill::Success),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
.into()
|
||||
},
|
||||
))
|
||||
.style(theme::Container::Card(theme::Card::Simple)),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn sign_action<'a>(
|
||||
warning: Option<&Error>,
|
||||
hws: &'a [HardwareWallet],
|
||||
signer: Option<Fingerprint>,
|
||||
signer_alias: Option<&'a String>,
|
||||
processing: bool,
|
||||
chosen_hw: Option<usize>,
|
||||
signed: &HashSet<Fingerprint>,
|
||||
) -> Element<'a, Message> {
|
||||
Column::new()
|
||||
.push_maybe(warning.map(|w| warn(Some(w))))
|
||||
.push(card::simple(
|
||||
Column::new()
|
||||
.push(
|
||||
Column::new()
|
||||
.push(
|
||||
Row::new()
|
||||
.push(
|
||||
text("Select signing device to sign with:")
|
||||
.bold()
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(button::secondary(None, "Refresh").on_press(Message::Reload))
|
||||
.align_items(Alignment::Center),
|
||||
)
|
||||
.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,
|
||||
processing,
|
||||
hw.fingerprint()
|
||||
.map(|f| signed.contains(&f))
|
||||
.unwrap_or(false),
|
||||
))
|
||||
},
|
||||
))
|
||||
.push_maybe(signer.map(|fingerprint| {
|
||||
Button::new(if signed.contains(&fingerprint) {
|
||||
hw::sign_success_hot_signer(fingerprint, signer_alias)
|
||||
} else {
|
||||
hw::hot_signer(fingerprint, signer_alias)
|
||||
})
|
||||
.on_press(Message::Spend(SpendTxMessage::SelectHotSigner))
|
||||
.padding(10)
|
||||
.style(theme::Button::Border)
|
||||
.width(Length::Fill)
|
||||
}))
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.spacing(20)
|
||||
.width(Length::Fill)
|
||||
.align_items(Alignment::Center),
|
||||
))
|
||||
.width(Length::Units(500))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn update_spend_view<'a>(
|
||||
psbt: String,
|
||||
updated: &form::Value<String>,
|
||||
error: Option<&Error>,
|
||||
processing: bool,
|
||||
) -> Element<'a, Message> {
|
||||
Column::new()
|
||||
.push(warn(error))
|
||||
.push(card::simple(
|
||||
Column::new()
|
||||
.spacing(20)
|
||||
.push(
|
||||
Row::new()
|
||||
.push(text("PSBT:").bold().width(Length::Fill))
|
||||
.push(
|
||||
button::border(Some(icon::clipboard_icon()), "Copy")
|
||||
.on_press(Message::Clipboard(psbt)),
|
||||
)
|
||||
.align_items(Alignment::Center),
|
||||
)
|
||||
.push(separation().width(Length::Fill))
|
||||
.push(
|
||||
Column::new()
|
||||
.spacing(10)
|
||||
.push(text("Insert updated PSBT:").bold())
|
||||
.push(
|
||||
form::Form::new("PSBT", updated, move |msg| {
|
||||
Message::ImportSpend(ImportSpendMessage::PsbtEdited(msg))
|
||||
})
|
||||
.warning("Please enter the correct base64 encoded PSBT")
|
||||
.size(20)
|
||||
.padding(10),
|
||||
)
|
||||
.push(Row::new().push(Space::with_width(Length::Fill)).push(
|
||||
if updated.valid && !updated.value.is_empty() && !processing {
|
||||
button::primary(None, "Update")
|
||||
.on_press(Message::ImportSpend(ImportSpendMessage::Confirm))
|
||||
} else if processing {
|
||||
button::primary(None, "Processing...")
|
||||
} else {
|
||||
button::primary(None, "Update")
|
||||
},
|
||||
)),
|
||||
),
|
||||
))
|
||||
.max_width(400)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn update_spend_success_view<'a>() -> Element<'a, Message> {
|
||||
Column::new()
|
||||
.push(
|
||||
card::simple(Container::new(
|
||||
text("Spend transaction is updated").style(color::GREEN),
|
||||
))
|
||||
.padding(50),
|
||||
)
|
||||
.width(Length::Units(400))
|
||||
.align_items(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
@ -98,26 +98,27 @@ fn spend_tx_list_view<'a>(i: usize, tx: &SpendTx) -> Element<'a, Message> {
|
||||
Row::new()
|
||||
.push(badge::spend())
|
||||
.push(if !tx.sigs.recovery_paths().is_empty() {
|
||||
Row::new().push(
|
||||
Container::new(p2_regular(" Recovery "))
|
||||
.padding(10)
|
||||
.style(theme::Container::Pill(theme::Pill::Simple)),
|
||||
)
|
||||
badge::recovery()
|
||||
} else {
|
||||
let sigs = tx.sigs.primary_path();
|
||||
Row::new()
|
||||
.spacing(5)
|
||||
.align_items(Alignment::Center)
|
||||
.push(text(format!(
|
||||
"{}/{}",
|
||||
if sigs.sigs_count <= sigs.threshold {
|
||||
sigs.sigs_count
|
||||
} else {
|
||||
sigs.threshold
|
||||
},
|
||||
sigs.threshold
|
||||
)))
|
||||
.push(icon::key_icon())
|
||||
Container::new(
|
||||
Row::new()
|
||||
.spacing(5)
|
||||
.align_items(Alignment::Center)
|
||||
.push(
|
||||
p2_regular(format!(
|
||||
"{}/{}",
|
||||
if sigs.sigs_count <= sigs.threshold {
|
||||
sigs.sigs_count
|
||||
} else {
|
||||
sigs.threshold
|
||||
},
|
||||
sigs.threshold
|
||||
))
|
||||
.style(color::GREY_3),
|
||||
)
|
||||
.push(icon::key_icon().style(color::GREY_3)),
|
||||
)
|
||||
})
|
||||
.spacing(10)
|
||||
.align_items(Alignment::Center)
|
||||
@ -131,8 +132,13 @@ fn spend_tx_list_view<'a>(i: usize, tx: &SpendTx) -> Element<'a, Message> {
|
||||
})
|
||||
.push(
|
||||
Column::new()
|
||||
.push(amount(&tx.spend_amount))
|
||||
.push(text(format!("fee: {:8}", tx.fee_amount.to_btc())).small())
|
||||
.align_items(Alignment::End)
|
||||
.push(if tx.is_self_send() {
|
||||
Container::new(amount(&tx.spend_amount))
|
||||
} else {
|
||||
Container::new(p1_regular("Self send"))
|
||||
})
|
||||
.push(amount_with_size(&tx.fee_amount, P2_SIZE))
|
||||
.width(Length::Shrink),
|
||||
)
|
||||
.align_items(Alignment::Center)
|
||||
|
||||
@ -1,2 +1,304 @@
|
||||
pub mod detail;
|
||||
pub mod step;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use iced::{
|
||||
alignment,
|
||||
widget::{checkbox, scrollable, Space},
|
||||
Alignment, Length,
|
||||
};
|
||||
|
||||
use liana::{
|
||||
descriptors::LianaPolicy,
|
||||
miniscript::bitcoin::{util::bip32::Fingerprint, Amount, Network},
|
||||
};
|
||||
|
||||
use liana_ui::{
|
||||
color,
|
||||
component::{amount::*, badge, button, form, text::*},
|
||||
icon, theme,
|
||||
util::Collection,
|
||||
widget::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
cache::Cache,
|
||||
error::Error,
|
||||
menu::Menu,
|
||||
view::{coins, dashboard, message::*, psbt},
|
||||
},
|
||||
daemon::model::{remaining_sequence, Coin, SpendTx},
|
||||
};
|
||||
|
||||
pub fn spend_view<'a>(
|
||||
cache: &'a Cache,
|
||||
tx: &'a SpendTx,
|
||||
saved: bool,
|
||||
desc_info: &'a LianaPolicy,
|
||||
key_aliases: &'a HashMap<Fingerprint, String>,
|
||||
network: Network,
|
||||
) -> Element<'a, Message> {
|
||||
dashboard(
|
||||
&Menu::CreateSpendTx,
|
||||
cache,
|
||||
None,
|
||||
Column::new()
|
||||
.spacing(20)
|
||||
.push(Container::new(h3("Send")).width(Length::Fill))
|
||||
.push(psbt::spend_header(tx))
|
||||
.push(psbt::spend_overview_view(tx, desc_info, key_aliases))
|
||||
.push(psbt::inputs_and_outputs_view(
|
||||
&tx.coins,
|
||||
&tx.psbt.unsigned_tx,
|
||||
network,
|
||||
Some(tx.change_indexes.clone()),
|
||||
None,
|
||||
))
|
||||
.push(if saved {
|
||||
Row::new()
|
||||
.push(
|
||||
button::secondary(None, "Delete")
|
||||
.width(Length::Units(200))
|
||||
.on_press(Message::Spend(SpendTxMessage::Delete)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
} else {
|
||||
Row::new()
|
||||
.push(
|
||||
button::secondary(None, "< Previous")
|
||||
.width(Length::Units(150))
|
||||
.on_press(Message::Previous),
|
||||
)
|
||||
.push(Space::with_width(Length::Fill))
|
||||
.push(
|
||||
button::secondary(None, "Save")
|
||||
.width(Length::Units(150))
|
||||
.on_press(Message::Spend(SpendTxMessage::Save)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn create_spend_tx<'a>(
|
||||
cache: &'a Cache,
|
||||
balance_available: &'a Amount,
|
||||
recipients: Vec<Element<'a, Message>>,
|
||||
total_amount: Amount,
|
||||
is_valid: bool,
|
||||
duplicate: bool,
|
||||
timelock: u16,
|
||||
coins: &[(Coin, bool)],
|
||||
amount_left: Option<&Amount>,
|
||||
feerate: &form::Value<String>,
|
||||
error: Option<&Error>,
|
||||
) -> Element<'a, Message> {
|
||||
dashboard(
|
||||
&Menu::CreateSpendTx,
|
||||
cache,
|
||||
error,
|
||||
Column::new()
|
||||
.push(h3("Send"))
|
||||
.push(
|
||||
Column::new()
|
||||
.push(Column::with_children(recipients).spacing(10))
|
||||
.push(
|
||||
Row::new()
|
||||
.push_maybe(if duplicate {
|
||||
Some(
|
||||
Container::new(
|
||||
text("Two recipient addresses are the same")
|
||||
.style(color::RED),
|
||||
)
|
||||
.padding(10),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.push(Space::with_width(Length::Fill))
|
||||
.push(
|
||||
button::secondary(Some(icon::plus_icon()), "Add recipient")
|
||||
.on_press(Message::CreateSpend(
|
||||
CreateSpendMessage::AddRecipient,
|
||||
)),
|
||||
),
|
||||
)
|
||||
.spacing(20),
|
||||
)
|
||||
.push(
|
||||
Row::new()
|
||||
.push(
|
||||
Row::new()
|
||||
.push(Container::new(p1_bold("Fee rate")).padding(10))
|
||||
.spacing(10)
|
||||
.push(
|
||||
form::Form::new("Feerate (sat/vbyte)", feerate, move |msg| {
|
||||
Message::CreateSpend(CreateSpendMessage::FeerateEdited(msg))
|
||||
})
|
||||
.warning("Invalid feerate")
|
||||
.size(20)
|
||||
.padding(10),
|
||||
)
|
||||
.width(Length::FillPortion(1)),
|
||||
)
|
||||
.push(Space::with_width(Length::FillPortion(1))),
|
||||
)
|
||||
.push(
|
||||
Container::new(
|
||||
Column::new()
|
||||
.spacing(10)
|
||||
.push(
|
||||
Row::new()
|
||||
.align_items(Alignment::Center)
|
||||
.push(p1_bold("Coins selection").width(Length::Fill))
|
||||
.push(Container::new(if let Some(amount_left) = amount_left {
|
||||
Row::new()
|
||||
.spacing(5)
|
||||
.push(amount_with_size(amount_left, P2_SIZE))
|
||||
.push(p2_regular("left to select").style(color::GREY_3))
|
||||
} else {
|
||||
Row::new()
|
||||
.push(text("Feerate needs to be set.").style(color::GREY_3))
|
||||
}))
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(
|
||||
Container::new(scrollable(coins.iter().enumerate().fold(
|
||||
Column::new().spacing(10),
|
||||
|col, (i, (coin, selected))| {
|
||||
col.push(coin_list_view(
|
||||
i,
|
||||
coin,
|
||||
timelock,
|
||||
cache.blockheight as u32,
|
||||
*selected,
|
||||
))
|
||||
},
|
||||
)))
|
||||
.max_height(300),
|
||||
),
|
||||
)
|
||||
.padding(20)
|
||||
.style(theme::Card::Simple),
|
||||
)
|
||||
.push(
|
||||
Row::new()
|
||||
.spacing(20)
|
||||
.align_items(Alignment::Center)
|
||||
.push(Space::with_width(Length::Fill))
|
||||
.push(
|
||||
button::primary(None, "Clear")
|
||||
.on_press(Message::Menu(Menu::CreateSpendTx))
|
||||
.width(Length::Units(100)),
|
||||
)
|
||||
.push(
|
||||
if is_valid
|
||||
&& total_amount < *balance_available
|
||||
&& Some(&Amount::from_sat(0)) == amount_left
|
||||
{
|
||||
button::primary(None, "Next")
|
||||
.on_press(Message::CreateSpend(CreateSpendMessage::Generate))
|
||||
.width(Length::Units(100))
|
||||
} else {
|
||||
button::primary(None, "Next").width(Length::Units(100))
|
||||
},
|
||||
),
|
||||
)
|
||||
.push(Space::with_height(Length::Units(20)))
|
||||
.spacing(20),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn recipient_view<'a>(
|
||||
index: usize,
|
||||
address: &form::Value<String>,
|
||||
amount: &form::Value<String>,
|
||||
) -> 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(
|
||||
Row::new()
|
||||
.align_items(Alignment::Start)
|
||||
.spacing(10)
|
||||
.push(
|
||||
Container::new(p1_bold("Pay to"))
|
||||
.align_x(alignment::Horizontal::Right)
|
||||
.padding(10)
|
||||
.width(Length::Units(80)),
|
||||
)
|
||||
.push(
|
||||
form::Form::new("Address", address, move |msg| {
|
||||
CreateSpendMessage::RecipientEdited(index, "address", msg)
|
||||
})
|
||||
.warning("Invalid address (maybe it is for another network?)")
|
||||
.size(20)
|
||||
.padding(10),
|
||||
),
|
||||
)
|
||||
.push(
|
||||
Row::new()
|
||||
.align_items(Alignment::Start)
|
||||
.spacing(10)
|
||||
.push(
|
||||
Container::new(p1_bold("Amount"))
|
||||
.padding(10)
|
||||
.align_x(alignment::Horizontal::Right)
|
||||
.width(Length::Units(80)),
|
||||
)
|
||||
.push(
|
||||
form::Form::new("ex: 0.001", amount, move |msg| {
|
||||
CreateSpendMessage::RecipientEdited(index, "amount", msg)
|
||||
})
|
||||
.warning("Invalid amount. Must be > 0.00005000 BTC.")
|
||||
.size(20)
|
||||
.padding(10),
|
||||
)
|
||||
.width(Length::Fill),
|
||||
),
|
||||
)
|
||||
.padding(20)
|
||||
.style(theme::Card::Simple)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn coin_list_view<'a>(
|
||||
i: usize,
|
||||
coin: &Coin,
|
||||
timelock: u16,
|
||||
blockheight: u32,
|
||||
selected: bool,
|
||||
) -> Element<'a, Message> {
|
||||
Row::new()
|
||||
.push(
|
||||
Row::new()
|
||||
.push(checkbox("", selected, move |_| {
|
||||
Message::CreateSpend(CreateSpendMessage::SelectCoin(i))
|
||||
}))
|
||||
.push(if coin.spend_info.is_some() {
|
||||
badge::spent()
|
||||
} else if coin.block_height.is_none() {
|
||||
badge::unconfirmed()
|
||||
} else {
|
||||
let seq = remaining_sequence(coin, blockheight, timelock);
|
||||
coins::coin_sequence_label(seq, timelock as u32)
|
||||
})
|
||||
.spacing(10)
|
||||
.align_items(Alignment::Center)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(amount(&coin.amount))
|
||||
// give some space for the scroll bar without using padding
|
||||
.push(Space::with_width(Length::Units(0)))
|
||||
.align_items(Alignment::Center)
|
||||
.spacing(20)
|
||||
.into()
|
||||
}
|
||||
|
||||
@ -1,232 +0,0 @@
|
||||
use iced::{Alignment, Length};
|
||||
|
||||
use liana::miniscript::bitcoin::Amount;
|
||||
|
||||
use liana_ui::{
|
||||
color,
|
||||
component::{
|
||||
amount::*,
|
||||
badge, button, form,
|
||||
text::{text, Text},
|
||||
},
|
||||
icon, theme,
|
||||
util::Collection,
|
||||
widget::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
cache::Cache,
|
||||
error::Error,
|
||||
view::{coins, message::*, modal},
|
||||
},
|
||||
daemon::model::{remaining_sequence, Coin},
|
||||
};
|
||||
|
||||
pub fn choose_recipients_view<'a>(
|
||||
balance_available: &'a Amount,
|
||||
recipients: Vec<Element<'a, Message>>,
|
||||
total_amount: Amount,
|
||||
is_valid: bool,
|
||||
duplicate: bool,
|
||||
) -> Element<'a, Message> {
|
||||
modal(
|
||||
false,
|
||||
None,
|
||||
Column::new()
|
||||
.push(text("Choose recipients").bold().size(50))
|
||||
.push(
|
||||
Column::new()
|
||||
.push(Column::with_children(recipients).spacing(10))
|
||||
.push(
|
||||
button::transparent(Some(icon::plus_icon()), "Add recipient")
|
||||
.on_press(Message::CreateSpend(CreateSpendMessage::AddRecipient)),
|
||||
)
|
||||
.padding(10)
|
||||
.max_width(1000)
|
||||
.spacing(10),
|
||||
)
|
||||
.spacing(20)
|
||||
.align_items(Alignment::Center),
|
||||
Some(
|
||||
Container::new(
|
||||
Row::new()
|
||||
.spacing(20)
|
||||
.align_items(Alignment::Center)
|
||||
.push(
|
||||
Container::new(
|
||||
Row::new()
|
||||
.align_items(Alignment::Center)
|
||||
.spacing(5)
|
||||
.push(text(format!("{}", total_amount)).bold())
|
||||
.push(text(format!("/ {}", balance_available))),
|
||||
)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push_maybe(if duplicate {
|
||||
Some(text("Two recipient addresses are the same").style(color::ORANGE))
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.push(if is_valid && total_amount < *balance_available {
|
||||
button::primary(None, "Next")
|
||||
.on_press(Message::Next)
|
||||
.width(Length::Units(100))
|
||||
} else {
|
||||
button::primary(None, "Next").width(Length::Units(100))
|
||||
}),
|
||||
)
|
||||
.style(theme::Container::Foreground)
|
||||
.padding(20),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn recipient_view<'a>(
|
||||
index: usize,
|
||||
address: &form::Value<String>,
|
||||
amount: &form::Value<String>,
|
||||
) -> Element<'a, CreateSpendMessage> {
|
||||
Row::new()
|
||||
.push(
|
||||
form::Form::new("Address", address, move |msg| {
|
||||
CreateSpendMessage::RecipientEdited(index, "address", msg)
|
||||
})
|
||||
.warning("Invalid address (maybe it is for another network?)")
|
||||
.size(20)
|
||||
.padding(10),
|
||||
)
|
||||
.push(
|
||||
Container::new(
|
||||
form::Form::new("Amount", amount, move |msg| {
|
||||
CreateSpendMessage::RecipientEdited(index, "amount", msg)
|
||||
})
|
||||
.warning("Invalid amount. Must be > 0.00005000 BTC.")
|
||||
.size(20)
|
||||
.padding(10),
|
||||
)
|
||||
.width(Length::Units(300)),
|
||||
)
|
||||
.spacing(5)
|
||||
.push(
|
||||
button::transparent(Some(icon::trash_icon()), "")
|
||||
.on_press(CreateSpendMessage::DeleteRecipient(index))
|
||||
.width(Length::Shrink),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn choose_coins_view<'a>(
|
||||
cache: &Cache,
|
||||
timelock: u16,
|
||||
coins: &[(Coin, bool)],
|
||||
amount_left: Option<&Amount>,
|
||||
feerate: &form::Value<String>,
|
||||
error: Option<&Error>,
|
||||
) -> Element<'a, Message> {
|
||||
modal(
|
||||
true,
|
||||
error,
|
||||
Column::new()
|
||||
.push(text("Choose coins and feerate").bold().size(50))
|
||||
.push(
|
||||
Container::new(
|
||||
form::Form::new("Feerate (sat/vbyte)", feerate, move |msg| {
|
||||
Message::CreateSpend(CreateSpendMessage::FeerateEdited(msg))
|
||||
})
|
||||
.warning("Invalid feerate")
|
||||
.size(20)
|
||||
.padding(10),
|
||||
)
|
||||
.width(Length::Units(250)),
|
||||
)
|
||||
.push(
|
||||
Column::new()
|
||||
.padding(10)
|
||||
.spacing(10)
|
||||
.push(coins.iter().enumerate().fold(
|
||||
Column::new().spacing(10),
|
||||
|col, (i, (coin, selected))| {
|
||||
col.push(coin_list_view(
|
||||
i,
|
||||
coin,
|
||||
timelock,
|
||||
cache.blockheight as u32,
|
||||
*selected,
|
||||
))
|
||||
},
|
||||
)),
|
||||
)
|
||||
.spacing(20)
|
||||
.align_items(Alignment::Center),
|
||||
Some(
|
||||
Container::new(
|
||||
Row::new()
|
||||
.align_items(Alignment::Center)
|
||||
.push(
|
||||
Container::new(if let Some(amount_left) = amount_left {
|
||||
Row::new()
|
||||
.spacing(5)
|
||||
.push(text("Amount left to select:"))
|
||||
.push(text(amount_left.to_string()).bold())
|
||||
} else {
|
||||
Row::new().push(text("Feerate needs to be set."))
|
||||
})
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(if Some(&Amount::from_sat(0)) == amount_left {
|
||||
button::primary(None, "Next")
|
||||
.on_press(Message::CreateSpend(CreateSpendMessage::Generate))
|
||||
.width(Length::Units(100))
|
||||
} else {
|
||||
button::primary(None, "Next").width(Length::Units(100))
|
||||
}),
|
||||
)
|
||||
.style(theme::Container::Foreground)
|
||||
.padding(20),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn coin_list_view<'a>(
|
||||
i: usize,
|
||||
coin: &Coin,
|
||||
timelock: u16,
|
||||
blockheight: u32,
|
||||
selected: bool,
|
||||
) -> Element<'a, Message> {
|
||||
Container::new(
|
||||
Button::new(
|
||||
Row::new()
|
||||
.push(
|
||||
Row::new()
|
||||
.push(if selected {
|
||||
icon::square_check_icon()
|
||||
} else {
|
||||
icon::square_icon()
|
||||
})
|
||||
.push(badge::coin())
|
||||
.push(if coin.spend_info.is_some() {
|
||||
badge::spent()
|
||||
} else if coin.block_height.is_none() {
|
||||
badge::unconfirmed()
|
||||
} else {
|
||||
let seq = remaining_sequence(coin, blockheight, timelock);
|
||||
coins::coin_sequence_label(seq, timelock as u32)
|
||||
})
|
||||
.spacing(10)
|
||||
.align_items(Alignment::Center)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(amount(&coin.amount))
|
||||
.align_items(Alignment::Center)
|
||||
.spacing(20),
|
||||
)
|
||||
.padding(10)
|
||||
.on_press(Message::CreateSpend(CreateSpendMessage::SelectCoin(i)))
|
||||
.style(theme::Button::TransparentBorder),
|
||||
)
|
||||
.style(theme::Container::Card(theme::Card::Simple))
|
||||
.into()
|
||||
}
|
||||
@ -176,7 +176,7 @@ pub fn tx_view<'a>(cache: &Cache, tx: &'a HistoryTransaction) -> Element<'a, Mes
|
||||
)
|
||||
.spacing(5),
|
||||
))
|
||||
.push(super::spend::detail::inputs_and_outputs_view(
|
||||
.push(super::psbt::inputs_and_outputs_view(
|
||||
&tx.coins,
|
||||
&tx.tx,
|
||||
cache.network,
|
||||
|
||||
@ -121,6 +121,10 @@ impl SpendTx {
|
||||
|
||||
signers
|
||||
}
|
||||
|
||||
pub fn is_self_send(&self) -> bool {
|
||||
!self.coins.is_empty() && self.spend_amount == Amount::from_sat(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@ -65,6 +65,19 @@ pub fn coin<T>() -> Container<'static, T> {
|
||||
.center_y()
|
||||
}
|
||||
|
||||
pub fn recovery<'a, T: 'a>() -> Container<'a, T> {
|
||||
Container::new(
|
||||
tooltip::Tooltip::new(
|
||||
Container::new(text::p2_regular(" Recovery "))
|
||||
.padding(10)
|
||||
.style(theme::Container::Pill(theme::Pill::Simple)),
|
||||
"This transaction is using a recovery path",
|
||||
tooltip::Position::Top,
|
||||
)
|
||||
.style(theme::Container::Card(theme::Card::Simple)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn unconfirmed<'a, T: 'a>() -> Container<'a, T> {
|
||||
Container::new(
|
||||
tooltip::Tooltip::new(
|
||||
|
||||
@ -418,20 +418,30 @@ pub struct CheckBox {}
|
||||
impl checkbox::StyleSheet for Theme {
|
||||
type Style = CheckBox;
|
||||
|
||||
fn active(&self, _style: &Self::Style, _is_selected: bool) -> checkbox::Appearance {
|
||||
checkbox::Appearance {
|
||||
background: iced::Color::TRANSPARENT.into(),
|
||||
border_width: 1.0,
|
||||
border_color: color::GREY_7,
|
||||
checkmark_color: color::GREEN,
|
||||
text_color: None,
|
||||
border_radius: 0.0,
|
||||
fn active(&self, _style: &Self::Style, is_selected: bool) -> checkbox::Appearance {
|
||||
if is_selected {
|
||||
checkbox::Appearance {
|
||||
background: color::GREEN.into(),
|
||||
border_width: 0.0,
|
||||
border_color: iced::Color::TRANSPARENT,
|
||||
checkmark_color: color::GREY_4,
|
||||
text_color: None,
|
||||
border_radius: 4.0,
|
||||
}
|
||||
} else {
|
||||
checkbox::Appearance {
|
||||
background: color::GREY_4.into(),
|
||||
border_width: 0.0,
|
||||
border_color: iced::Color::TRANSPARENT,
|
||||
checkmark_color: color::GREEN,
|
||||
text_color: None,
|
||||
border_radius: 4.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hovered(&self, style: &Self::Style, is_selected: bool) -> checkbox::Appearance {
|
||||
let active = self.active(style, is_selected);
|
||||
checkbox::Appearance { ..active }
|
||||
self.active(style, is_selected)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user