From e9f0c3f0e233183d17b53637c4db456a0062d36d Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Sun, 5 Jul 2026 05:53:00 -0400 Subject: [PATCH] Goblin: publish the "payment sent" receipt at dispatch, not finalize Closes the buyer's double-send window. When a Nostr-rail send carries order context, the plain kind-17 status=sent receipt used to publish at finalize alongside the encrypted proof. With an offline merchant, finalize can be hours away, so the buyer stared at a scannable QR for a payment already made: maximum double-send temptation. The receipt now publishes at S1 dispatch, the same moment the payment envelope is accepted by a relay and the wallet UI flips to "sent", through the same crash-safe reconcile queue. The event shape is unchanged (same tags, kind, content); only the timing moves earlier, so magick's status=sent matcher is unaffected. The encrypted PROOF delivery stays at finalize (the proof does not exist before then). Idempotency: a new receipt_sent flag on TxNostrMeta gates the receipt to exactly one publish per tx, so finalize never duplicates it. deliver_proof no longer touches the receipt; the reconcile pass gets an independent receipt-retry path for the crash/offline case. Decision logic is factored into pure predicates (receipt_due_at_dispatch, receipt_retry_due, proof_delivery_due) used by the live paths and covered by three tests: dispatch-publish decision, no-duplicate-at-finalize, and proof-still-at-finalize. Full lib + i18n drift green. --- src/nostr/client.rs | 259 +++++++++++++++++++++++++++++++------------ src/nostr/ingest.rs | 1 + src/nostr/types.rs | 26 ++++- src/wallet/wallet.rs | 29 +++++ 4 files changed, 243 insertions(+), 72 deletions(-) diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 07b748e5..0bf6e3ad 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -1532,71 +1532,103 @@ fn expiry_locks_outputs(direction: NostrTxDirection, status: NostrSendStatus) -> ) } -/// Produce and deliver the proof-on-request artifacts for a finalized SEND -/// (frozen contract 4.3): the plain "payment sent" receipt to the app relays and -/// the encrypted proof delivery to the watcher. Idempotent and safe to retry: a -/// duplicate plain receipt is harmless (the market dedupes by invoice) and the -/// watcher dedupes its own inputs, so it is driven from both the finalize task -/// and the reconcile pass. Returns true when every REQUIRED delivery for this -/// context landed, so the caller can set `proof_delivered` and stop retrying. -async fn deliver_proof(svc: &Arc, wallet: &Wallet, meta: &TxNostrMeta) -> bool { +/// Whether the plain "payment sent" receipt (frozen contract 4.3.1) still owes a +/// (re)publish for this tx. True for a proof-mode SEND whose payment envelope has +/// been accepted by a relay (status past `Created`, the UI has flipped to +/// "sent") but whose receipt has not landed yet. This is the crash/offline retry +/// gate: the receipt normally publishes inline at dispatch, and this catches the +/// case where that publish failed or the process crashed after dispatch. Once +/// `receipt_sent` flips, it is never republished: the one-receipt-per-tx guard. +fn receipt_retry_due(meta: &TxNostrMeta) -> bool { + meta.direction == NostrTxDirection::Sent + && meta.proof_mode + && !meta.receipt_sent + && matches!( + meta.status, + NostrSendStatus::AwaitingS2 | NostrSendStatus::Finalized + ) +} + +/// Whether the encrypted proof delivery (frozen contract 4.3.2) still owes a +/// (re)publish for this tx. True only for a FINALIZED proof-mode SEND whose proof +/// delivery has not landed: the proof does not exist before finalize, so unlike +/// the receipt it is never attempted at dispatch. +fn proof_delivery_due(meta: &TxNostrMeta) -> bool { + meta.direction == NostrTxDirection::Sent + && meta.status == NostrSendStatus::Finalized + && meta.proof_mode + && !meta.proof_delivered +} + +/// Publish the plain "payment sent" receipt for a dispatched proof-mode send and, +/// on success, flip `receipt_sent` so it is never republished. Retry-safe: driven +/// from the reconcile pass whenever [`receipt_retry_due`] holds. +async fn deliver_receipt(svc: &Arc, meta: &TxNostrMeta) { let Some(order) = meta.proof_order.clone() else { - // No order handle => no `payment-request` routing key the market can match. - // Nothing deliverable; treat as done so we don't retry a dead context. + return; + }; + let amount = meta.proof_amount.unwrap_or(0); + match svc.publish_receipt_sent(&order, amount).await { + Ok(()) => { + let mut updated = meta.clone(); + updated.receipt_sent = true; + updated.updated_at = unix_time(); + svc.store.save_tx_meta(&updated); + } + Err(e) => warn!( + "nostr: reconcile receipt publish failed for {}: {e}", + meta.slate_id + ), + } +} + +/// Deliver the encrypted proof-on-request artifact for a finalized SEND (frozen +/// contract 4.3.2): the gift-wrapped proof to the watcher's npub. The plain +/// "payment sent" receipt is deliberately NOT published here; it already went +/// out at S1 dispatch (4.3.1), gated by `receipt_sent`, so exactly one receipt +/// exists per tx and finalize never duplicates it. Idempotent/retry-safe (the +/// watcher dedupes its inputs), so it is driven from both the finalize task and +/// the reconcile pass. Returns true when the required delivery for this context +/// landed, so the caller can set `proof_delivered` and stop retrying. +async fn deliver_proof(svc: &Arc, wallet: &Wallet, meta: &TxNostrMeta) -> bool { + // No watcher target (4.1) => nothing to encrypt. The dispatch receipt was the + // whole job; treat as done so the reconcile pass stops retrying. + let Some(notify) = meta.proof_notify.clone() else { + return true; + }; + let Some(order) = meta.proof_order.clone() else { + // No order handle => no `payment-request` routing key the watcher can match. warn!( - "nostr: proof mode without order handle for {}, skipping delivery", + "nostr: proof mode without order handle for {}, skipping proof delivery", meta.slate_id ); return true; }; let amount = meta.proof_amount.unwrap_or(0); - - // 1. Plain receipt (instant page feedback). Always attempted in proof mode. - let receipt_ok = match svc.publish_receipt_sent(&order, amount).await { - Ok(()) => true, - Err(e) => { - warn!( - "nostr: proof receipt publish failed for {}: {e}", - meta.slate_id - ); - false - } + let Ok(slate_id) = uuid::Uuid::parse_str(&meta.slate_id) else { + return false; }; - - // 2. Encrypted proof delivery: only when a watcher target AND a real proof - // exist. If `notify` is absent (4.1), the plain receipt alone is the whole - // job and there is nothing to encrypt. - let wrap_ok = match &meta.proof_notify { - None => true, - Some(notify) => { - let Ok(slate_id) = uuid::Uuid::parse_str(&meta.slate_id) else { - return false; - }; - match wallet.payment_proof_delivery(slate_id) { - Some((json, kernel_hex)) => { - match svc - .deliver_proof_wrap(notify, &order, amount, &kernel_hex, &json) - .await - { - Ok(()) => true, - Err(e) => { - warn!("nostr: proof delivery failed for {}: {e}", meta.slate_id); - false - } - } - } - None => { - warn!( - "nostr: no payment proof retrievable yet for {}", - meta.slate_id - ); + match wallet.payment_proof_delivery(slate_id) { + Some((json, kernel_hex)) => { + match svc + .deliver_proof_wrap(¬ify, &order, amount, &kernel_hex, &json) + .await + { + Ok(()) => true, + Err(e) => { + warn!("nostr: proof delivery failed for {}: {e}", meta.slate_id); false } } } - }; - - receipt_ok && wrap_ok + None => { + warn!( + "nostr: no payment proof retrievable yet for {}", + meta.slate_id + ); + false + } + } } /// Re-dispatch our pending outgoing messages (crash/offline recovery). @@ -1606,15 +1638,22 @@ async fn reconcile(svc: &Arc, wallet: &Wallet) { if now - meta.created_at > RESEND_WINDOW_SECS { continue; } - // Proof-on-request delivery retry (frozen contract 4.3, W4): a finalized - // send in proof mode whose delivery events have not all landed yet. Retried - // on every reconcile pass until `proof_delivered` flips, exactly like the - // S2 replies above it. - if meta.direction == NostrTxDirection::Sent - && meta.status == NostrSendStatus::Finalized - && meta.proof_mode - && !meta.proof_delivered - { + // Receipt retry (frozen contract 4.3.1): the plain "payment sent" receipt + // normally publishes inline at dispatch, the moment the UI flips to "sent". + // This catches the crash/offline case where that publish failed. Retried + // every pass until `receipt_sent` flips, and independent of the proof + // delivery below: the receipt closes the buyer's double-send window at + // "sent", the proof waits for finalize. A Finalized tx can owe both, so this + // does NOT `continue`; it falls through to the proof block. + if receipt_retry_due(&meta) { + deliver_receipt(svc, &meta).await; + } + // Proof-on-request delivery retry (frozen contract 4.3.2, W4): a finalized + // send in proof mode whose encrypted proof delivery has not landed yet. + // Retried on every reconcile pass until `proof_delivered` flips. + if proof_delivery_due(&meta) { + // Re-read: deliver_receipt above may have flipped receipt_sent on disk. + let meta = svc.store.tx_meta(&meta.slate_id).unwrap_or(meta); if deliver_proof(svc, wallet, &meta).await { let mut updated = meta.clone(); updated.proof_delivered = true; @@ -2087,6 +2126,7 @@ async fn handle_wrap(svc: &Arc, wallet: &Wallet, event: Event) { proof_notify: None, proof_amount: None, proof_delivered: false, + receipt_sent: false, }); // Commit dedup markers now the receive is durable, BEFORE // the reply + sync tail. A crash there must not let this @@ -2184,16 +2224,15 @@ async fn handle_wrap(svc: &Arc, wallet: &Wallet, event: Event) { contact.last_paid_at = Some(unix_time()); svc.store.save_contact(&contact); } - // Proof-on-request delivery (frozen contract 4.3): this finalized + // Proof-on-request delivery (frozen contract 4.3.2): this finalized // SEND (our own payment) now holds a real, receiver-signed Grin - // payment proof. If it was sent in proof mode, publish the plain - // receipt + the encrypted proof delivery. On partial failure we - // leave proof_delivered=false so the reconcile pass retries. + // payment proof. Deliver the ENCRYPTED proof to the watcher here. + // The plain "payment sent" receipt is NOT (re)published at finalize: + // it already went out at S1 dispatch (4.3.1), gated by receipt_sent, + // so there is exactly one receipt per tx. On failure we leave + // proof_delivered=false so the reconcile pass retries. if let Some(mut m) = svc.store.tx_meta(&slate.id.to_string()) { - if m.direction == NostrTxDirection::Sent - && m.proof_mode && !m.proof_delivered - && deliver_proof(svc, wallet, &m).await - { + if proof_delivery_due(&m) && deliver_proof(svc, wallet, &m).await { m.proof_delivered = true; m.updated_at = unix_time(); svc.store.save_tx_meta(&m); @@ -2336,4 +2375,84 @@ mod tests { assert!(!expiry_locks_outputs(RequestedByUs, Created)); assert!(!expiry_locks_outputs(RequestedOfUs, ReceivedNoReply)); } + + /// A proof-mode SEND meta at a given lifecycle status, order context present, + /// no watcher target (the receipt-only shape). Callers tweak fields per case. + fn sample_send_meta(status: NostrSendStatus) -> TxNostrMeta { + TxNostrMeta { + ver: 1, + slate_id: "00000000-0000-0000-0000-000000000000".to_string(), + npub: "npub".to_string(), + direction: NostrTxDirection::Sent, + note: None, + status, + sent_event_id: None, + received_rumor_id: None, + created_at: 0, + updated_at: 0, + proof_mode: true, + proof_order: Some("MM-abcd".to_string()), + proof_notify: None, + proof_amount: Some(1_000), + proof_delivered: false, + receipt_sent: false, + } + } + + #[test] + fn receipt_at_dispatch_only_with_order_context() { + // With order context (proof mode + order handle) the receipt publishes at + // dispatch, the moment the UI flips to "sent". + assert!(receipt_due_at_dispatch(true, Some("MM-abcd"))); + // A person-to-person send carries no order: no receipt at all, ever. + assert!(!receipt_due_at_dispatch(false, None)); + assert!(!receipt_due_at_dispatch(false, Some("MM-abcd"))); + // Proof mode but an empty/blank order handle is not routable → no receipt. + assert!(!receipt_due_at_dispatch(true, None)); + assert!(!receipt_due_at_dispatch(true, Some(""))); + assert!(!receipt_due_at_dispatch(true, Some(" "))); + } + + #[test] + fn receipt_retry_gated_by_flag_no_duplicate() { + // Dispatched (envelope accepted, UI "sent") but receipt not landed → retry. + let m = sample_send_meta(NostrSendStatus::AwaitingS2); + assert!(receipt_retry_due(&m)); + // A finalized send may still owe an un-landed receipt → still retried. + let m = sample_send_meta(NostrSendStatus::Finalized); + assert!(receipt_retry_due(&m)); + // Once the receipt has landed, it is NEVER republished: the guard against + // a duplicate at finalize (and on every later reconcile pass). + let mut m = sample_send_meta(NostrSendStatus::Finalized); + m.receipt_sent = true; + assert!(!receipt_retry_due(&m)); + // Not yet dispatched (Created / SendFailed): the UI has not flipped to + // "sent", so nothing is published yet. + let m = sample_send_meta(NostrSendStatus::Created); + assert!(!receipt_retry_due(&m)); + let m = sample_send_meta(NostrSendStatus::SendFailed); + assert!(!receipt_retry_due(&m)); + // A non-proof (person-to-person) send never publishes a receipt. + let mut m = sample_send_meta(NostrSendStatus::AwaitingS2); + m.proof_mode = false; + assert!(!receipt_retry_due(&m)); + } + + #[test] + fn proof_delivery_only_at_finalize() { + // The proof does not exist before finalize, so it is due ONLY once + // finalized (never at dispatch/AwaitingS2). + let m = sample_send_meta(NostrSendStatus::Finalized); + assert!(proof_delivery_due(&m)); + let m = sample_send_meta(NostrSendStatus::AwaitingS2); + assert!(!proof_delivery_due(&m)); + // Already delivered → not retried. + let mut m = sample_send_meta(NostrSendStatus::Finalized); + m.proof_delivered = true; + assert!(!proof_delivery_due(&m)); + // Non-proof send delivers nothing. + let mut m = sample_send_meta(NostrSendStatus::Finalized); + m.proof_mode = false; + assert!(!proof_delivery_due(&m)); + } } diff --git a/src/nostr/ingest.rs b/src/nostr/ingest.rs index 8999ed2f..dc6e2fa3 100644 --- a/src/nostr/ingest.rs +++ b/src/nostr/ingest.rs @@ -172,6 +172,7 @@ mod tests { proof_notify: None, proof_amount: None, proof_delivered: false, + receipt_sent: false, } } diff --git a/src/nostr/types.rs b/src/nostr/types.rs index 1660af74..7c8ad694 100644 --- a/src/nostr/types.rs +++ b/src/nostr/types.rs @@ -102,10 +102,22 @@ pub struct TxNostrMeta { /// slate. #[serde(default)] pub proof_amount: Option, - /// Set once BOTH delivery events (plain receipt + gift-wrapped proof) have - /// been accepted by a relay. Until then the reconcile pass retries them. + /// Set once the encrypted proof delivery (frozen contract 4.3.2) has been + /// accepted by a relay. The plain receipt is tracked separately by + /// `receipt_sent`, since the two now publish at different lifecycle points + /// (receipt at dispatch, proof at finalize). Until then the reconcile pass + /// retries the proof delivery. #[serde(default)] pub proof_delivered: bool, + /// Set once the plain "payment sent" receipt (frozen contract 4.3.1) has been + /// accepted by a relay. Published at S1 DISPATCH, the moment the payment + /// envelope is accepted and the wallet UI flips to "sent", NOT at finalize, + /// so the buyer's order page loses its scannable QR the instant they pay and + /// the double-send window closes. One receipt per tx: this flag is the guard + /// against a duplicate at finalize. `false` for every ordinary (non-order) + /// send, which publishes no receipt at all. + #[serde(default)] + pub receipt_sent: bool, } /// A contact: another nostr user we can pay. @@ -192,6 +204,16 @@ pub struct NewsItem { pub lang: Option, } +/// Whether the plain "payment sent" receipt (frozen contract 4.3.1) is due at +/// S1 dispatch for this send. True only for an order-carrying send in proof +/// mode: a person-to-person send (no `order=` context) publishes no receipt at +/// all, at any lifecycle point. The receipt is the buyer's routing key back to +/// the market (the `payment-request` tag echoes the order handle), so without an +/// order handle there is nothing the market could match and nothing to publish. +pub fn receipt_due_at_dispatch(proof_mode: bool, order: Option<&str>) -> bool { + proof_mode && order.is_some_and(|o| !o.trim().is_empty()) +} + /// Current unix time in seconds. pub fn unix_time() -> i64 { std::time::SystemTime::now() diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index 4791bf94..0ad53b06 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -2539,6 +2539,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) { proof_notify: if proof_mode { notify.clone() } else { None }, proof_amount: if proof_mode { Some(*a) } else { None }, proof_delivered: false, + receipt_sent: false, }); let tx = w.retrieve_tx_by_id(None, Some(s.id)); w.send_creating.store(false, Ordering::Relaxed); @@ -2548,9 +2549,34 @@ async fn handle_task(w: &Wallet, t: WalletTask) { .await { Ok(event_id) => { + // Proof-on-request (frozen contract 4.3.1): publish the + // plain "payment sent" receipt HERE at S1 dispatch, the + // same moment the payment envelope was accepted by a relay + // and the wallet UI flips to "sent", not at finalize. This + // closes the buyer's double-send window: an offline merchant + // leaves finalize hours away, and until this receipt lands + // the order page still shows a scannable QR for a payment + // already made. The encrypted PROOF stays at finalize (it + // does not exist before then). On failure we leave + // receipt_sent=false so the reconcile pass retries it. + let mut receipt_sent = false; + if crate::nostr::receipt_due_at_dispatch( + proof_mode, + order.as_deref(), + ) { + let ord = order.as_deref().unwrap_or_default(); + match service.publish_receipt_sent(ord, *a).await { + Ok(()) => receipt_sent = true, + Err(e) => log::warn!( + "nostr: dispatch receipt publish failed for {}: {e}", + s.id + ), + } + } if let Some(mut meta) = service.store.tx_meta(&s.id.to_string()) { meta.status = crate::nostr::NostrSendStatus::AwaitingS2; meta.sent_event_id = Some(event_id); + meta.receipt_sent = receipt_sent; meta.updated_at = crate::nostr::unix_time(); service.store.save_tx_meta(&meta); } @@ -2653,6 +2679,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) { proof_notify: None, proof_amount: None, proof_delivered: false, + receipt_sent: false, }); let tx = w.retrieve_tx_by_id(None, Some(s.id)); w.invoice_creating.store(false, Ordering::Relaxed); @@ -2903,6 +2930,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) { proof_notify: None, proof_amount: None, proof_delivered: false, + receipt_sent: false, }); match service .send_payment_dm(&request.npub, &text, None, &[]) @@ -2952,6 +2980,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) { proof_notify: None, proof_amount: None, proof_delivered: false, + receipt_sent: false, }); match service .send_payment_dm(&request.npub, &text, None, &[])