From 062cf2f2abea1b26c6a0ef100fb096bcf7c90f9b Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:12:15 -0400 Subject: [PATCH] goblin: always offer a manual Cancel on stuck pendings + soft stale nudge Some pending transactions showed no way to cancel or clear them, stranding users with stuck pendings (fee-bug leftovers, and payments orphaned after an identity switch whose nostr meta lives in another identity's store). Root causes: - Goblin receipt: both cancel affordances required the payment's nostr tx_meta, which is per-identity. A tx orphaned by an identity switch (or left by an old build) has no meta in the active store, so neither Cancel button rendered. - GRIM tx list + tx detail: the Cancel button was gated behind wallet.synced_from_node(), so an unreachable/unsynced node hid it entirely. Fixes (cancellation is unchanged: the plain libwallet cancel_tx that unlocks the reserved inputs, GRIM parity, no new money behavior): - Receipt: universal fallback Cancel for any still-cancellable pending the nostr-aware paths miss, running WalletTask::Cancel. ReceiptDetail carries can_cancel/stale. - GRIM list/detail: ungate the local Cancel from node sync (only repost/repeat still needs a live height). - Soft "stale" nudge on pendings older than 24h (WalletTx::stale/STALE_AGE_SECS) with the Cancel right there. It only colours a label; nothing auto-cancels on a timer (a slow interactive send may still complete). - New tx_stale / receipt.stale_note strings across all six locales. - Unit tests for can_cancel state coverage and the stale threshold. --- locales/de.yml | 2 + locales/en.yml | 2 + locales/fr.yml | 2 + locales/ru.yml | 2 + locales/tr.yml | 2 + locales/zh-CN.yml | 2 + src/gui/views/goblin/data.rs | 11 +++ src/gui/views/goblin/mod.rs | 42 +++++++++-- src/gui/views/wallets/wallet/txs/content.rs | 28 ++++--- src/gui/views/wallets/wallet/txs/tx.rs | 14 +++- src/wallet/types.rs | 81 +++++++++++++++++++++ 11 files changed, 168 insertions(+), 20 deletions(-) diff --git a/locales/de.yml b/locales/de.yml index d2b72fc9..7f43d77e 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -95,6 +95,7 @@ wallets: tx_finalizing: Finalisierung tx_posting: Buchungsvorgang tx_confirmed: Bestätigt + tx_stale: Zu lange ausstehend txs: Transaktionen tx: Transaktion messages: Nachrichten @@ -425,6 +426,7 @@ goblin: transaction: "Transaktion" cancel_request: "Anfrage abbrechen" cancel_send: "Zahlung abbrechen" + stale_note: "Diese Zahlung ist seit einer Weile ausstehend." cancel_send_confirm: "Zum Abbrechen erneut tippen — sie könnten sie noch erhalten" cancel_send_done: "Zahlung abgebrochen — dein Guthaben ist wieder verfügbar" cancel_send_too_late: "Diese Zahlung ist bereits durchgegangen und kann nicht abgebrochen werden" diff --git a/locales/en.yml b/locales/en.yml index 16f45086..aa6f449e 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -95,6 +95,7 @@ wallets: tx_finalizing: Finalizing tx_posting: Posting tx_confirmed: Confirmed + tx_stale: Pending too long txs: Transactions tx: Transaction messages: Messages @@ -425,6 +426,7 @@ goblin: transaction: "Transaction" cancel_request: "Cancel request" cancel_send: "Cancel payment" + stale_note: "This payment has been pending a while." cancel_send_confirm: "Tap again to cancel — they may still receive it" cancel_send_done: "Payment cancelled — your funds are available again" cancel_send_too_late: "This payment already went through and can't be cancelled" diff --git a/locales/fr.yml b/locales/fr.yml index 46dbfbe3..88bac7c0 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -95,6 +95,7 @@ wallets: tx_finalizing: Finalisation tx_posting: Publication tx_confirmed: Confirmé + tx_stale: En attente trop longtemps txs: Transactions tx: Transaction messages: Messages @@ -425,6 +426,7 @@ goblin: transaction: "Transaction" cancel_request: "Annuler la demande" cancel_send: "Annuler le paiement" + stale_note: "Ce paiement est en attente depuis un moment." cancel_send_confirm: "Appuyez à nouveau pour annuler — il peut encore le recevoir" cancel_send_done: "Paiement annulé — vos fonds sont à nouveau disponibles" cancel_send_too_late: "Ce paiement est déjà passé et ne peut pas être annulé" diff --git a/locales/ru.yml b/locales/ru.yml index 994f5186..0ce6979f 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -95,6 +95,7 @@ wallets: tx_finalizing: Завершение tx_posting: Публикация tx_confirmed: Подтверждено + tx_stale: Слишком долго в ожидании txs: Транзакции tx: Транзакция messages: Сообщения @@ -425,6 +426,7 @@ goblin: transaction: "Транзакция" cancel_request: "Отменить запрос" cancel_send: "Отменить платёж" + stale_note: "Этот платёж уже некоторое время в ожидании." cancel_send_confirm: "Нажмите ещё раз для отмены — он ещё может его получить" cancel_send_done: "Платёж отменён — ваши средства снова доступны" cancel_send_too_late: "Этот платёж уже прошёл и не может быть отменён" diff --git a/locales/tr.yml b/locales/tr.yml index 82d330f3..a03bd9eb 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -95,6 +95,7 @@ wallets: tx_finalizing: Islem tamamlaniyor tx_posting: Islem kaydetme tx_confirmed: Onaylandi + tx_stale: Çok uzun süredir bekliyor txs: Islemler tx: Islem messages: Mesajlar @@ -425,6 +426,7 @@ goblin: transaction: "İşlem" cancel_request: "İsteği iptal et" cancel_send: "Ödemeyi iptal et" + stale_note: "Bu ödeme bir süredir bekliyor." cancel_send_confirm: "İptal için tekrar dokun — hâlâ alabilir" cancel_send_done: "Ödeme iptal edildi — paranız yeniden kullanılabilir" cancel_send_too_late: "Bu ödeme zaten geçti ve iptal edilemez" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 77835d9d..9bcc745c 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -95,6 +95,7 @@ wallets: tx_finalizing: 完成 tx_posting: 过账交易 tx_confirmed: 已确认 + tx_stale: 等待时间过长 txs: 所有交易 tx: 交易 messages: 消息 @@ -425,6 +426,7 @@ goblin: transaction: "交易" cancel_request: "取消请求" cancel_send: "取消付款" + stale_note: "此付款已等待一段时间。" cancel_send_confirm: "再次点按以取消 — 对方可能仍会收到" cancel_send_done: "付款已取消 — 你的资金已重新可用" cancel_send_too_late: "这笔付款已经完成,无法取消" diff --git a/src/gui/views/goblin/data.rs b/src/gui/views/goblin/data.rs index 1a9ba7c2..1f1e6344 100644 --- a/src/gui/views/goblin/data.rs +++ b/src/gui/views/goblin/data.rs @@ -63,6 +63,15 @@ pub struct ReceiptDetail { /// Network fee in atomic units (sends only; unknown for receives). pub fee: Option, pub slate_id: Option, + /// Whether a manual grin cancel is possible for this tx (unconfirmed, not + /// broadcasting, not already cancelled). Drives the universal fallback + /// Cancel that is always offered for a stuck pending, even when the + /// nostr-aware cancel paths do not apply (e.g. a tx orphaned by an identity + /// switch, whose meta lives in another identity's store). + pub can_cancel: bool, + /// Whether this still-cancellable pending has been waiting long enough to + /// nudge the user (a soft flag; never triggers an automatic cancel). + pub stale: bool, } /// Build the receipt detail for a transaction id. @@ -144,6 +153,8 @@ pub fn receipt_detail(wallet: &Wallet, tx_id: u32) -> Option { note, fee, slate_id, + can_cancel: tx.can_cancel(), + stale: tx.stale(), }) } diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 6c298c4d..e5fb6bc8 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -2359,7 +2359,29 @@ impl GoblinWalletView { }) .unwrap_or(false) && !d.canceled && !d.confirmed; - if cancelable_send { + // A manual Cancel is ALWAYS available for a stuck pending. The + // nostr-aware path above (after the grace window) also voids + // the counterparty's DM; this fallback covers every other + // cancellable pending it missed — e.g. a tx orphaned by an + // identity switch (its meta lives in another identity's + // store) or one left by an older build. Both run the plain + // libwallet cancel that unlocks our reserved inputs; nothing + // auto-fires on a timer. + let fallback_cancel = + !cancelable_request && !cancelable_send && d.can_cancel; + if cancelable_send || fallback_cancel { + // Soft nudge that this pending has been waiting a long + // time (a hint; the Cancel button sits right below). + if d.stale { + ui.add_space(12.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("goblin.receipt.stale_note")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.accent), + ); + }); + } ui.add_space(16.0); let confirming = self.cancel_confirm == Some(d.tx_id); let label = if confirming { @@ -2369,12 +2391,18 @@ impl GoblinWalletView { }; if w::big_action(ui, &label, true).clicked() { if confirming { - if let Some(sid) = &d.slate_id { - wallet.task( - crate::wallet::types::WalletTask::NostrCancelSend( - sid.clone(), - ), - ); + if cancelable_send { + if let Some(sid) = &d.slate_id { + wallet.task( + crate::wallet::types::WalletTask::NostrCancelSend( + sid.clone(), + ), + ); + } + } else { + wallet.task(crate::wallet::types::WalletTask::Cancel( + d.tx_id, + )); } self.cancel_confirm = None; } else { diff --git a/src/gui/views/wallets/wallet/txs/content.rs b/src/gui/views/wallets/wallet/txs/content.rs index d4f812f1..5c1a252c 100644 --- a/src/gui/views/wallets/wallet/txs/content.rs +++ b/src/gui/views/wallets/wallet/txs/content.rs @@ -275,12 +275,15 @@ impl WalletTransactionsContent { self.show_delete_confirmation_modal(tx.data.id); }, ); - } else if !tx.cancelled() - && !tx.cancelling() - && !tx.posting() && wallet.synced_from_node() - { - let repeat = tx.broadcasting_timed_out(wallet); - // Draw button to cancel transaction. + } else if !tx.cancelled() && !tx.cancelling() && !tx.posting() { + // Repost/repeat needs a live node height; only a synced wallet + // can decide a broadcast has timed out. + let synced = wallet.synced_from_node(); + let repeat = synced && tx.broadcasting_timed_out(wallet); + // Draw button to cancel transaction. Cancel is a purely local + // libwallet operation (it unlocks the reserved inputs), so it + // is always offered for a cancellable pending, even when the + // node is unreachable or the wallet has not synced yet. if tx.can_cancel() || repeat { let (icon, color) = (PROHIBIT, Some(Colors::red())); View::item_button(ui, btn_rounding, icon, color, || { @@ -293,7 +296,7 @@ impl WalletTransactionsContent { }); } // Draw button to repeat transaction action. - if tx.can_repeat_action(wallet) || repeat { + if synced && (tx.can_repeat_action(wallet) || repeat) { Self::tx_repeat_button_ui( ui, CornerRadius::default(), @@ -429,7 +432,7 @@ impl WalletTransactionsContent { // Setup transaction status text. let height = data.info.last_confirmed_height; - let status_text = if !tx.data.confirmed { + let mut status_text = if !tx.data.confirmed { let is_canceled = tx.data.tx_type == TxLogEntryType::TxSentCancelled || tx.data.tx_type == TxLogEntryType::TxReceivedCancelled; @@ -574,7 +577,7 @@ impl WalletTransactionsContent { }; // Setup status text color. - let status_color = match tx.data.tx_type { + let mut status_color = match tx.data.tx_type { TxLogEntryType::ConfirmedCoinbase => Colors::text(false), TxLogEntryType::TxReceived => { if tx.data.confirmed { @@ -594,6 +597,13 @@ impl WalletTransactionsContent { TxLogEntryType::TxSentCancelled => Colors::inactive_text(), TxLogEntryType::TxReverted => Colors::inactive_text(), }; + // Soft "stale" nudge: a long-pending, still-cancellable tx + // gets an amber warning label. Purely a hint; the manual + // Cancel button sits right beside it and nothing auto-fires. + if tx.stale() { + status_text = format!("{} {}", WARNING, t!("wallets.tx_stale")); + status_color = Colors::gold_dark(); + } View::ellipsize_text(ui, status_text, 15.0, status_color); // Setup transaction time. diff --git a/src/gui/views/wallets/wallet/txs/tx.rs b/src/gui/views/wallets/wallet/txs/tx.rs index 051ba8e7..42464a48 100644 --- a/src/gui/views/wallets/wallet/txs/tx.rs +++ b/src/gui/views/wallets/wallet/txs/tx.rs @@ -337,9 +337,15 @@ impl WalletTransactionContent { } return; } - if wallet.synced_from_node() && !tx.cancelled() && !tx.cancelling() && !tx.posting() { - let repeat = tx.broadcasting_timed_out(&wallet); - // Draw button to cancel transaction. + if !tx.cancelled() && !tx.cancelling() && !tx.posting() { + // Repost/repeat needs a live node height; only a synced wallet can + // decide a broadcast has timed out. + let synced = wallet.synced_from_node(); + let repeat = synced && tx.broadcasting_timed_out(wallet); + // Draw button to cancel transaction. Cancel is a purely local + // libwallet operation (it unlocks the reserved inputs), so it is + // always offered for a cancellable pending, even when the node is + // unreachable or the wallet has not synced yet. if tx.can_cancel() || repeat { let r = View::item_rounding(0, 2, true); View::item_button(ui, r, PROHIBIT, Some(Colors::red()), || { @@ -348,7 +354,7 @@ impl WalletTransactionContent { }); } // Draw button to repeat transaction action. - if tx.can_repeat_action(wallet) || repeat { + if synced && (tx.can_repeat_action(wallet) || repeat) { let r = if tx.can_finalize() || tx.can_cancel() { CornerRadius::default() } else { diff --git a/src/wallet/types.rs b/src/wallet/types.rs index ebec2c45..d33312b7 100644 --- a/src/wallet/types.rs +++ b/src/wallet/types.rs @@ -385,6 +385,29 @@ impl WalletTx { } false } + + /// Age (in seconds) after which a still-cancellable pending is flagged + /// "stale": a soft, user-facing nudge that the transaction has been waiting a + /// long time and can be cancelled. This NEVER triggers any automatic action + /// (a slow interactive send may still complete); it only gates a label. + pub const STALE_AGE_SECS: i64 = 24 * 60 * 60; + + /// Whether to nudge the user that this pending has been waiting long enough to + /// consider cancelling. Soft flag only: it colours a status label and sits + /// beside the always-present manual Cancel button. + pub fn stale(&self) -> bool { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + self.stale_at(now) + } + + /// Pure staleness check against a supplied wall-clock second (testable). + fn stale_at(&self, now_ts: i64) -> bool { + self.can_cancel() + && now_ts.saturating_sub(self.data.creation_ts.timestamp()) >= Self::STALE_AGE_SECS + } } /// Result of [`crate::wallet::Wallet::manual_process_slatepack`]: either a reply @@ -481,3 +504,61 @@ pub enum WalletTask { /// * slate id (uuid string) NostrCancelSend(String), } + +#[cfg(test)] +mod tests { + use super::*; + use grin_keychain::Identifier; + + /// Build a bare [`WalletTx`] of a given type/confirmation for state tests. + fn tx(tx_type: TxLogEntryType, confirmed: bool, action: Option) -> WalletTx { + let mut data = TxLogEntry::new(Identifier::zero(), tx_type, 0); + data.confirmed = confirmed; + WalletTx { + data, + // Standard1: response received, not yet finalized -> not broadcasting. + state: SlateState::Standard1, + proof: None, + amount: 0, + receiver: None, + sender: None, + height: None, + broadcasting_height: None, + action, + action_error: None, + } + } + + #[test] + fn can_cancel_covers_pending_states() { + // A fresh unconfirmed send/receive is cancellable. + assert!(tx(TxLogEntryType::TxSent, false, None).can_cancel()); + assert!(tx(TxLogEntryType::TxReceived, false, None).can_cancel()); + // Confirmed, already-cancelled, or actively cancelling are not. + assert!(!tx(TxLogEntryType::TxSent, true, None).can_cancel()); + assert!(!tx(TxLogEntryType::TxSentCancelled, false, None).can_cancel()); + assert!(!tx(TxLogEntryType::TxReceivedCancelled, false, None).can_cancel()); + assert!( + !tx( + TxLogEntryType::TxSent, + false, + Some(WalletTxAction::Cancelling) + ) + .can_cancel() + ); + } + + #[test] + fn stale_only_after_threshold_and_only_when_cancellable() { + let pending = tx(TxLogEntryType::TxSent, false, None); + let created = pending.data.creation_ts.timestamp(); + // Just under the threshold: not yet stale. + assert!(!pending.stale_at(created + WalletTx::STALE_AGE_SECS - 1)); + // At/after the threshold: stale nudge appears. + assert!(pending.stale_at(created + WalletTx::STALE_AGE_SECS)); + // A confirmed tx is never stale, however old. + let confirmed = tx(TxLogEntryType::TxSent, true, None); + let c_created = confirmed.data.creation_ts.timestamp(); + assert!(!confirmed.stale_at(c_created + 10 * WalletTx::STALE_AGE_SECS)); + } +}