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:
@@ -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(),
|
||||
));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user