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.
This commit is contained in:
2ro
2026-07-05 05:53:00 -04:00
parent a437aad2f8
commit e9f0c3f0e2
4 changed files with 243 additions and 72 deletions
+189 -70
View File
@@ -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<NostrService>, 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<NostrService>, 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<NostrService>, 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(&notify, &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<NostrService>, 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<NostrService>, 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<NostrService>, 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));
}
}
+1
View File
@@ -172,6 +172,7 @@ mod tests {
proof_notify: None,
proof_amount: None,
proof_delivered: false,
receipt_sent: false,
}
}
+24 -2
View File
@@ -102,10 +102,22 @@ pub struct TxNostrMeta {
/// slate.
#[serde(default)]
pub proof_amount: Option<u64>,
/// 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<String>,
}
/// 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()
+29
View File
@@ -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, &[])