1
0
forked from GRIN/grim

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
+5
View File
@@ -416,6 +416,11 @@ goblin:
privacy_value: "Mimblewimble + Nym"
transaction: "Transaktion"
cancel_request: "Anfrage abbrechen"
cancel_send: "Zahlung abbrechen"
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"
waiting_to_receive: "Warte, bis %{name} empfängt…"
request:
title: "%{name} fordert an"
approve: "Annehmen"
+5
View File
@@ -416,6 +416,11 @@ goblin:
privacy_value: "Mimblewimble + Nym"
transaction: "Transaction"
cancel_request: "Cancel request"
cancel_send: "Cancel payment"
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"
waiting_to_receive: "Waiting for %{name} to receive…"
request:
title: "%{name} requests"
approve: "Approve"
+5
View File
@@ -416,6 +416,11 @@ goblin:
privacy_value: "Mimblewimble + Nym"
transaction: "Transaction"
cancel_request: "Annuler la demande"
cancel_send: "Annuler le paiement"
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é"
waiting_to_receive: "En attente de réception par %{name}…"
request:
title: "%{name} demande"
approve: "Approuver"
+5
View File
@@ -416,6 +416,11 @@ goblin:
privacy_value: "Mimblewimble + Nym"
transaction: "Транзакция"
cancel_request: "Отменить запрос"
cancel_send: "Отменить платёж"
cancel_send_confirm: "Нажмите ещё раз для отмены — он ещё может его получить"
cancel_send_done: "Платёж отменён — ваши средства снова доступны"
cancel_send_too_late: "Этот платёж уже прошёл и не может быть отменён"
waiting_to_receive: "Ожидание, пока %{name} получит…"
request:
title: "%{name} запрашивает"
approve: "Принять"
+5
View File
@@ -416,6 +416,11 @@ goblin:
privacy_value: "Mimblewimble + Nym"
transaction: "İşlem"
cancel_request: "İsteği iptal et"
cancel_send: "Ödemeyi iptal et"
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"
waiting_to_receive: "%{name} alana kadar bekleniyor…"
request:
title: "%{name} istiyor"
approve: "Onayla"
+5
View File
@@ -416,6 +416,11 @@ goblin:
privacy_value: "Mimblewimble + Nym"
transaction: "交易"
cancel_request: "取消请求"
cancel_send: "取消付款"
cancel_send_confirm: "再次点按以取消 — 对方可能仍会收到"
cancel_send_done: "付款已取消 — 你的资金已重新可用"
cancel_send_too_late: "这笔付款已经完成,无法取消"
waiting_to_receive: "等待 %{name} 接收…"
request:
title: "%{name} 发起请求"
approve: "同意"
+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);
});
});
+92 -11
View File
@@ -89,6 +89,12 @@ pub struct NostrService {
/// the failure screen so the user (and we) can see WHY, not just "couldn't
/// send". Cleared when a new attempt starts.
last_send_error: RwLock<Option<String>>,
/// Result of the most recent manual payment-cancel, taken once by the receipt
/// UI to show "cancelled" vs "already went through".
cancel_notice: RwLock<Option<CancelOutcome>>,
/// Serializes a manual payment-cancel against a concurrent S2 finalize+post
/// so the two can't both succeed (cancel the outputs AND post on-chain).
cancel_finalize_lock: Mutex<()>,
}
/// Phase of the most recent outgoing send, polled by the send UI.
@@ -125,6 +131,8 @@ impl NostrService {
rate: Mutex::new(HashMap::new()),
send_phase: std::sync::atomic::AtomicU8::new(send_phase::IDLE),
last_send_error: RwLock::new(None),
cancel_notice: RwLock::new(None),
cancel_finalize_lock: Mutex::new(()),
})
}
@@ -253,6 +261,23 @@ impl NostrService {
self.last_send_error.read().clone()
}
/// Record the outcome of a manual payment-cancel for the UI to surface.
pub fn set_cancel_notice(&self, outcome: CancelOutcome) {
*self.cancel_notice.write() = Some(outcome);
}
/// Take (consume) the pending payment-cancel outcome, if any.
pub fn take_cancel_notice(&self) -> Option<CancelOutcome> {
self.cancel_notice.write().take()
}
/// Acquire the cancel/finalize serialization lock. Held by both the manual
/// payment-cancel and `nostr_finalize_post` so a cancel and a concurrent S2
/// finalize can't both commit (one would reclaim outputs the other posts).
pub fn lock_finalize(&self) -> parking_lot::MutexGuard<'_, ()> {
self.cancel_finalize_lock.lock()
}
/// Whether at least one relay is connected.
pub fn is_connected(&self) -> bool {
self.connected.load(Ordering::Relaxed)
@@ -984,16 +1009,21 @@ fn handle_request_void(svc: &Arc<NostrService>, wallet: &Wallet, slate_id: &str,
if voided {
return;
}
// Role B — we are the requester and the payer declined. Cancel our invoice tx
// and mark the meta cancelled so it leaves the pending state.
if let Some(meta) = svc.store.tx_meta(slate_id) {
if meta.direction == NostrTxDirection::RequestedByUs
&& matches!(
meta.status,
NostrSendStatus::Created | NostrSendStatus::AwaitingI2
) && meta.npub == sender
{
info!("nostr: outgoing request {} declined by payer", slate_id);
// The `sender` must match the stored counterparty (binding checked below) so
// a stranger can't void someone else's tx.
let Some(meta) = svc.store.tx_meta(slate_id) else {
return;
};
if meta.npub != sender {
return;
}
match (meta.direction, meta.status) {
// Role B — we are the requester and the payer declined our invoice. An
// issued invoice locks no outputs of ours, so cancelling the grin tx is
// safe and keeps the ledger tidy.
(NostrTxDirection::RequestedByUs, NostrSendStatus::Created)
| (NostrTxDirection::RequestedByUs, NostrSendStatus::AwaitingI2) => {
info!("nostr: outgoing request {slate_id} declined by payer");
if let Some(tx_id) = wallet.get_data().and_then(|d| d.txs).and_then(|txs| {
txs.iter()
.find(|t| {
@@ -1006,6 +1036,20 @@ fn handle_request_void(svc: &Arc<NostrService>, wallet: &Wallet, slate_id: &str,
svc.store
.update_tx_status(slate_id, NostrSendStatus::Cancelled);
}
// Role C — we received a payment the SENDER now says is void. Only mark
// the meta cancelled for display; do NOT cancel the grin tx. Cancelling a
// received tx DELETES our incoming output from wallet tracking, and a
// malicious sender could void-then-still-finalize (they hold our S2 once
// we replied), confirming funds our wallet would no longer see. Leaving
// the output tracked means it still confirms if they post; if they don't,
// it simply never confirms (and shows Cancelled while unconfirmed).
(NostrTxDirection::Received, NostrSendStatus::ReceivedNoReply)
| (NostrTxDirection::Received, NostrSendStatus::RepliedS2) => {
info!("nostr: incoming payment {slate_id} voided by sender");
svc.store
.update_tx_status(slate_id, NostrSendStatus::Cancelled);
}
_ => {}
}
}
@@ -1091,6 +1135,15 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client,
// counterparty inside, so a stranger can't void someone else's request.
if let Some(void_slate_id) = protocol::extract_control(&rumor.tags) {
handle_request_void(svc, wallet, &void_slate_id, &sender_hex);
// Record the void keyed by (slate, sender) so a payment S1 that arrives
// AFTER its void (relays reorder; NIP-59 randomizes timestamps) is dropped.
// Binding to the sender stops a stranger pre-voiding someone else's slate.
// A slate id is a UUID (36 chars); ignore anything longer so an attacker
// can't bloat the processed-key store with an oversized tag value.
if void_slate_id.len() <= 64 {
svc.store
.mark_processed(&format!("void:{}:{}", void_slate_id, sender_hex));
}
svc.store.mark_processed(&wrap_id);
svc.store.mark_processed(&rumor_id);
return;
@@ -1115,6 +1168,22 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client,
svc.store.mark_processed(&rumor_id);
return;
}
// 10b. Void-before-payment: the sender cancelled this payment and the void
// reached us before the S1. Drop the dead slate rather than auto-receiving it.
if matches!(slate.state, grin_wallet_libwallet::SlateState::Standard1)
&& svc
.store
.is_processed(&format!("void:{}:{}", slate.id, sender_hex))
{
info!(
"nostr: dropping S1 for slate {} already voided by sender",
slate.id
);
svc.store.mark_processed(&wrap_id);
svc.store.mark_processed(&rumor_id);
svc.store.mark_processed(&slate_marker);
return;
}
// 11. Policy decision.
let meta = svc.store.tx_meta(&slate.id.to_string());
let tx_exists = wallet.has_tx_for_slate(&slate.id);
@@ -1212,7 +1281,7 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client,
svc.store.mark_processed(&slate_marker);
}
IngestDecision::FinalizePost => match wallet.nostr_finalize_post(&slate) {
Ok(()) => {
Ok(true) => {
svc.store
.update_tx_status(&slate.id.to_string(), NostrSendStatus::Finalized);
// Finalize+post committed; mark dedup before the sync tail so a
@@ -1228,6 +1297,18 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client,
}
wallet.sync();
}
Ok(false) => {
// The send was cancelled out-of-band (the meta usually already
// reflects this and decide() drops the S2 before we get here; this
// covers a tx-list cancel that left the meta untouched). Reconcile
// the status and treat the reply as handled — never retry/re-post.
svc.store
.update_tx_status(&slate.id.to_string(), NostrSendStatus::Cancelled);
svc.store.mark_processed(&wrap_id);
svc.store.mark_processed(&rumor_id);
svc.store.mark_processed(&slate_marker);
info!("nostr: skipped finalize of cancelled slate {}", slate.id);
}
Err(e) => {
error!("nostr: finalize failed for slate {}: {:?}", slate.id, e);
}
+10
View File
@@ -45,6 +45,10 @@ pub struct NostrConfig {
/// Seconds after which a still-pending transaction is auto-canceled/expired.
/// Default 24h; lower it (e.g. 60) in nostr.toml to test the expiry flow.
expiry_secs: Option<i64>,
/// Seconds before the manual "Cancel payment" button appears on a still-
/// pending send (one that never reached a relay shows it immediately).
/// Default 10 min; lower it in nostr.toml to test the cancel flow.
cancel_grace_secs: Option<i64>,
/// Whether incoming payment requests (Invoice1) are accepted. Opt-out: on
/// by default. When off, incoming requests are dropped and the preference is
/// advertised in our kind-0 profile so requesters see it before sending.
@@ -63,6 +67,7 @@ impl Default for NostrConfig {
accept_from: None,
nip05_server: None,
expiry_secs: None,
cancel_grace_secs: None,
allow_incoming_requests: None,
path: None,
}
@@ -130,6 +135,11 @@ impl NostrConfig {
self.expiry_secs.unwrap_or(24 * 60 * 60)
}
/// Seconds before the manual cancel button appears on a pending send.
pub fn cancel_grace_secs(&self) -> i64 {
self.cancel_grace_secs.unwrap_or(600)
}
pub fn allow_incoming_requests(&self) -> bool {
self.allow_incoming_requests.unwrap_or(true)
}
+19
View File
@@ -269,6 +269,25 @@ mod tests {
assert!(matches!(decide(&c), IngestDecision::Drop(_)));
}
#[test]
fn s2_on_cancelled_send_drops() {
// Safety backstop for the cancel/reclaim race: once a manual "Cancel
// payment" (or 24h expiry) marks the meta Cancelled, a late S2 from a
// recipient who finally came online must be DROPPED — never re-finalized
// onto outputs the sender already reclaimed.
let m = meta(NostrTxDirection::Sent, NostrSendStatus::Cancelled, ALICE);
let c = ctx(SlateState::Standard2, 100, ALICE, Some(&m), true);
assert!(matches!(decide(&c), IngestDecision::Drop(_)));
}
#[test]
fn s2_on_finalized_send_drops() {
// Idempotency: a duplicate S2 after we already finalized is dropped.
let m = meta(NostrTxDirection::Sent, NostrSendStatus::Finalized, ALICE);
let c = ctx(SlateState::Standard2, 100, ALICE, Some(&m), true);
assert!(matches!(decide(&c), IngestDecision::Drop(_)));
}
#[test]
fn s2_finalizes_from_pre_dispatch_states() {
// Created/SendFailed are deliberately accepted: a crash between
+11
View File
@@ -52,6 +52,17 @@ pub enum NostrSendStatus {
Cancelled,
}
/// Outcome of a manual payment-cancel, surfaced transiently on the receipt so
/// the user knows whether their funds came back or the payment had already
/// completed in the race window.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CancelOutcome {
/// The pending payment was cancelled and the locked funds released.
Cancelled,
/// The payment had already gone through; nothing was cancelled.
AlreadyCompleted,
}
/// Per-transaction nostr metadata, joined to wallet txs by slate id.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct TxNostrMeta {
+7
View File
@@ -458,4 +458,11 @@ pub enum WalletTask {
/// void control message so the pending card disappears on their side.
/// * slate id (uuid string)
NostrCancelOutgoing(String),
/// Cancel a payment WE sent that the recipient never completed: cancel the
/// local grin tx to RECLAIM the locked outputs, mark it cancelled, and send
/// the recipient a best-effort void so a late catch-up drops the dead slate.
/// Refuses (and notes "already completed") if the payment finalized in the
/// race window.
/// * slate id (uuid string)
NostrCancelSend(String),
}
+110 -2
View File
@@ -1261,10 +1261,40 @@ impl Wallet {
/// Guarded nostr ingest: finalize and post a matching S2/I2 reply.
/// Caller (ingest policy) has already verified the counterparty.
pub fn nostr_finalize_post(&self, slate: &Slate) -> Result<(), Error> {
///
/// Returns `Ok(true)` when the reply was finalized + posted, `Ok(false)` when
/// the local tx had been cancelled out-of-band (manual "Cancel payment", the
/// generic tx-list cancel, or 24h expiry) so the reply was intentionally
/// skipped — the caller records it as handled, NOT retried, and never
/// re-posts a payment the sender already reclaimed.
pub fn nostr_finalize_post(&self, slate: &Slate) -> Result<bool, Error> {
// Serialize against a concurrent manual cancel of the same payment: hold
// the lock across the check + finalize + post so a cancel can't reclaim
// the outputs while we post them on-chain (and vice-versa). The guard is
// kept alive for the whole function via `_svc`/`_lock`.
let _svc = self.nostr_service();
let _lock = _svc.as_ref().map(|s| s.lock_finalize());
let tx = self
.retrieve_tx_by_id(None, Some(slate.id))
.ok_or_else(|| Error::GenericError("transaction not found".to_string()))?;
if matches!(
tx.tx_type,
TxLogEntryType::TxSentCancelled | TxLogEntryType::TxReceivedCancelled
) {
return Ok(false);
}
// Also honour a cancel that marked the meta but whose grin cancel hasn't
// committed yet (the cancel handler marks the meta first, under this lock).
if let Some(svc) = &_svc {
if svc
.store
.tx_meta(&slate.id.to_string())
.map(|m| m.status == crate::nostr::NostrSendStatus::Cancelled)
.unwrap_or(false)
{
return Ok(false);
}
}
// A prior attempt may have finalized but then failed to post (a transient
// node outage). Re-finalizing errors on the already-finalized tx, so when
// the finalized (Standard3) slatepack is already on disk we parse and
@@ -1279,7 +1309,7 @@ impl Wallet {
None => self.finalize(slate, tx.id)?,
};
self.post(&finalized, Some(tx.id))?;
Ok(())
Ok(true)
}
/// Pay an APPROVED payment request (Invoice1). Only ever called from the
@@ -2445,6 +2475,84 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
}
sync_wallet_data(&w, false);
}
WalletTask::NostrCancelSend(slate_id) => {
let Some(service) = w.nostr_service() else {
return;
};
let Some(meta) = service.store.tx_meta(slate_id) else {
error!("nostr cancel send: no metadata for slate {slate_id}");
return;
};
if meta.direction != crate::nostr::NostrTxDirection::Sent {
error!("nostr cancel send: slate {slate_id} is not an outgoing send");
return;
}
let Ok(uuid) = uuid::Uuid::parse_str(slate_id) else {
error!("nostr cancel send: bad slate id {slate_id}");
return;
};
// The critical section is serialized with `nostr_finalize_post` so a
// concurrent S2 can't post the payment while we reclaim its outputs.
let mut did_cancel = false;
{
let _lock = service.lock_finalize();
// Re-read status UNDER the lock. If the payment already finalized in
// the race window, refuse and report it; if it's already cancelled,
// report success idempotently.
match service.store.tx_meta(slate_id).map(|m| m.status) {
Some(crate::nostr::NostrSendStatus::Finalized) => {
service.set_cancel_notice(crate::nostr::CancelOutcome::AlreadyCompleted);
return;
}
Some(crate::nostr::NostrSendStatus::Cancelled) => {
service.set_cancel_notice(crate::nostr::CancelOutcome::Cancelled);
return;
}
_ => {}
}
// Authoritative tx lookup (not the paginated display cache). If it's
// missing we must NOT claim success — nothing was reclaimed.
let Some(tx) = w.retrieve_tx_by_id(None, Some(uuid)) else {
error!("nostr cancel send: grin tx not found for slate {slate_id}");
service.set_cancel_notice(crate::nostr::CancelOutcome::AlreadyCompleted);
return;
};
if tx.confirmed {
service.set_cancel_notice(crate::nostr::CancelOutcome::AlreadyCompleted);
return;
}
if matches!(
tx.tx_type,
TxLogEntryType::TxSentCancelled | TxLogEntryType::TxReceivedCancelled
) {
// Already cancelled at the grin layer — just reconcile the meta.
service
.store
.update_tx_status(slate_id, crate::nostr::NostrSendStatus::Cancelled);
service.set_cancel_notice(crate::nostr::CancelOutcome::Cancelled);
} else {
// Mark the meta cancelled FIRST so any S2 still at the decide()
// stage is dropped, THEN cancel the grin tx to free the outputs.
service
.store
.update_tx_status(slate_id, crate::nostr::NostrSendStatus::Cancelled);
if let Err(e) = w.cancel(tx.id) {
error!("nostr cancel send: wallet cancel failed: {e}");
}
service.set_cancel_notice(crate::nostr::CancelOutcome::Cancelled);
did_cancel = true;
}
}
sync_wallet_data(&w, false);
// Best-effort void so a recipient who catches up later drops the dead
// slate. They're likely offline (that's why the payment stalled), so a
// failure here is expected and harmless — the local reclaim stands.
if did_cancel {
if let Err(e) = service.send_control_dm(&meta.npub, slate_id, &[]).await {
info!("nostr cancel send: void dispatch failed (recipient offline?): {e}");
}
}
}
WalletTask::NostrResend(id) => {
let Some(service) = w.nostr_service() else {
return;