diff --git a/gui/Cargo.lock b/gui/Cargo.lock index 70d4d897..f77c87fa 100644 --- a/gui/Cargo.lock +++ b/gui/Cargo.lock @@ -1541,7 +1541,7 @@ dependencies = [ [[package]] name = "liana" version = "0.0.1" -source = "git+https://github.com/revault/liana?branch=master#dc23f3667a977dae93cfeb13ee9694e41d020251" +source = "git+https://github.com/revault/liana?branch=master#38e342c8cd4c7d5d9d497013aa466fe62c0e3a4b" dependencies = [ "backtrace", "base64", diff --git a/gui/src/app/mod.rs b/gui/src/app/mod.rs index 3aab6ded..9403f27a 100644 --- a/gui/src/app/mod.rs +++ b/gui/src/app/mod.rs @@ -71,9 +71,13 @@ impl App { .into(), menu::Menu::Receive => ReceivePanel::default().into(), menu::Menu::Spend => SpendPanel::new(self.config.clone(), &self.cache.spend_txs).into(), - menu::Menu::CreateSpendTx => { - CreateSpendPanel::new(self.config.clone(), &self.cache.coins).into() - } + menu::Menu::CreateSpendTx => CreateSpendPanel::new( + self.config.clone(), + &self.cache.coins, + self.daemon.config().main_descriptor.timelock_value(), + self.cache.blockheight as u32, + ) + .into(), }; self.state.load(self.daemon.clone()) } diff --git a/gui/src/app/state/mod.rs b/gui/src/app/state/mod.rs index 59a90fda..121bb8c3 100644 --- a/gui/src/app/state/mod.rs +++ b/gui/src/app/state/mod.rs @@ -75,6 +75,7 @@ impl State for Home { false, self.warning.as_ref(), view::home::event_view(cache, &self.events[i]), + None::>, ); } view::dashboard( diff --git a/gui/src/app/state/spend/mod.rs b/gui/src/app/state/spend/mod.rs index e66da1a6..9d10d729 100644 --- a/gui/src/app/state/spend/mod.rs +++ b/gui/src/app/state/spend/mod.rs @@ -104,14 +104,17 @@ pub struct CreateSpendPanel { } impl CreateSpendPanel { - pub fn new(config: Config, coins: &[Coin]) -> Self { + pub fn new(config: Config, coins: &[Coin], timelock: u32, blockheight: u32) -> Self { Self { draft: step::TransactionDraft::default(), current: 0, steps: vec![ Box::new(step::ChooseRecipients::default()), - Box::new(step::ChooseCoins::new(coins.to_vec())), - Box::new(step::ChooseFeerate::default()), + Box::new(step::ChooseCoins::new( + coins.to_vec(), + timelock, + blockheight, + )), Box::new(step::SaveSpend::new(config)), ], } diff --git a/gui/src/app/state/spend/step.rs b/gui/src/app/state/spend/step.rs index e519a475..e931028d 100644 --- a/gui/src/app/state/spend/step.rs +++ b/gui/src/app/state/spend/step.rs @@ -3,8 +3,9 @@ use std::str::FromStr; use std::sync::Arc; use iced::{Command, Element}; -use liana::miniscript::bitcoin::{ - util::psbt::Psbt, Address, Amount, Denomination, OutPoint, Script, +use liana::{ + config::Config as DaemonConfig, + miniscript::bitcoin::{util::psbt::Psbt, Address, Amount, Denomination, OutPoint, Script}, }; use crate::{ @@ -12,7 +13,7 @@ use crate::{ cache::Cache, config::Config, error::Error, message::Message, state::spend::detail, view, }, daemon::{ - model::{Coin, SpendTx}, + model::{remaining_sequence, Coin, SpendTx}, Daemon, }, ui::component::form, @@ -22,7 +23,6 @@ use crate::{ pub struct TransactionDraft { inputs: Vec, outputs: HashMap, - feerate: u64, generated: Option, } @@ -94,6 +94,12 @@ impl Step for ChooseRecipients { .enumerate() .map(|(i, recipient)| recipient.view(i).map(view::Message::CreateSpend)) .collect(), + Amount::from_sat( + self.recipients + .iter() + .map(|r| r.amount().unwrap_or(0_u64)) + .sum(), + ), !self.recipients.iter().any(|recipient| !recipient.valid()), ) } @@ -171,13 +177,111 @@ impl Recipient { } #[derive(Default)] -pub struct ChooseFeerate { +pub struct ChooseCoins { + timelock: u32, + coins: Vec<(Coin, bool)>, + recipients: Vec<(Address, Amount)>, + + amount_left_to_select: Option, feerate: form::Value, generated: Option, warning: Option, } -impl Step for ChooseFeerate { +impl ChooseCoins { + pub fn new(coins: Vec, timelock: u32, 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 { + 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, cfg: &DaemonConfig) { + let mut tx_size = 0_u64; + let mut outgoing_amount = 0_u64; + for (address, amount) in &self.recipients { + outgoing_amount += amount.to_sat(); + tx_size += 8 + address.script_pubkey().len() as u64; + } + + // change output + tx_size += 8 + 34; + // overhead + tx_size += 11; + + let input_size = cfg + .main_descriptor + .receive_descriptor() + .spender_input_size(); + + let mut selected_amount = 0_u64; + for (coin, selected) in &self.coins { + if *selected { + selected_amount += coin.amount.to_sat(); + tx_size += input_size as u64; + } + } + + // If feerate is set we can calcul the required amount. + if let Ok(feerate) = self.feerate.value.parse::() { + let required_amount = tx_size * feerate + outgoing_amount; + + if selected_amount > required_amount { + self.amount_left_to_select = Some(Amount::from_sat(0)); + } else { + self.amount_left_to_select = + Some(Amount::from_sat(required_amount - selected_amount)); + } + } else { + self.amount_left_to_select = None; + } + } +} + +impl Step for ChooseCoins { + fn load(&mut self, draft: &TransactionDraft) { + self.recipients = draft + .outputs + .iter() + .map(|(k, v)| (k.clone(), Amount::from_sat(*v))) + .collect(); + } + + 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, @@ -192,16 +296,25 @@ impl Step for ChooseFeerate { if s.parse::().is_ok() { self.feerate.value = s; self.feerate.valid = true; + self.amount_left_to_select(daemon.config()); } 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 = draft.inputs.iter().map(|c| c.outpoint).collect(); + let inputs: Vec = 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::().unwrap_or(0); self.warning = None; @@ -222,104 +335,30 @@ impl Step for ChooseFeerate { } 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(daemon.config()); + } + } _ => {} } Command::none() } - fn apply(&self, draft: &mut TransactionDraft) { - draft.feerate = self.feerate.value.parse::().expect("Checked before"); - draft.generated = self.generated.clone(); - } - - fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> { - view::spend::step::choose_feerate_view( + 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.feerate.valid && !self.feerate.value.is_empty(), self.warning.as_ref(), ) } } -#[derive(Default)] -pub struct ChooseCoins { - coins: Vec<(Coin, bool)>, - /// draft output amount must be superior to total input amount. - is_valid: bool, - total_needed: Option, -} - -impl ChooseCoins { - pub fn new(coins: Vec) -> Self { - Self { - coins: coins - .into_iter() - .filter_map(|c| { - if c.spend_info.is_none() { - Some((c, false)) - } else { - None - } - }) - .collect(), - is_valid: false, - total_needed: None, - } - } -} - -impl Step for ChooseCoins { - fn load(&mut self, draft: &TransactionDraft) { - self.total_needed = Some(Amount::from_sat( - draft.outputs.values().fold(0, |acc, a| acc + *a), - )); - } - - fn update( - &mut self, - _daemon: Arc, - _cache: &Cache, - _draft: &TransactionDraft, - message: Message, - ) -> Command { - if let Message::View(view::Message::CreateSpend(view::CreateSpendMessage::SelectCoin(i))) = - message - { - if let Some(coin) = self.coins.get_mut(i) { - coin.1 = !coin.1; - } - - self.is_valid = self - .coins - .iter() - .filter_map(|(coin, selected)| { - if *selected { - Some(coin.amount.to_sat()) - } else { - None - } - }) - .sum::() - > self.total_needed.map(|a| a.to_sat()).unwrap_or(0); - } - - Command::none() - } - - fn apply(&self, draft: &mut TransactionDraft) { - draft.inputs = self - .coins - .iter() - .filter_map(|(coin, selected)| if *selected { Some(*coin) } else { None }) - .collect(); - } - - fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> { - view::spend::step::choose_coins_view(&self.coins, self.total_needed.as_ref(), self.is_valid) - } -} - pub struct SaveSpend { config: Config, spend: Option, diff --git a/gui/src/app/view/mod.rs b/gui/src/app/view/mod.rs index 1b90c8e8..781441a0 100644 --- a/gui/src/app/view/mod.rs +++ b/gui/src/app/view/mod.rs @@ -273,10 +273,11 @@ fn main_section<'a, T: 'a>(menu: widget::Container<'a, T>) -> widget::Container< .height(Length::Fill) } -pub fn modal<'a, T: Into>>( +pub fn modal<'a, T: Into>, F: Into>>( is_previous: bool, warning: Option<&Error>, content: T, + fixed_footer: Option, ) -> Element<'a, Message> { Column::new() .push(warn(warning)) @@ -299,6 +300,7 @@ pub fn modal<'a, T: Into>>( .style(container::Style::Background), ) .push(modal_section(Container::new(scrollable(content)))) + .push_maybe(fixed_footer) .width(Length::Fill) .height(Length::Fill) .into() @@ -306,7 +308,6 @@ pub fn modal<'a, T: Into>>( fn modal_section<'a, T: 'a>(menu: widget::Container<'a, T>) -> widget::Container<'a, T> { Container::new(menu.max_width(1500)) - .padding(20) .style(container::Style::Background) .center_x() .width(Length::Fill) diff --git a/gui/src/app/view/spend/detail.rs b/gui/src/app/view/spend/detail.rs index fc64fe42..5e844bce 100644 --- a/gui/src/app/view/spend/detail.rs +++ b/gui/src/app/view/spend/detail.rs @@ -155,7 +155,9 @@ pub fn spend_modal<'a, T: Into>>( .padding(10) .style(container::Style::Background), ) - .push(modal_section(Container::new(Scrollable::new(content)))) + .push(modal_section(Container::new( + Container::new(Scrollable::new(content)).max_width(750), + ))) .width(Length::Fill) .height(Length::Fill) .into() diff --git a/gui/src/app/view/spend/mod.rs b/gui/src/app/view/spend/mod.rs index a7fe868b..d7f3e0df 100644 --- a/gui/src/app/view/spend/mod.rs +++ b/gui/src/app/view/spend/mod.rs @@ -22,7 +22,7 @@ pub fn spend_view<'a>(spend_txs: &[SpendTx]) -> Element<'a, Message> { Column::new() .push( Row::new().push(Column::new().width(Length::Fill)).push( - button::primary(Some(icon::plus_icon()), "Create a new transaction") + button::primary(Some(icon::plus_icon()), "New transaction") .on_press(Message::Menu(Menu::CreateSpendTx)), ), ) diff --git a/gui/src/app/view/spend/step.rs b/gui/src/app/view/spend/step.rs index 57238dc9..23f7e758 100644 --- a/gui/src/app/view/spend/step.rs +++ b/gui/src/app/view/spend/step.rs @@ -7,11 +7,13 @@ use liana::miniscript::bitcoin::Amount; use crate::{ app::{ + cache::Cache, error::Error, view::{message::*, modal}, }, daemon::model::Coin, ui::{ + color, component::{ badge, button, card, form, text::{text, Text}, @@ -23,6 +25,7 @@ use crate::{ pub fn choose_recipients_view( recipients: Vec>, + total_amount: Amount, is_valid: bool, ) -> Element { modal( @@ -37,20 +40,30 @@ pub fn choose_recipients_view( button::transparent(Some(icon::plus_icon()), "Add recipient") .on_press(Message::CreateSpend(CreateSpendMessage::AddRecipient)), ) + .padding(10) .max_width(1000) .spacing(10), ) - .push_maybe(if is_valid { - Some( - button::primary(None, "Next") - .on_press(Message::Next) - .width(Length::Units(100)), - ) - } else { - None - }) .spacing(20) .align_items(Alignment::Center), + Some( + Container::new( + Row::new() + .align_items(Alignment::Center) + .push( + Container::new(text(format!("{}", total_amount)).bold()) + .width(Length::Fill), + ) + .push(if is_valid { + button::primary(None, "Next") + .on_press(Message::Next) + .width(Length::Units(100)) + } else { + button::primary(None, "Next").width(Length::Units(100)) + }), + ) + .padding(20), + ), ) } @@ -89,16 +102,19 @@ pub fn recipient_view<'a>( .into() } -pub fn choose_feerate_view<'a>( +pub fn choose_coins_view<'a>( + cache: &Cache, + timelock: u32, + coins: &[(Coin, bool)], + amount_left: Option<&Amount>, feerate: &form::Value, - is_valid: bool, error: Option<&Error>, ) -> Element<'a, Message> { modal( true, - None, + error, Column::new() - .push(text("Choose feerate").bold().size(50)) + .push(text("Choose coins and feerate").bold().size(50)) .push( Container::new( form::Form::new("Feerate", feerate, move |msg| { @@ -110,61 +126,60 @@ pub fn choose_feerate_view<'a>( ) .width(Length::Units(250)), ) - .push_maybe(error.map(|e| card::error("Failed to create spend", e.to_string()))) - .push_maybe(if is_valid { - Some( - button::primary(None, "Next") - .on_press(Message::CreateSpend(CreateSpendMessage::Generate)) - .width(Length::Units(100)), - ) - } else { - None - }) - .spacing(20) - .align_items(Alignment::Center), - ) -} - -pub fn choose_coins_view<'a>( - coins: &[(Coin, bool)], - total_needed: Option<&Amount>, - is_valid: bool, -) -> Element<'a, Message> { - modal( - true, - None, - Column::new() - .push(text("Choose coins").bold().size(50)) .push( - Column::new().spacing(10).push( - coins - .iter() - .enumerate() - .fold(Column::new().spacing(10), |col, (i, (coin, selected))| { - col.push(coin_list_view(i, coin, *selected)) - }), - ), + 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, + )) + }, + )), ) - .push_maybe(if is_valid { - Some(Container::new( - button::primary(None, "Next") - .on_press(Message::Next) - .width(Length::Units(100)), - )) - } else if total_needed.is_some() { - Some(Container::new(card::warning(format!( - "Total amount must be superior to {}", - total_needed.unwrap().to_btc(), - )))) - } else { - None - }) .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("Please, define feerate")) + }) + .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)) + }), + ) + .padding(20), + ), ) } -fn coin_list_view<'a>(i: usize, coin: &Coin, selected: bool) -> Element<'a, Message> { +fn coin_list_view<'a>( + i: usize, + coin: &Coin, + timelock: u32, + blockheight: u32, + selected: bool, +) -> Element<'a, Message> { Container::new( Button::new( Row::new() @@ -176,7 +191,32 @@ fn coin_list_view<'a>(i: usize, coin: &Coin, selected: bool) -> Element<'a, Mess icon::square_icon() }) .push(badge::coin()) - .push(text(format!("block: {}", coin.block_height.unwrap_or(0))).small()) + .push_maybe(if let Some(b) = coin.block_height { + if blockheight > b as u32 + timelock { + Some(Container::new( + Row::new() + .spacing(5) + .push(text(" 0").small().style(color::ALERT)) + .push( + icon::hourglass_done_icon().small().style(color::ALERT), + ) + .align_items(Alignment::Center), + )) + } else { + Some(Container::new( + Row::new() + .spacing(5) + .push( + text(format!(" {}", b as u32 + timelock - blockheight)) + .small(), + ) + .push(icon::hourglass_icon().small()) + .align_items(Alignment::Center), + )) + } + } else { + None + }) .spacing(10) .align_items(Alignment::Center) .width(Length::Fill), diff --git a/gui/src/daemon/model.rs b/gui/src/daemon/model.rs index e33c252a..f4ef62fc 100644 --- a/gui/src/daemon/model.rs +++ b/gui/src/daemon/model.rs @@ -8,6 +8,18 @@ pub use liana::{ pub type Coin = ListCoinsEntry; +pub fn remaining_sequence(coin: &Coin, blockheight: u32, timelock: u32) -> u32 { + if let Some(coin_blockheight) = coin.block_height { + if blockheight > coin_blockheight as u32 + timelock { + 0 + } else { + coin_blockheight as u32 + timelock - blockheight + } + } else { + timelock + } +} + #[derive(Debug, Clone)] pub struct SpendTx { pub coins: Vec,