1
0
forked from GRIN/grim

goblin: reserve the network fee in the send flow (GRIM parity)

The live Goblin send UI guarded only on amount > spendable, so a send of
the whole balance was offered as valid with no room for the network fee.
The wallet then built a slate needing amount + fee but holding only amount,
so the send failed with NotEnoughFunds or sat unconfirmed.

GRIM's send modal reserves the fee: it refuses amount + fee > max and caps
its max at spendable - fee. Mirror that here. Both the amount screen and the
review page now price the exact amount (one CalculateFee per amount, as GRIM
does) and refuse the send unless amount + fee fits the spendable balance;
hold-to-send stays disabled until the fee is priced, so a full-balance
payment is never dispatched with no room for its fee.

Adds amount_fits / max_sendable helpers with unit tests covering the
send-max boundary.
This commit is contained in:
2ro
2026-07-06 14:37:32 -04:00
parent bc27cb312c
commit 55d8f038dc
+128 -19
View File
@@ -35,6 +35,22 @@ use super::avatars::AvatarTextures;
use super::data::{self, display_name, recent_peers, search_contacts, short_npub};
use super::widgets::{self as w, HoldToSend};
/// GRIM parity: the network fee is reserved out of the spendable balance before
/// a send. The largest amount that can be sent while leaving room for `fee` is
/// `spendable - fee` (GRIM's send modal caps its "max" at exactly this value).
fn max_sendable(spendable: u64, fee: u64) -> u64 {
spendable.saturating_sub(fee)
}
/// GRIM parity: a send may only be dispatched when the amount PLUS its network
/// fee fits inside the spendable balance. GRIM refuses to build a slate when
/// `amount + fee > max`; mirroring that here stops the wallet from creating a
/// full-balance payment that can never cover its own fee (the slate build fails
/// with NotEnoughFunds, or the tx sits unconfirmed). A zero amount never fits.
fn amount_fits(amount: u64, fee: u64, spendable: u64) -> bool {
amount != 0 && amount <= max_sendable(spendable, fee)
}
/// Avatar texture for a display handle, if one is cached. Handles no longer
/// carry an '@'; bare-npub / empty names have no avatar on the server.
fn tex_for(
@@ -1023,13 +1039,31 @@ impl SendFlow {
// Block sends over the spendable balance: red amount + a message + an
// error buzz on tap, rather than letting it fail later at the node.
// GRIM parity: reserve the network fee, so the "whole balance" is never
// offered as sendable. Price this exact amount (one async CalculateFee
// per amount, like GRIM's send modal) and, once priced, refuse it unless
// amount + fee fits. Until the fee lands, fall back to the balance-only
// check so ordinary sub-balance sends flow while pricing catches up.
let spendable = wallet
.get_data()
.map(|d| d.info.amount_currently_spendable)
.unwrap_or(0);
let over = amount_from_hr_string(&self.amount)
.map(|a| a > spendable)
.unwrap_or(false);
let amount_nano = amount_from_hr_string(&self.amount).unwrap_or(0);
if amount_nano > 0 && self.fee_requested_for != Some(amount_nano) {
self.fee_requested_for = Some(amount_nano);
wallet.task(WalletTask::CalculateFee(amount_nano, 0));
}
let over = match wallet.calculated_fee(amount_nano) {
Some(fee) => amount_nano != 0 && !amount_fits(amount_nano, fee, spendable),
None => {
if amount_nano > 0 {
// Fee lands on a worker thread; poll so the guard updates.
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(120));
}
amount_nano > spendable
}
};
// Recipient chip, centered per the design.
let name_label = t!("goblin.send.to_name", "name" => recipient.name).to_string();
@@ -1195,11 +1229,25 @@ impl SendFlow {
.get_data()
.map(|d| d.info.amount_currently_spendable)
.unwrap_or(0);
// GRIM parity: price this exact amount (one async CalculateFee per amount,
// like GRIM's send modal) and reserve the fee before offering the send.
// Requests never spend our balance, so the guard does not apply to them.
let amount_nano = amount_from_hr_string(&amount).unwrap_or(0);
if !self.request && amount_nano > 0 && self.fee_requested_for != Some(amount_nano) {
self.fee_requested_for = Some(amount_nano);
wallet.task(WalletTask::CalculateFee(amount_nano, 0));
}
let fee_opt = wallet.calculated_fee(amount_nano);
// Not yet priced: hold-to-send stays disabled rather than dispatching a
// full-balance payment with no room for the fee.
let pricing = !self.request && amount_nano > 0 && fee_opt.is_none();
// Over-balance (fee-aware): the send cannot cover its own fee. GRIM
// refuses `amount + fee > max`; mirror that exactly.
let over = !self.request
&& amount_from_hr_string(&amount)
.map(|a| a > spendable)
.unwrap_or(false);
&& match fee_opt {
Some(fee) => !amount_fits(amount_nano, fee, spendable),
None => false,
};
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
@@ -1285,15 +1333,10 @@ impl SendFlow {
&t!("goblin.send.row_delivery_val"),
);
} else {
// Live network fee for this exact amount, priced by the wallet (one
// async CalculateFee per amount, like GRIM's send modal). Until the
// first result lands we show an ellipsis rather than a wrong number.
let amount_nano = amount_from_hr_string(&amount).unwrap_or(0);
if amount_nano > 0 && self.fee_requested_for != Some(amount_nano) {
self.fee_requested_for = Some(amount_nano);
wallet.task(WalletTask::CalculateFee(amount_nano, 0));
}
let fee_val = match wallet.calculated_fee(amount_nano) {
// Live network fee for this exact amount, priced above (one async
// CalculateFee per amount, like GRIM's send modal). Until the first
// result lands we show an ellipsis rather than a wrong number.
let fee_val = match fee_opt {
Some(fee) => format!("{}{}", w::amount_str(fee), w::TSU),
None => {
// Result lands on a worker thread; poll until it does.
@@ -1343,10 +1386,12 @@ impl SendFlow {
});
ui.add_space(8.0);
}
// Greyed out while over balance; the `&& !over` also refuses the send in
// case the hold widget ignores the disabled state.
ui.add_enabled_ui(!over, |ui| {
if self.hold.ui(ui, &t!("goblin.send.hold_to_send")) && !over {
// Greyed out while over balance or still pricing the fee; the `&& !over
// && !pricing` also refuses the send in case the hold widget ignores the
// disabled state, so a full-balance payment is never dispatched with no
// room for its fee (GRIM parity).
ui.add_enabled_ui(!over && !pricing, |ui| {
if self.hold.ui(ui, &t!("goblin.send.hold_to_send")) && !over && !pricing {
self.dispatch(wallet);
self.stage = Stage::Sending;
}
@@ -1825,4 +1870,68 @@ mod tests {
assert!(decode_recipient_key("alice@goblin.st").is_none());
assert!(decode_recipient_key("@alice").is_none());
}
// ---- Money path: send-max fee reservation (GRIM parity) ----
// 2 GRIN and a typical whole-balance fee (~0.015 GRIN), in nanogrin.
const SPENDABLE: u64 = 2_000_000_000;
const FEE: u64 = 15_000_000;
#[test]
fn amount_fits_reserves_the_fee() {
// GRIM refuses `amount + fee > max`: the whole balance never fits, and
// neither does anything above `spendable - fee`.
assert!(
!amount_fits(SPENDABLE, FEE, SPENDABLE),
"the full balance must not fit: no room for the fee"
);
assert!(
!amount_fits(SPENDABLE - FEE + 1, FEE, SPENDABLE),
"one nanogrin over the cap must not fit"
);
assert!(
amount_fits(SPENDABLE - FEE, FEE, SPENDABLE),
"exactly spendable - fee is the largest that fits"
);
assert!(
amount_fits(SPENDABLE - FEE - 1, FEE, SPENDABLE),
"comfortably under the cap fits"
);
// A zero amount never fits; a zero fee still requires amount <= spendable.
assert!(!amount_fits(0, FEE, SPENDABLE));
assert!(amount_fits(SPENDABLE, 0, SPENDABLE));
assert!(!amount_fits(SPENDABLE + 1, 0, SPENDABLE));
}
#[test]
fn max_sendable_leaves_room_for_fee() {
assert_eq!(max_sendable(SPENDABLE, FEE), SPENDABLE - FEE);
// Saturates to zero instead of underflowing when the fee exceeds balance.
assert_eq!(max_sendable(10_000, FEE), 0);
assert_eq!(max_sendable(0, 0), 0);
// The value max_sendable yields is exactly the largest amount that fits.
assert!(amount_fits(max_sendable(SPENDABLE, FEE), FEE, SPENDABLE));
assert!(!amount_fits(
max_sendable(SPENDABLE, FEE) + 1,
FEE,
SPENDABLE
));
}
#[test]
fn full_balance_send_is_refused_unlike_the_old_balance_only_guard() {
// The pre-fix guard was `amount > spendable`, which let a send of the
// whole balance through (it is not strictly greater), so the wallet built
// a slate with no room for the fee. The fee-aware guard refuses it.
let amount = SPENDABLE; // "send max" — the whole balance
let old_balance_only_guard_allows = !(amount > SPENDABLE);
assert!(
old_balance_only_guard_allows,
"the old balance-only guard let the full balance through"
);
assert!(
!amount_fits(amount, FEE, SPENDABLE),
"the fee-aware guard refuses the full-balance send"
);
}
}