1
0
forked from GRIN/grim

Build 80: show the real balance and explain failed sends

The balance hero only showed amount_currently_spendable, so a wallet whose
funds were still confirming read 0 while GRIM showed the real total — the source
of the "Goblin says 0" confusion. It now shows the TOTAL (matching GRIM) with an
"X available - Y confirming" breakdown when some isn't spendable yet.

A send or approve that hits NotEnoughFunds (coins from a recent payment still
confirming, ~10 min) now says exactly that instead of a blank "Couldn't send",
and the Approve button no longer stays greyed forever — it un-greys on failure
with the reason shown, so the user can retry once funds clear. The relay-dial
cap for cross-relay delivery drops 12s to 6s so the first send isn't sluggish.
This commit is contained in:
2ro
2026-06-15 00:21:10 -04:00
parent af30af48e4
commit f715149302
5 changed files with 102 additions and 13 deletions
+28 -4
View File
@@ -59,6 +59,9 @@ pub struct GoblinWalletView {
profile: Option<String>,
/// Request ids already approved this session (double-tap guard).
approving: std::collections::HashSet<String>,
/// Why the last approve failed (e.g. funds confirming), shown above the
/// request list; cleared when a new approve is attempted.
request_error: Option<String>,
/// Identifier of the wallet this view is bound to (reset on change).
wallet_id: Option<String>,
/// Inline username-claim state for the Me tab.
@@ -136,6 +139,7 @@ impl Default for GoblinWalletView {
receipt: None,
profile: None,
approving: std::collections::HashSet::new(),
request_error: None,
wallet_id: None,
claim: None,
rotate: None,
@@ -311,6 +315,7 @@ impl GoblinWalletView {
self.rotate = None;
self.import_nsec = None;
self.approving.clear();
self.request_error = None;
self.pay_amount.clear();
self.request_amount = None;
self.settings_page = SettingsPage::Main;
@@ -867,11 +872,11 @@ impl GoblinWalletView {
} else {
ui.add_space(48.0);
}
let spendable = data
let (total, spendable) = data
.as_ref()
.map(|d| d.info.amount_currently_spendable)
.unwrap_or(0);
w::balance_hero(ui, spendable, fiat_line(&data).as_deref(), 56.0);
.map(|d| (d.info.total, d.info.amount_currently_spendable))
.unwrap_or((0, 0));
w::balance_hero(ui, total, spendable, fiat_line(&data).as_deref(), 56.0);
ui.add_space(20.0);
let (send, receive) = w::send_receive(ui);
if send {
@@ -1568,9 +1573,27 @@ impl GoblinWalletView {
// Pending payment requests pinned on top.
if let Some(service) = wallet.nostr_service() {
// An approve that failed (e.g. funds still confirming) flips the send
// phase to FAILED — un-grey the buttons so the user can retry, and
// surface why instead of leaving the card stuck.
if service.send_phase() == crate::nostr::send_phase::FAILED
&& !self.approving.is_empty()
{
self.approving.clear();
self.request_error = service.last_send_error();
}
let requests = service.store.pending_requests();
if !requests.is_empty() {
w::section_header(ui, &t!("goblin.activity.requests"));
if let Some(err) = &self.request_error {
ui.add_space(4.0);
ui.label(
RichText::new(err)
.font(FontId::new(13.0, fonts::regular()))
.color(theme::tokens().neg),
);
ui.add_space(4.0);
}
for req in requests {
self.request_row_ui(ui, &req, wallet);
}
@@ -1735,6 +1758,7 @@ impl GoblinWalletView {
// Guard against double-tap: only enqueue the
// payment once per request id this session.
self.approving.insert(req.rumor_id.clone());
self.request_error = None;
wallet.task(crate::wallet::types::WalletTask::NostrPayRequest(
req.rumor_id.clone(),
));
+7 -1
View File
@@ -1243,7 +1243,13 @@ impl SendFlow {
self.error = Some(t!("goblin.send.request_blocked", "who" => who).to_string());
self.stage = Stage::Failed;
}
crate::nostr::send_phase::FAILED => self.stage = Stage::Failed,
crate::nostr::send_phase::FAILED => {
// Surface the real reason (e.g. funds still confirming).
if self.error.is_none() {
self.error = wallet.nostr_service().and_then(|s| s.last_send_error());
}
self.stage = Stage::Failed;
}
_ => {}
}
ui.ctx().request_repaint();
+23 -3
View File
@@ -499,12 +499,32 @@ pub fn field_well(ui: &mut Ui, content: impl FnOnce(&mut Ui)) {
}
/// A balance hero block: kicker, big number + ツ, optional fiat line.
pub fn balance_hero(ui: &mut Ui, atomic: u64, fiat: Option<&str>, size: f32) {
pub fn balance_hero(ui: &mut Ui, total: u64, spendable: u64, fiat: Option<&str>, size: f32) {
let t = theme::tokens();
// Centered to match the Pay amount and the empty-state below it.
// Headline is the TOTAL the wallet holds — same number GRIM shows — so a
// wallet mid-confirmation doesn't look empty.
ui.vertical_centered(|ui| kicker(ui, "Balance"));
ui.add_space(6.0);
amount_text_centered(ui, &amount_str(atomic), size);
amount_text_centered(ui, &amount_str(total), size);
// When some of it can't be spent yet (a payment still confirming, ~10 blocks),
// say how much is available vs confirming so a failed send explains itself.
if total > spendable {
let confirming = total - spendable;
ui.add_space(4.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(format!(
"{}{} available · {}{} confirming",
amount_str(spendable),
TSU,
amount_str(confirming),
TSU
))
.font(FontId::new(12.5, fonts::medium()))
.color(t.text_dim),
);
});
}
if let Some(fiat) = fiat {
ui.add_space(4.0);
ui.vertical_centered(|ui| {
+26 -4
View File
@@ -85,6 +85,10 @@ pub struct NostrService {
rate: Mutex<HashMap<String, Vec<i64>>>,
/// Current outgoing-send phase for the UI (see [`SendPhase`]).
send_phase: std::sync::atomic::AtomicU8,
/// Human-readable reason the last send/request/approve failed, surfaced on
/// the failure screen so the user (and we) can see WHY, not just "couldn't
/// send". Cleared when a new attempt starts.
last_send_error: RwLock<Option<String>>,
}
/// Phase of the most recent outgoing send, polled by the send UI.
@@ -120,6 +124,7 @@ impl NostrService {
has_new_requests: AtomicBool::new(false),
rate: Mutex::new(HashMap::new()),
send_phase: std::sync::atomic::AtomicU8::new(send_phase::IDLE),
last_send_error: RwLock::new(None),
})
}
@@ -227,11 +232,27 @@ impl NostrService {
self.send_phase.load(Ordering::Relaxed)
}
/// Set the outgoing-send phase (called by the send task + UI).
/// Set the outgoing-send phase (called by the send task + UI). Starting a new
/// attempt (WORKING) clears any prior failure reason.
pub fn set_send_phase(&self, phase: u8) {
if phase == send_phase::WORKING {
*self.last_send_error.write() = None;
}
self.send_phase.store(phase, Ordering::Relaxed);
}
/// Record why the current send/request/approve failed (shown on the failure
/// screen) and flip the phase to FAILED.
pub fn fail_send(&self, reason: impl Into<String>) {
*self.last_send_error.write() = Some(reason.into());
self.send_phase.store(send_phase::FAILED, Ordering::Relaxed);
}
/// The reason the last send failed, if any.
pub fn last_send_error(&self) -> Option<String> {
self.last_send_error.read().clone()
}
/// Whether at least one relay is connected.
pub fn is_connected(&self) -> bool {
self.connected.load(Ordering::Relaxed)
@@ -787,9 +808,10 @@ async fn connect_relays(client: &Client, urls: &[String]) {
let url = url.clone();
async move {
let _ = client.add_relay(&url).await;
let _ = client
.try_connect_relay(&url, Duration::from_secs(12))
.await;
// Short cap: a reachable relay connects in ~2-4s over the mixnet; we
// don't want one dead relay in the list to stall the whole send. Once
// connected it stays connected, so only the first send pays this.
let _ = client.try_connect_relay(&url, Duration::from_secs(6)).await;
}
});
futures::future::join_all(dials).await;
+18 -1
View File
@@ -2010,6 +2010,18 @@ fn start_sync(wallet: Wallet) -> Thread {
.clone()
}
/// Map a wallet error to a short, user-facing reason for the failure screen so
/// "Couldn't send" actually explains itself — most often locked/unconfirmed
/// funds after a recent payment.
fn friendly_send_error(e: &Error) -> String {
let s = format!("{e:?}");
if s.contains("NotEnoughFunds") {
"Not enough spendable grin — coins from a recent payment may still be confirming (about 10 min). Try again once it clears.".to_string()
} else {
format!("Couldn't complete the payment: {e}")
}
}
/// Handle wallet task.
async fn handle_task(w: &Wallet, t: WalletTask) {
match &t {
@@ -2281,7 +2293,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
Err(e) => {
error!("nostr send error: {:?}", e);
w.send_creating.store(false, Ordering::Relaxed);
service.set_send_phase(crate::nostr::send_phase::FAILED);
service.fail_send(friendly_send_error(&e));
}
}
}
@@ -2463,6 +2475,9 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
error!("nostr pay: request is not pending");
return;
}
// Drive the approve button's busy/failed state so it doesn't stay
// greyed forever if the pay can't go through.
service.set_send_phase(crate::nostr::send_phase::WORKING);
// Re-parse and re-validate the stored slatepack: it must still be
// an Invoice1 (or a Standard1 surfaced under a strict policy).
match w.parse_slatepack(&request.slatepack) {
@@ -2499,10 +2514,12 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
}
request.status = crate::nostr::RequestStatus::Approved;
service.store.save_request(&request);
service.set_send_phase(crate::nostr::send_phase::SENT);
sync_wallet_data(&w, false);
}
Err(e) => {
error!("nostr pay failed: {:?}", e);
service.fail_send(friendly_send_error(&e));
}
},
Ok((s, _)) if s.state == SlateState::Standard1 => {