Repository: wizardsardine/liana
Pull request #1662: Show fee next to feerate input when creating a new spend
Author: login: thomasballivet
Created: 2025-04-21T05:36:11Z | Merged: 2025-04-23T07:59:44Z


![Capture d’écran du 2025-04-21 07-41-39](https://github.com/user-attachments/assets/d9de2c77-a458-4116-bce2-e3ca530372cf)

![Capture d’écran du 2025-04-21 07-42-33](https://github.com/user-attachments/assets/a9648183-74f2-4ec6-8a8f-8667baa70fea)


Closes #1653 

Linked issue #1653: [GUI] Show fee next to feerate input when creating a new spend
Once a draft spend has been created, we could show the fee next to the feerate input:

![Image](https://github.com/user-attachments/assets/645c04a4-0bee-44c4-872c-039241184cf5)

This can be applied also to a recovery spend following implementation of #1641.

Diff:
diff --git a/liana-gui/src/app/state/spend/step.rs b/liana-gui/src/app/state/spend/step.rs
index 504966e4c..54a1c3e30 100644
--- a/liana-gui/src/app/state/spend/step.rs
+++ b/liana-gui/src/app/state/spend/step.rs
@@ -88,6 +88,7 @@ pub struct DefineSpend {
     batch_label: form::Value<String>,
     amount_left_to_select: Option<Amount>,
     feerate: form::Value<String>,
+    fee_amount: Option<Amount>,
     generated: Option<(Psbt, Vec<String>)>,
     warning: Option<Error>,
 }
@@ -125,6 +126,7 @@ impl DefineSpend {
             is_valid: false,
             is_duplicate: false,
             feerate: form::Value::default(),
+            fee_amount: None,
             amount_left_to_select: None,
             warning: None,
         }
@@ -197,10 +199,7 @@ impl DefineSpend {
     /// redraft calculates the amount left to select and auto selects coins
     /// if the user did not select a coin manually
     fn redraft(&mut self, daemon: Arc<dyn Daemon + Sync + Send>) {
-        if !self.form_values_are_valid(true)
-            || self.exists_duplicate()
-            || self.recipients.is_empty()
-        {
+        if !self.form_values_are_valid(true) || self.exists_duplicate() {
             // The current form details are not valid to draft a spend, so remove any previously
             // calculated amount as it will no longer be valid and could be misleading, e.g. if
             // the user removes the amount from one of the recipients.
@@ -218,9 +217,10 @@ impl DefineSpend {
                         view::CreateSpendMessage::RecipientEdited(i, "amount", "".to_string()),
                     );
             }
+            self.fee_amount = None;
             return;
         }
-
+        let is_self_transfer = self.recipients.is_empty();
         let destinations: HashMap<Address<address::NetworkUnchecked>, u64> = self
             .recipients
             .iter()
@@ -252,8 +252,7 @@ impl DefineSpend {
         } else {
             None
         };
-
-        let outpoints = if self.is_user_coin_selection {
+        let outpoints = if self.is_user_coin_selection || is_self_transfer {
             let outpoints: Vec<_> = self
                 .coins
                 .iter()
@@ -279,10 +278,11 @@ impl DefineSpend {
                 // 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.
                 self.amount_left_to_select = Some(Amount::from_sat(destinations.values().sum()));
+                self.fee_amount = None;
                 return;
             }
             outpoints
-        } else if self.send_max_to_recipient.is_some() {
+        } else if !self.is_user_coin_selection && self.send_max_to_recipient.is_some() {
             // If user has not selected coins, send the max available from all owned coins.
             self.coins
                 .iter()
@@ -323,6 +323,7 @@ impl DefineSpend {
         }) {
             Ok(CreateSpendResult::Success { psbt, .. }) => {
                 self.warning = None;
+                self.fee_amount = Some(psbt.fee().expect("Valid fees"));
                 if !self.is_user_coin_selection {
                     let selected_coins: Vec<OutPoint> = psbt
                         .unsigned_tx
@@ -358,6 +359,7 @@ 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 {
                     // The missing amount is based on all candidates for coin selection
@@ -388,6 +390,7 @@ impl DefineSpend {
             }
             Err(e) => {
                 self.warning = Some(e.into());
+                self.fee_amount = None;
             }
         }
     }
@@ -646,6 +649,7 @@ impl Step for DefineSpend {
             &self.batch_label,
             self.amount_left_to_select.as_ref(),
             &self.feerate,
+            self.fee_amount.as_ref(),
             self.warning.as_ref(),
         )
     }
diff --git a/liana-gui/src/app/view/spend/mod.rs b/liana-gui/src/app/view/spend/mod.rs
index eb3cdc268..e8b9973a7 100644
--- a/liana-gui/src/app/view/spend/mod.rs
+++ b/liana-gui/src/app/view/spend/mod.rs
@@ -119,6 +119,7 @@ pub fn create_spend_tx<'a>(
     batch_label: &form::Value<String>,
     amount_left: Option<&Amount>,
     feerate: &form::Value<String>,
+    fee_amount: Option<&Amount>,
     error: Option<&Error>,
 ) -> Element<'a, Message> {
     let is_self_send = recipients.is_empty();
@@ -176,28 +177,28 @@ pub fn create_spend_tx<'a>(
             )
             .push(
                 Row::new()
+                    .spacing(10)
+                    .align_y(Alignment::Center)
+                    .push(Container::new(p1_bold("Feerate:")).padding(10))
                     .push(
-                        Row::new()
-                            .push(Container::new(p1_bold("Feerate")).padding(10))
-                            .spacing(10)
-                            .push(
-                                form::Form::new_trimmed(
-                                    "42 (in sats/vbyte)",
-                                    feerate,
-                                    move |msg| {
-                                        Message::CreateSpend(CreateSpendMessage::FeerateEdited(msg))
-                                    },
-                                )
-                                .warning(
-                                    "Feerate must be an integer less than \
-                                    or equal to 1000 sats/vbyte",
-                                )
-                                .size(P1_SIZE)
-                                .padding(10),
+                        Container::new(
+                            form::Form::new_trimmed("42 (in sats/vbyte)", feerate, move |msg| {
+                                Message::CreateSpend(CreateSpendMessage::FeerateEdited(msg))
+                            })
+                            .warning(
+                                "Feerate must be an integer less than or equal to 1000 sats/vbyte",
                             )
-                            .width(Length::FillPortion(1)),
+                            .size(P1_SIZE)
+                            .padding(10),
+                        )
+                        .width(Length::Fixed(150.0)),
                     )
-                    .push(Space::with_width(Length::FillPortion(1))),
+                    .push_maybe(fee_amount.map(|fee| {
+                        Row::new()
+                            .spacing(10)
+                            .push(p1_regular("Fee:").style(theme::text::secondary))
+                            .push(amount_with_size(fee, P1_SIZE))
+                    })),
             )
             .push(
                 Container::new(


Reviews:
user: login: edouardparis
body: Indeed simpler, can you squash please into a single commit for clarity. thanks
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T09:37:17Z
user: login: jp1ac4
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-22T11:30:49Z
user: login: thomasballivet
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T12:32:35Z
user: login: thomasballivet
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T12:34:25Z
user: login: thomasballivet
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T12:47:59Z
user: login: thomasballivet
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T13:10:25Z
user: login: edouardparis
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T07:58:30Z
user: login: edouardparis
body: ACK b426d31914bf906d65307bdeb6851430d59b399d

Thanks again for the contribution !
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-23T07:58:57Z

Review comments:
diff_hunk: @@ -197,10 +199,7 @@ impl DefineSpend {
     /// redraft calculates the amount left to select and auto selects coins
     /// if the user did not select a coin manually
     fn redraft(&mut self, daemon: Arc<dyn Daemon + Sync + Send>) {
-        if !self.form_values_are_valid(true)
-            || self.exists_duplicate()
-            || self.recipients.is_empty()
-        {
+        if !self.form_values_are_valid(true) || self.exists_duplicate() {
path: liana-gui/src/app/state/spend/step.rs
user: login: edouardparis
body: I do not see why self.recipients.is_empty() should disappear
created_at: 2025-04-22T09:31:21Z
author_association: MEMBER
diff_hunk: @@ -253,7 +252,13 @@ impl DefineSpend {
             None
         };
 
-        let outpoints = if self.is_user_coin_selection {
+        let outpoints = if !self.is_user_coin_selection && self.send_max_to_recipient.is_some() {
path: liana-gui/src/app/state/spend/step.rs
user: login: edouardparis
body: I think the logic maybe broken by changing this if

```
if !A && B {1} else {2}
```
is different than
if !A {1} else if B {2} else {3}
where the case A && !B seems to give different result.
Something feels not right and it should not be changed in this PR.
created_at: 2025-04-22T09:36:33Z
author_association: MEMBER
diff_hunk: @@ -197,10 +199,7 @@ impl DefineSpend {
     /// redraft calculates the amount left to select and auto selects coins
     /// if the user did not select a coin manually
     fn redraft(&mut self, daemon: Arc<dyn Daemon + Sync + Send>) {
-        if !self.form_values_are_valid(true)
-            || self.exists_duplicate()
-            || self.recipients.is_empty()
-        {
+        if !self.form_values_are_valid(true) || self.exists_duplicate() {
path: liana-gui/src/app/state/spend/step.rs
user: login: jp1ac4
body: Yes, I think we should keep that in place as the redraft function is not currently used for self-sends and some of the subsequent logic may not be correct for a self-send.

There's a separate issue for using the same approach for a self-send as for a regular spend: https://github.com/wizardsardine/liana/issues/868.
created_at: 2025-04-22T11:30:49Z
author_association: COLLABORATOR
diff_hunk: @@ -253,7 +252,13 @@ impl DefineSpend {
             None
         };
 
-        let outpoints = if self.is_user_coin_selection {
+        let outpoints = if !self.is_user_coin_selection && self.send_max_to_recipient.is_some() {
path: liana-gui/src/app/state/spend/step.rs
user: login: thomasballivet
body: before we had 
`if self.is_user_coin_selection { A } else if self.send_max_to_recipient.is_some() { B } else { C }`
then we had
`if !self.is_user_coin_selection && self.send_max_to_recipient.is_some() { B } else { A }`
So A and B were still okay, but I removed C and just realized I lost auto-select.

This change wasn’t right. I adapted it to make it work for both self-transfer and sending to others, introducing a is_self_transfer boolean. I need it to not face a `NoOutpointForSelfSend` when `is_user_coin_selection` is false.

created_at: 2025-04-22T12:32:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -197,10 +199,7 @@ impl DefineSpend {
     /// redraft calculates the amount left to select and auto selects coins
     /// if the user did not select a coin manually
     fn redraft(&mut self, daemon: Arc<dyn Daemon + Sync + Send>) {
-        if !self.form_values_are_valid(true)
-            || self.exists_duplicate()
-            || self.recipients.is_empty()
-        {
+        if !self.form_values_are_valid(true) || self.exists_duplicate() {
path: liana-gui/src/app/state/spend/step.rs
user: login: thomasballivet
body: Because we need a redraft even for self transfer (`recipients.is_empty()`)
created_at: 2025-04-22T12:34:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -197,10 +199,7 @@ impl DefineSpend {
     /// redraft calculates the amount left to select and auto selects coins
     /// if the user did not select a coin manually
     fn redraft(&mut self, daemon: Arc<dyn Daemon + Sync + Send>) {
-        if !self.form_values_are_valid(true)
-            || self.exists_duplicate()
-            || self.recipients.is_empty()
-        {
+        if !self.form_values_are_valid(true) || self.exists_duplicate() {
path: liana-gui/src/app/state/spend/step.rs
user: login: thomasballivet
body: Didn't see @jp1ac4 answer.
Do you mean that I should ignore the self transfer case for now? 
created_at: 2025-04-22T12:47:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -197,10 +199,7 @@ impl DefineSpend {
     /// redraft calculates the amount left to select and auto selects coins
     /// if the user did not select a coin manually
     fn redraft(&mut self, daemon: Arc<dyn Daemon + Sync + Send>) {
-        if !self.form_values_are_valid(true)
-            || self.exists_duplicate()
-            || self.recipients.is_empty()
-        {
+        if !self.form_values_are_valid(true) || self.exists_duplicate() {
path: liana-gui/src/app/state/spend/step.rs
user: login: thomasballivet
body: I've tested many cases and didn’t find any issues (b426d319).
Looking at the logic I think a redraft when `recipients.is_empty()` is true is fine but let me know
created_at: 2025-04-22T13:10:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -197,10 +199,7 @@ impl DefineSpend {
     /// redraft calculates the amount left to select and auto selects coins
     /// if the user did not select a coin manually
     fn redraft(&mut self, daemon: Arc<dyn Daemon + Sync + Send>) {
-        if !self.form_values_are_valid(true)
-            || self.exists_duplicate()
-            || self.recipients.is_empty()
-        {
+        if !self.form_values_are_valid(true) || self.exists_duplicate() {
path: liana-gui/src/app/state/spend/step.rs
user: login: edouardparis
body: I did not encounter any issue with the refresh.
created_at: 2025-04-23T07:58:30Z
author_association: MEMBER

PR comments:
user: login: edouardparis
created_at: 2025-04-21T09:01:49Z
body: Thanks for the PR, the displaying of Fees is great.

In order to have the fee amount calculated, a new task is not required. 
You can get inspiration from the field `amount_left_to_select` that is updated in the method `redraft`.
The main difference with this field and the `fees_amount` field you are introducing, is that the `fees_amount` should be updated to `Some(the calcul)` when the `create_spend_tx` is successful, and `None` when it fails (like for example not enough coins are selected).
When `fees_amount` is None, the view should display anything.
author_association: MEMBER
user: login: thomasballivet
created_at: 2025-04-22T02:28:59Z
body: I had not succeeded at first but yes I also wanted to reuse the redraft task. Based on your comment and by using a simpler way to get the fees (from Psbt's fee method instead of a new SpendTx) I've been able to do it and it's much simpler now.
author_association: CONTRIBUTOR
user: login: jp1ac4
created_at: 2025-04-22T11:34:35Z
body: I haven't reviewed the code changes yet, but just a quick comment that I would use "Fee" instead of "Fees" for the label.
author_association: COLLABORATOR