diff --git a/locales/de.yml b/locales/de.yml index dcc8fae..c7e871e 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -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" diff --git a/locales/en.yml b/locales/en.yml index 2cb82a6..f1f4062 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -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" diff --git a/locales/fr.yml b/locales/fr.yml index cb29114..b52b33b 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -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" diff --git a/locales/ru.yml b/locales/ru.yml index 89a5998..4f10a9b 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -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: "Принять" diff --git a/locales/tr.yml b/locales/tr.yml index 45a638a..35ffc5c 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -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" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 9fdad4b..b329e9d 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -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: "同意" diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index c643bbf..34cd9fa 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -105,6 +105,11 @@ pub struct GoblinWalletView { avatar_slot: std::sync::Arc), String>>>>, /// Last upload outcome message (cleared on the next attempt). avatar_msg: Option, + /// 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, + /// 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); }); }); diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 627a70c..3a3ec39 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -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>, + /// Result of the most recent manual payment-cancel, taken once by the receipt + /// UI to show "cancelled" vs "already went through". + cancel_notice: RwLock>, + /// 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 { + 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, 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, 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, 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, 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, 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, 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); } diff --git a/src/nostr/config.rs b/src/nostr/config.rs index 0bd40b1..b8cb2c9 100644 --- a/src/nostr/config.rs +++ b/src/nostr/config.rs @@ -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, + /// 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, /// 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) } diff --git a/src/nostr/ingest.rs b/src/nostr/ingest.rs index 9991af8..1300669 100644 --- a/src/nostr/ingest.rs +++ b/src/nostr/ingest.rs @@ -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 diff --git a/src/nostr/types.rs b/src/nostr/types.rs index 9f83756..39e0b88 100644 --- a/src/nostr/types.rs +++ b/src/nostr/types.rs @@ -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 { diff --git a/src/wallet/types.rs b/src/wallet/types.rs index 108e353..c8de83f 100644 --- a/src/wallet/types.rs +++ b/src/wallet/types.rs @@ -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), } diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index 89ed2a4..172554c 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -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 { + // 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;