1
0
forked from GRIN/grim

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.
This commit is contained in:
2ro
2026-07-06 20:12:15 -04:00
parent 2790d9a592
commit 062cf2f2ab
11 changed files with 168 additions and 20 deletions
+2
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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é"
+2
View File
@@ -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: "Этот платёж уже прошёл и не может быть отменён"
+2
View File
@@ -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"
+2
View File
@@ -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: "这笔付款已经完成,无法取消"
+11
View File
@@ -63,6 +63,15 @@ pub struct ReceiptDetail {
/// Network fee in atomic units (sends only; unknown for receives).
pub fee: Option<u64>,
pub slate_id: Option<String>,
/// 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<ReceiptDetail> {
note,
fee,
slate_id,
can_cancel: tx.can_cancel(),
stale: tx.stale(),
})
}
+35 -7
View File
@@ -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 {
+19 -9
View File
@@ -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.
+10 -4
View File
@@ -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 {
+81
View File
@@ -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<WalletTxAction>) -> 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));
}
}