Skip to content

Commit 46cd0f4

Browse files
committed
gui: add max checkbox for spend recipients
1 parent 83fa9a9 commit 46cd0f4

File tree

3 files changed

+215
-40
lines changed

3 files changed

+215
-40
lines changed

gui/src/app/state/spend/step.rs

+163-30
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
use std::collections::HashMap;
2-
use std::str::FromStr;
3-
use std::sync::Arc;
1+
use std::{cmp::Ordering, collections::HashMap, str::FromStr, sync::Arc};
42

53
use iced::{Command, Subscription};
64
use liana::{
@@ -65,6 +63,9 @@ pub trait Step {
6563
pub struct DefineSpend {
6664
balance_available: Amount,
6765
recipients: Vec<Recipient>,
66+
/// If set, this is the index of a recipient that should
67+
/// receive the max amount.
68+
send_max_to_recipient: Option<usize>,
6869
/// Will be `true` if coins for spend were manually selected by user.
6970
/// Otherwise, will be `false` (including for self-send).
7071
is_user_coin_selection: bool,
@@ -123,6 +124,7 @@ impl DefineSpend {
123124
coins_labels: HashMap::new(),
124125
batch_label: form::Value::default(),
125126
recipients: vec![Recipient::default()],
127+
send_max_to_recipient: None,
126128
is_user_coin_selection: false, // Start with auto-selection until user edits selection.
127129
is_valid: false,
128130
is_duplicate: false,
@@ -162,12 +164,16 @@ impl DefineSpend {
162164
self
163165
}
164166

165-
fn form_values_are_valid(&self) -> bool {
167+
// If `is_redraft`, the validation of recipients will take into account
168+
// whether any should receive the max amount. Otherwise, all recipients
169+
// will be fully validated.
170+
fn form_values_are_valid(&self, is_redraft: bool) -> bool {
166171
self.feerate.valid
167172
&& !self.feerate.value.is_empty()
168173
&& (self.batch_label.valid || self.recipients.len() < 2)
169174
// Recipients will be empty for self-send.
170-
&& self.recipients.iter().all(|r| r.valid())
175+
&& self.recipients.iter().enumerate().all(|(i, r)|
176+
r.valid() || (is_redraft && self.send_max_to_recipient == Some(i) && r.address_valid()))
171177
}
172178

173179
fn exists_duplicate(&self) -> bool {
@@ -185,34 +191,68 @@ impl DefineSpend {
185191

186192
fn check_valid(&mut self) {
187193
self.is_valid =
188-
self.form_values_are_valid() && self.coins.iter().any(|(_, selected)| *selected);
194+
self.form_values_are_valid(false) && self.coins.iter().any(|(_, selected)| *selected);
189195
self.is_duplicate = self.exists_duplicate();
190196
}
191197
/// redraft calculates the amount left to select and auto selects coins
192198
/// if the user did not select a coin manually
193199
fn redraft(&mut self, daemon: Arc<dyn Daemon + Sync + Send>) {
194-
if !self.form_values_are_valid() || self.exists_duplicate() || self.recipients.is_empty() {
200+
if !self.form_values_are_valid(true)
201+
|| self.exists_duplicate()
202+
|| self.recipients.is_empty()
203+
{
195204
// The current form details are not valid to draft a spend, so remove any previously
196205
// calculated amount as it will no longer be valid and could be misleading, e.g. if
197206
// the user removes the amount from one of the recipients.
198207
// We can leave any coins selected as they will either be automatically updated
199208
// as soon as the form is valid or the user has selected these specific coins and
200209
// so we should not touch them.
201210
self.amount_left_to_select = None;
211+
// Remove any max amount from a recipient as it could be misleading.
212+
if let Some(i) = self.send_max_to_recipient {
213+
self.recipients
214+
.get_mut(i)
215+
.expect("max has been requested for this recipient so it must exist")
216+
.update(
217+
self.network,
218+
view::CreateSpendMessage::RecipientEdited(i, "amount", "".to_string()),
219+
);
220+
}
202221
return;
203222
}
204223

205224
let destinations: HashMap<Address<address::NetworkUnchecked>, u64> = self
206225
.recipients
207226
.iter()
208-
.map(|recipient| {
209-
(
210-
Address::from_str(&recipient.address.value).expect("Checked before"),
211-
recipient.amount().expect("Checked before"),
212-
)
227+
.enumerate()
228+
.filter_map(|(i, recipient)| {
229+
// A recipient that receives the max should be treated as change for coin selection.
230+
// Note that we only give a change output if its value is above the dust
231+
// threshold, but a user can only send payments above the same dust threshold,
232+
// so using change output to determine the max amount for a recipient will
233+
// not prevent a value that could otherwise be entered manually by the user.
234+
if self.send_max_to_recipient == Some(i) {
235+
None
236+
} else {
237+
Some((
238+
Address::from_str(&recipient.address.value).expect("Checked before"),
239+
recipient.amount().expect("Checked before"),
240+
))
241+
}
213242
})
214243
.collect();
215244

245+
let recipient_with_max = if let Some(i) = self.send_max_to_recipient {
246+
Some((
247+
i,
248+
self.recipients
249+
.get_mut(i)
250+
.expect("max has been requested for this recipient so it must exist"),
251+
))
252+
} else {
253+
None
254+
};
255+
216256
let outpoints = if self.is_user_coin_selection {
217257
let outpoints: Vec<_> = self
218258
.coins
@@ -228,30 +268,53 @@ impl DefineSpend {
228268
)
229269
.collect();
230270
if outpoints.is_empty() {
231-
// If the user has deselected all coins, simply set the amount left to select as the
232-
// total destination value. Note this doesn't take account of the fee, but passing
233-
// an empty list to `create_spend_tx` would use auto-selection and so we settle for
234-
// this approximation.
271+
// If the user has deselected all coins, set any recipient's max amount to 0.
272+
if let Some((i, recipient)) = recipient_with_max {
273+
recipient.update(
274+
self.network,
275+
view::CreateSpendMessage::RecipientEdited(i, "amount", "0".to_string()),
276+
);
277+
}
278+
// Simply set the amount left to select as the total destination value. Note this
279+
// doesn't take account of the fee, but passing an empty list to `create_spend_tx`
280+
// would use auto-selection and so we settle for this approximation.
235281
self.amount_left_to_select = Some(Amount::from_sat(destinations.values().sum()));
236282
return;
237283
}
238284
outpoints
285+
} else if self.send_max_to_recipient.is_some() {
286+
// If user has not selected coins, send the max available from all coins.
287+
self.coins.iter().map(|(c, _)| c.outpoint).collect()
239288
} else {
240289
Vec::new() // pass empty list for auto-selection
241290
};
242291

243-
// Use a fixed change address so that we don't increment the change index.
244-
let dummy_address = self
245-
.descriptor
246-
.change_descriptor()
247-
.derive(0.into(), &self.curve)
248-
.address(self.network)
249-
.as_unchecked()
250-
.clone();
292+
// If sending the max to a recipient, use that recipient's address as the
293+
// change address.
294+
// Otherwise, use a fixed change address from the user's own wallet so that
295+
// we don't increment the change index.
296+
let change_address = if let Some((_, recipient)) = &recipient_with_max {
297+
Address::from_str(&recipient.address.value)
298+
.expect("Checked before")
299+
.as_unchecked()
300+
.clone()
301+
} else {
302+
self.descriptor
303+
.change_descriptor()
304+
.derive(0.into(), &self.curve)
305+
.address(self.network)
306+
.as_unchecked()
307+
.clone()
308+
};
251309

252310
let feerate_vb = self.feerate.value.parse::<u64>().expect("Checked before");
253311

254-
match daemon.create_spend_tx(&outpoints, &destinations, feerate_vb, Some(dummy_address)) {
312+
match daemon.create_spend_tx(
313+
&outpoints,
314+
&destinations,
315+
feerate_vb,
316+
Some(change_address.clone()),
317+
) {
255318
Ok(CreateSpendResult::Success { psbt, .. }) => {
256319
self.warning = None;
257320
if !self.is_user_coin_selection {
@@ -268,6 +331,25 @@ impl DefineSpend {
268331
}
269332
// As coin selection was successful, we can assume there is nothing left to select.
270333
self.amount_left_to_select = Some(Amount::from_sat(0));
334+
if let Some((i, recipient)) = recipient_with_max {
335+
// If there's no change output, any excess must be below the dust threshold
336+
// and so the max available for this recipient is 0.
337+
let amount = psbt
338+
.unsigned_tx
339+
.output
340+
.iter()
341+
.find(|o| {
342+
o.script_pubkey
343+
== change_address.clone().assume_checked().script_pubkey()
344+
})
345+
.map(|change_output| change_output.value.to_btc())
346+
.unwrap_or(0.0)
347+
.to_string();
348+
recipient.update(
349+
self.network,
350+
view::CreateSpendMessage::RecipientEdited(i, "amount", amount),
351+
);
352+
}
271353
}
272354
// For coin selection error (insufficient funds), do not make any changes to
273355
// selected coins on screen and just show user how much is left to select.
@@ -276,6 +358,25 @@ impl DefineSpend {
276358
// - select coins manually.
277359
Ok(CreateSpendResult::InsufficientFunds { missing }) => {
278360
self.amount_left_to_select = Some(Amount::from_sat(missing));
361+
if let Some((i, recipient)) = recipient_with_max {
362+
let amount = Amount::from_sat(if destinations.is_empty() {
363+
// If there are no other recipients, then the missing value will
364+
// be the amount left to select in order to create an output at the dust
365+
// threshold. Therefore, set this recipient's amount to this value so
366+
// that the information shown is consistent.
367+
// Otherwise, there are already insufficient funds for the other
368+
// recipients and so the max available for this recipient is 0.
369+
DUST_OUTPUT_SATS
370+
} else {
371+
0
372+
})
373+
.to_btc()
374+
.to_string();
375+
recipient.update(
376+
self.network,
377+
view::CreateSpendMessage::RecipientEdited(i, "amount", amount),
378+
);
379+
}
279380
}
280381
Err(e) => {
281382
self.warning = Some(e.into());
@@ -307,6 +408,20 @@ impl Step for DefineSpend {
307408
self.batch_label.valid = true;
308409
self.batch_label.value = "".to_string();
309410
}
411+
if let Some(j) = self.send_max_to_recipient {
412+
match j.cmp(&i) {
413+
Ordering::Equal => {
414+
self.send_max_to_recipient = None;
415+
}
416+
Ordering::Greater => {
417+
self.send_max_to_recipient = Some(
418+
j.checked_sub(1)
419+
.expect("j must be greater than 0 in this case"),
420+
);
421+
}
422+
_ => {}
423+
}
424+
}
310425
}
311426
view::CreateSpendMessage::RecipientEdited(i, _, _) => {
312427
self.recipients
@@ -377,6 +492,17 @@ impl Step for DefineSpend {
377492
self.is_user_coin_selection = true;
378493
}
379494
}
495+
view::CreateSpendMessage::SendMaxToRecipient(i) => {
496+
if self.recipients.get(i).is_some() {
497+
if self.send_max_to_recipient == Some(i) {
498+
// If already set to this recipient, then unset it.
499+
self.send_max_to_recipient = None;
500+
} else {
501+
// Either it's set to some other recipient or not at all.
502+
self.send_max_to_recipient = Some(i);
503+
};
504+
}
505+
}
380506
_ => {}
381507
}
382508

@@ -453,7 +579,11 @@ impl Step for DefineSpend {
453579
self.recipients
454580
.iter()
455581
.enumerate()
456-
.map(|(i, recipient)| recipient.view(i).map(view::Message::CreateSpend))
582+
.map(|(i, recipient)| {
583+
recipient
584+
.view(i, self.send_max_to_recipient == Some(i))
585+
.map(view::Message::CreateSpend)
586+
})
457587
.collect(),
458588
Amount::from_sat(
459589
self.recipients
@@ -509,9 +639,12 @@ impl Recipient {
509639
Ok(amount.to_sat())
510640
}
511641

642+
fn address_valid(&self) -> bool {
643+
!self.address.value.is_empty() && self.address.valid
644+
}
645+
512646
fn valid(&self) -> bool {
513-
!self.address.value.is_empty()
514-
&& self.address.valid
647+
self.address_valid()
515648
&& !self.amount.value.is_empty()
516649
&& self.amount.valid
517650
&& self.label.valid
@@ -550,8 +683,8 @@ impl Recipient {
550683
};
551684
}
552685

553-
fn view(&self, i: usize) -> Element<view::CreateSpendMessage> {
554-
view::spend::recipient_view(i, &self.address, &self.amount, &self.label)
686+
fn view(&self, i: usize, is_max_selected: bool) -> Element<view::CreateSpendMessage> {
687+
view::spend::recipient_view(i, &self.address, &self.amount, &self.label, is_max_selected)
555688
}
556689
}
557690

gui/src/app/view/message.rs

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ pub enum CreateSpendMessage {
3737
FeerateEdited(String),
3838
SelectPath(usize),
3939
Generate,
40+
SendMaxToRecipient(usize),
4041
}
4142

4243
#[derive(Debug, Clone)]

0 commit comments

Comments
 (0)