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:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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é"
|
||||
|
||||
@@ -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: "Этот платёж уже прошёл и не может быть отменён"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: "这笔付款已经完成,无法取消"
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user