mirror of
https://code.gri.mw/GUI/grim.git
synced 2026-07-20 05:38:55 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user