Add manual 'Cancel payment' to reclaim a stuck outgoing send

A Goblin payment locks the sender's outputs until the recipient replies (S2)
and we finalize+post. If the recipient never connects to nostr, the funds stay
locked until the 24h auto-expiry. This adds a manual Cancel that reclaims them
on demand (after a 10-min grace, or immediately if the send never reached a
relay), marks the payment Cancelled, and best-effort voids it to the recipient.

- WalletTask::NostrCancelSend: authoritative tx lookup; refuses if already
  finalized/confirmed (race); marks meta Cancelled BEFORE cancelling the grin
  tx; serialized with nostr_finalize_post via a per-service lock so a cancel and
  a concurrent S2 finalize can't both commit.
- nostr_finalize_post returns Ok(false) (skip, no retry/re-post) when the tx is
  cancelled or the meta is Cancelled — covers the tx-list cancel path too.
- decide() already drops a late S2 on a Cancelled meta (new unit tests assert
  it); recipient-side void marks a received payment Cancelled for display WITHOUT
  deleting the output (a malicious sender could void-then-post otherwise).
- Void-before-S1 ordering handled via a (slate,sender)-bound marker.
- Receipt: tap-twice 'Cancel payment' with caveat + outcome notice; honest
  'Waiting for X to receive…' label; first-class Cancelled status. 6 locales.
- cancel_grace_secs config (default 600).
This commit is contained in:
2ro
2026-06-15 19:34:44 -04:00
parent 2e6cff9eeb
commit 9768de2fbd
13 changed files with 376 additions and 13 deletions
+97
View File
@@ -105,6 +105,11 @@ pub struct GoblinWalletView {
avatar_slot: std::sync::Arc<std::sync::Mutex<Option<Result<(String, Vec<u8>), String>>>>,
/// Last upload outcome message (cleared on the next attempt).
avatar_msg: Option<String>,
/// Receipt "Cancel payment" tap-twice confirm: the tx_id awaiting a second
/// confirming tap (cleared when another receipt opens or it's fired).
cancel_confirm: Option<u32>,
/// Outcome of the last manual cancel, shown transiently on the receipt.
cancel_msg: Option<(crate::nostr::CancelOutcome, std::time::Instant)>,
}
/// Sub-pages of the Settings tab.
@@ -183,6 +188,8 @@ impl Default for GoblinWalletView {
avatar_busy: false,
avatar_slot: std::sync::Arc::new(std::sync::Mutex::new(None)),
avatar_msg: None,
cancel_confirm: None,
cancel_msg: None,
}
}
}
@@ -1328,6 +1335,13 @@ impl GoblinWalletView {
t!("goblin.receipt.confs", c => c, r => r)
.to_string()
}
// An outgoing nostr send with no confirmations
// yet hasn't been received — say so honestly,
// rather than implying it's on-chain.
None if !d.incoming && d.npub.is_some() => {
t!("goblin.receipt.waiting_to_receive", name => d.title)
.to_string()
}
None => {
t!("goblin.receipt.waiting_to_confirm").to_string()
}
@@ -1405,6 +1419,89 @@ impl GoblinWalletView {
close = true;
}
}
// Reclaim a payment WE sent that the recipient never
// completed: cancel the grin tx to unlock our funds, mark
// it cancelled, best-effort void. Appears after the grace
// window (or immediately if it never reached a relay).
let send_meta = d.slate_id.as_ref().and_then(|sid| {
wallet.nostr_service().and_then(|s| s.store.tx_meta(sid))
});
let grace = wallet
.nostr_service()
.map(|s| s.config.read().cancel_grace_secs())
.unwrap_or(600);
let cancelable_send = send_meta
.as_ref()
.map(|m| {
m.direction == crate::nostr::NostrTxDirection::Sent
&& matches!(
m.status,
crate::nostr::NostrSendStatus::Created
| crate::nostr::NostrSendStatus::AwaitingS2
| crate::nostr::NostrSendStatus::SendFailed
) && (matches!(
m.status,
crate::nostr::NostrSendStatus::SendFailed
) || crate::nostr::unix_time() - m.created_at > grace)
})
.unwrap_or(false) && !d.canceled
&& !d.confirmed;
if cancelable_send {
ui.add_space(16.0);
let confirming = self.cancel_confirm == Some(d.tx_id);
let label = if confirming {
t!("goblin.receipt.cancel_send_confirm")
} else {
t!("goblin.receipt.cancel_send")
};
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(),
),
);
}
self.cancel_confirm = None;
} else {
self.cancel_confirm = Some(d.tx_id);
}
}
} else {
self.cancel_confirm = None;
}
// Transient outcome notice, set async by the task handler.
if let Some(outcome) =
wallet.nostr_service().and_then(|s| s.take_cancel_notice())
{
self.cancel_msg = Some((outcome, std::time::Instant::now()));
}
if let Some((outcome, at)) = self.cancel_msg {
if at.elapsed().as_secs() < 5 {
ui.add_space(10.0);
let (msg, col) = match outcome {
crate::nostr::CancelOutcome::Cancelled => {
(t!("goblin.receipt.cancel_send_done"), t.pos)
}
crate::nostr::CancelOutcome::AlreadyCompleted => {
(t!("goblin.receipt.cancel_send_too_late"), t.text_dim)
}
};
ui.vertical_centered(|ui| {
ui.label(
RichText::new(msg)
.font(FontId::new(13.0, fonts::regular()))
.color(col),
);
});
ui.ctx().request_repaint_after(
std::time::Duration::from_millis(300),
);
} else {
self.cancel_msg = None;
}
}
ui.add_space(20.0);
});
});