diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 769d671..12e3bf8 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -59,6 +59,9 @@ pub struct GoblinWalletView { profile: Option, /// Request ids already approved this session (double-tap guard). approving: std::collections::HashSet, + /// Why the last approve failed (e.g. funds confirming), shown above the + /// request list; cleared when a new approve is attempted. + request_error: Option, /// Identifier of the wallet this view is bound to (reset on change). wallet_id: Option, /// 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(), )); diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index 24e7311..d60fb28 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -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(); diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs index f560382..daceaf4 100644 --- a/src/gui/views/goblin/widgets.rs +++ b/src/gui/views/goblin/widgets.rs @@ -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| { diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 7947abe..3428d6b 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -85,6 +85,10 @@ pub struct NostrService { rate: Mutex>>, /// 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>, } /// 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) { + *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 { + 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; diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index 9899d0d..30ce823 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -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 => {