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:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: "Принять"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: "同意"
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user