diff --git a/locales/de.yml b/locales/de.yml index 2e074f1f..8fd7db47 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -790,6 +790,8 @@ goblin: row_from: "Von" row_to: "An" row_note: "Notiz" + row_proof: "Zahlungsnachweis" + row_proof_val: "Enthalten" row_they_pay: "Sie zahlen" row_they_pay_val: "Nur wenn sie zustimmen" row_delivery: "Zustellung" diff --git a/locales/en.yml b/locales/en.yml index c51eee6b..db98e9f8 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -790,6 +790,8 @@ goblin: row_from: "From" row_to: "To" row_note: "Note" + row_proof: "Payment proof" + row_proof_val: "Included" row_they_pay: "They pay" row_they_pay_val: "Only if they approve" row_delivery: "Delivery" diff --git a/locales/fr.yml b/locales/fr.yml index f632f088..cd66d94d 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -790,6 +790,8 @@ goblin: row_from: "De" row_to: "À" row_note: "Note" + row_proof: "Preuve de paiement" + row_proof_val: "Incluse" row_they_pay: "Ils paient" row_they_pay_val: "Seulement s'ils approuvent" row_delivery: "Livraison" diff --git a/locales/ru.yml b/locales/ru.yml index 5c615e6a..dfedf524 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -790,6 +790,8 @@ goblin: row_from: "От" row_to: "Кому" row_note: "Заметка" + row_proof: "Подтверждение платежа" + row_proof_val: "Включено" row_they_pay: "Они платят" row_they_pay_val: "Только если они одобрят" row_delivery: "Доставка" diff --git a/locales/tr.yml b/locales/tr.yml index 3fa1c7ce..30fe1e52 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -790,6 +790,8 @@ goblin: row_from: "Gönderen" row_to: "Alıcı" row_note: "Not" + row_proof: "Ödeme kanıtı" + row_proof_val: "Dahil" row_they_pay: "Onlar öder" row_they_pay_val: "Yalnızca onaylarlarsa" row_delivery: "Teslimat" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 5c94d40c..e12133bb 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -790,6 +790,8 @@ goblin: row_from: "付款方" row_to: "收款方" row_note: "备注" + row_proof: "支付证明" + row_proof_val: "已包含" row_they_pay: "对方支付" row_they_pay_val: "仅当对方同意时" row_delivery: "传输" diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index d30a6776..0a0eb196 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -145,6 +145,13 @@ pub struct SendFlow { fee_requested_for: Option, /// Draft note held while the editor modal is open, so Cancel discards it. note_draft: String, + /// Proof-on-request context parsed from the pay URI (frozen contract 4.1). + /// `proof` is the merchant's grin1 proof address (presence => proof mode), + /// `order` the opaque invoice handle, `notify` the watcher npub. All `None` + /// for a normal send, so person-to-person payments carry nothing extra. + proof: Option, + order: Option, + notify: Option, } impl Default for SendFlow { @@ -171,6 +178,9 @@ impl Default for SendFlow { receipt_npub: None, fee_requested_for: None, note_draft: String::new(), + proof: None, + order: None, + notify: None, } } } @@ -229,6 +239,15 @@ impl SendFlow { /// handler so both spell the same destination. `now` seeds the search-box /// debounce. fn apply_scan(&mut self, text: &str, wallet: &Wallet, now: f64) { + // Proof-on-request context (frozen contract 4.1) rides alongside the + // recipient/amount/memo routing and is independent of whether we land on + // Review or Search, so pull it from the same pure parse. `order` is a + // non-editable routing key, never shown as the memo. Absent params leave + // proof mode off, so a normal payment is byte-identical to before. + let pay = crate::nostr::payuri::parse(text); + self.proof = pay.proof; + self.order = pay.order; + self.notify = pay.notify; match recognize_scan(text) { // GoblinPay checkout / full pay link: recipient key AND amount are // both known, so skip discovery and open review directly, prefilled. @@ -1202,6 +1221,17 @@ impl SendFlow { &format!("\u{201C}{}\u{201D}", self.note.trim()), ); } + // Proof-on-request indicator (frozen contract W3): a small, informational + // row shown only when this payment will return a receiver-signed Grin + // payment proof. Seeing it is the whole interaction: nothing to accept or + // configure. Absent for every ordinary send. + if self.proof.is_some() && !self.request { + w::info_row( + ui, + &t!("goblin.send.row_proof"), + &t!("goblin.send.row_proof_val"), + ); + } if self.request { w::info_row( ui, @@ -1324,6 +1354,9 @@ impl SendFlow { recipient.npub.clone(), note, recipient.relay_hints.clone(), + self.proof.clone(), + self.order.clone(), + self.notify.clone(), )); } } diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 8a3246ab..07b748e5 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -593,6 +593,89 @@ impl NostrService { .await } + /// Publish the plain "payment sent" receipt (frozen contract 4.3.1): a + /// buyer-signed, UNENCRYPTED kind-17 to our app relays that flips the order + /// page to "payment detected, confirming". It is buyer-signed and unverified, + /// so the market NEVER treats it as "paid" (only the watcher's 4.4 event + /// does). The proof and kernel excess are DELIBERATELY omitted here: a Grin + /// payment proof carries the buyer's own slatepack address, and publishing it + /// in the clear would leak the buyer's wallet address to the world. + pub async fn publish_receipt_sent(&self, order: &str, amount: u64) -> Result<(), String> { + let client = { + let r_client = self.client.read(); + r_client.clone().ok_or("nostr client is not running")? + }; + let tags = vec![ + Tag::custom(TagKind::custom("payment-request"), [order.to_string()]), + Tag::custom( + TagKind::custom("payment"), + ["grin".to_string(), order.to_string(), String::new()], + ), + Tag::custom(TagKind::custom("amount"), [amount.to_string()]), + Tag::custom(TagKind::custom("status"), ["sent".to_string()]), + Tag::custom( + TagKind::custom(protocol::GOBLIN_TAG), + [protocol::PROTOCOL_VERSION.to_string()], + ), + ]; + let builder = EventBuilder::new(Kind::Custom(17), "Payment sent").tags(tags); + let event = client + .sign_event_builder(builder) + .await + .map_err(|e| format!("receipt sign failed: {e}"))?; + let urls: Vec = self.relays(); + match tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(&urls, &event)).await { + Ok(Ok(_)) => Ok(()), + Ok(Err(e)) => Err(format!("receipt publish failed: {e}")), + Err(_) => Err("receipt publish timeout".to_string()), + } + } + + /// Gift-wrap the full proof delivery (frozen contract 4.3.2) to the watcher's + /// npub: a kind-17 rumor whose content is the Grin payment proof JSON verbatim, + /// tagged with the invoice number, the amount, and the kernel excess. Encrypted + /// end to end, so the proof (which contains the buyer's sender address) never + /// goes out in the clear; only the addressed watcher can read it. + pub async fn deliver_proof_wrap( + &self, + notify_npub: &str, + order: &str, + amount: u64, + kernel_hex: &str, + proof_json: &str, + ) -> Result<(), String> { + let client = { + let r_client = self.client.read(); + r_client.clone().ok_or("nostr client is not running")? + }; + let receiver = + PublicKey::from_bech32(notify_npub).map_err(|e| format!("invalid notify npub: {e}"))?; + let tags = vec![ + Tag::custom(TagKind::custom("payment-request"), [order.to_string()]), + Tag::custom(TagKind::custom("amount"), [amount.to_string()]), + Tag::custom(TagKind::custom("kernel"), [kernel_hex.to_string()]), + Tag::custom(TagKind::custom("status"), ["proof".to_string()]), + Tag::custom( + TagKind::custom(protocol::GOBLIN_TAG), + [protocol::PROTOCOL_VERSION.to_string()], + ), + ]; + let wrap = wrapv3::wrap_kind( + &self.keys, + &receiver, + Kind::Custom(17), + proof_json.to_string(), + tags, + )?; + let urls: Vec = self.relays(); + connect_relays(&client, &urls).await; + match tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(&urls, &wrap)).await { + Ok(Ok(_)) => Ok(()), + Ok(Err(e)) => Err(format!("proof delivery failed: {e}")), + Err(_) => Err("proof delivery timeout".to_string()), + } + } + /// Dispatch one gift-wrapped DM over the negotiated encryption: when the /// recipient advertises `nip44_v3` the wrap is built by [`wrapv3::wrap`], /// otherwise it goes through the unchanged nostr-sdk v2 path (best mutual @@ -1449,6 +1532,73 @@ 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 { + 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. + warn!( + "nostr: proof mode without order handle for {}, skipping 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 + } + }; + + // 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 + ); + false + } + } + } + }; + + receipt_ok && wrap_ok +} + /// Re-dispatch our pending outgoing messages (crash/offline recovery). async fn reconcile(svc: &Arc, wallet: &Wallet) { let now = unix_time(); @@ -1456,6 +1606,23 @@ 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 + { + if deliver_proof(svc, wallet, &meta).await { + let mut updated = meta.clone(); + updated.proof_delivered = true; + updated.updated_at = unix_time(); + svc.store.save_tx_meta(&updated); + } + continue; + } let resend_state = match (meta.direction, meta.status) { // S1 never dispatched or failed. (NostrTxDirection::Sent, NostrSendStatus::Created) @@ -1915,6 +2082,11 @@ async fn handle_wrap(svc: &Arc, wallet: &Wallet, event: Event) { received_rumor_id: Some(rumor_id.clone()), created_at: now, updated_at: now, + proof_mode: false, + proof_order: None, + proof_notify: None, + proof_amount: None, + proof_delivered: false, }); // Commit dedup markers now the receive is durable, BEFORE // the reply + sync tail. A crash there must not let this @@ -2012,6 +2184,21 @@ 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 + // 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. + 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 + { + m.proof_delivered = true; + m.updated_at = unix_time(); + svc.store.save_tx_meta(&m); + } + } wallet.sync(); } Ok(false) => { diff --git a/src/nostr/ingest.rs b/src/nostr/ingest.rs index 13006699..8999ed2f 100644 --- a/src/nostr/ingest.rs +++ b/src/nostr/ingest.rs @@ -167,6 +167,11 @@ mod tests { received_rumor_id: None, created_at: unix_time(), updated_at: unix_time(), + proof_mode: false, + proof_order: None, + proof_notify: None, + proof_amount: None, + proof_delivered: false, } } diff --git a/src/nostr/payuri.rs b/src/nostr/payuri.rs index b4bfd8fe..146e213f 100644 --- a/src/nostr/payuri.rs +++ b/src/nostr/payuri.rs @@ -42,6 +42,10 @@ use grin_core::core::amount_from_hr_string; const MAX_URI_LEN: usize = 4096; /// Memo byte cap (post control-strip), display / tx-message only. const MAX_MEMO_BYTES: usize = 256; +/// Order-handle byte cap (post percent-decode + control-strip). The order +/// param is an opaque routing key (magick's `MM-` invoice number), never +/// free text, so it is capped tight per the frozen contract (section 4.1). +const MAX_ORDER_BYTES: usize = 64; /// Schemes that unlock amount/memo parsing. `goblin:` is Goblin's registered /// deep-link scheme (web "Open in Goblin" buttons, so the OS opens the wallet); @@ -58,6 +62,21 @@ pub struct PayUri { pub recipient: String, pub amount: Option, pub memo: Option, + /// Proof-on-request: the merchant's Grin slatepack (`grin1`/`tgrin1`) proof + /// address. Presence turns proof mode ON for this one transaction; the value + /// is threaded verbatim as `payment_proof_recipient_address` on the send. + /// Fail-closed: an unparseable value is dropped to `None` (a proof-less + /// send), never blocking the payment. Frozen contract section 4.1. + pub proof: Option, + /// The opaque order handle (magick's `MM-` invoice number) the wallet + /// echoes verbatim into the delivery events' `payment-request` tag. Distinct + /// from `memo`: `order` is a non-editable routing key, `memo` is editable + /// display text. Dropped if empty after sanitization. + pub order: Option, + /// The watcher's Nostr pubkey (`npub…`) the wallet gift-wraps the full proof + /// delivery to. Dropped if it is not a valid npub; absence simply means no + /// encrypted delivery target (the plain receipt still publishes). + pub notify: Option, } impl PayUri { @@ -67,6 +86,9 @@ impl PayUri { recipient, amount: None, memo: None, + proof: None, + order: None, + notify: None, } } } @@ -101,6 +123,9 @@ pub fn parse(scanned: &str) -> PayUri { let mut amount = None; let mut memo = None; + let mut proof = None; + let mut order = None; + let mut notify = None; if let Some(query) = query { for pair in query.split('&') { let Some((key, val)) = pair.split_once('=') else { @@ -111,6 +136,12 @@ pub fn parse(scanned: &str) -> PayUri { // second `amount=` can't override a validated one. "amount" if amount.is_none() => amount = validate_amount(val), "memo" if memo.is_none() => memo = validate_memo(val), + // Proof-on-request params (frozen contract section 4.1). Each is + // fail-closed: an invalid value is dropped to `None`, degrading to + // a normal proof-less payment rather than blocking the send. + "proof" if proof.is_none() => proof = validate_proof(val), + "order" if order.is_none() => order = validate_order(val), + "notify" if notify.is_none() => notify = validate_notify(val), // Unknown params are ignored for forward-compat. _ => {} } @@ -121,6 +152,9 @@ pub fn parse(scanned: &str) -> PayUri { recipient, amount, memo, + proof, + order, + notify, } } @@ -176,6 +210,81 @@ fn validate_memo(raw: &str) -> Option { if text.is_empty() { None } else { Some(text) } } +/// Validate a `proof` value: percent-decode, then accept it ONLY if it has the +/// shape of a Grin slatepack address (`grin1…`/`tgrin1…`, bech32 charset, long +/// enough to be a real key). This is a fail-closed SHAPE check; the send path +/// re-parses it authoritatively via `SlatepackAddress::try_from` and drops it +/// again if that fails, so an almost-valid string can never turn into a bad +/// proof recipient. Returns the clean decoded address on success. +fn validate_proof(raw: &str) -> Option { + let decoded = String::from_utf8_lossy(&percent_decode(raw)).into_owned(); + let decoded = decoded.trim(); + if looks_like_slatepack_address(decoded) { + Some(decoded.to_string()) + } else { + None + } +} + +/// Loose shape test for a Grin slatepack address: a `grin1`/`tgrin1` bech32 +/// human-readable prefix followed by a bech32-charset data part of plausible +/// length. Mirrors magick's `isValidGoblinPayAddress` grin1 arm; authoritative +/// decode happens at send time. +fn looks_like_slatepack_address(s: &str) -> bool { + let lower = s.to_ascii_lowercase(); + let data = if let Some(d) = lower.strip_prefix("tgrin1") { + d + } else if let Some(d) = lower.strip_prefix("grin1") { + d + } else { + return false; + }; + // bech32 data charset excludes `1`, `b`, `i`, `o`; a real key is well over + // 20 data chars. We only guard obvious garbage here. + data.len() >= 20 + && data + .chars() + .all(|c| c.is_ascii_alphanumeric() && !matches!(c, '1' | 'b' | 'i' | 'o')) +} + +/// Validate an `order` value: percent-decode, strip ASCII control chars (it is a +/// routing key, never display text or a path), hard-cap at [`MAX_ORDER_BYTES`] +/// on a UTF-8 boundary, trim. Empty → `None`. The wallet echoes this verbatim +/// into the `payment-request` tag of every delivery event, so it must survive +/// the round trip unchanged. +fn validate_order(raw: &str) -> Option { + let decoded = percent_decode(raw); + let cleaned: Vec = decoded + .into_iter() + .filter(|&b| b >= 0x20 && b != 0x7f) + .collect(); + let text = String::from_utf8_lossy(&cleaned).into_owned(); + let text = truncate_on_char_boundary(text, MAX_ORDER_BYTES); + let text = text.trim().to_string(); + if text.is_empty() { None } else { Some(text) } +} + +/// Validate a `notify` value: percent-decode, then accept it ONLY if it has the +/// shape of an `npub` (bech32 `npub1…`). Fail-closed SHAPE check; the delivery +/// path re-decodes it authoritatively via `PublicKey::from_bech32` and drops it +/// again on failure. Returns the clean decoded npub on success. +fn validate_notify(raw: &str) -> Option { + let decoded = String::from_utf8_lossy(&percent_decode(raw)).into_owned(); + let decoded = decoded.trim(); + let lower = decoded.to_ascii_lowercase(); + if let Some(data) = lower.strip_prefix("npub1") { + // A bech32-encoded 32-byte key is ~59 data chars; guard obvious garbage. + if data.len() >= 50 + && data + .chars() + .all(|c| c.is_ascii_alphanumeric() && !matches!(c, '1' | 'b' | 'i' | 'o')) + { + return Some(decoded.to_string()); + } + } + None +} + /// Truncate a string to at most `max` bytes without splitting a UTF-8 char. fn truncate_on_char_boundary(s: String, max: usize) -> String { if s.len() <= max { @@ -443,4 +552,124 @@ mod tests { assert_eq!(smallest.amount.as_deref(), Some("0.000000001")); assert_eq!(smallest.memo.as_deref(), Some("MM-ABC123")); } + + // --- proof-on-request params (frozen contract section 4.1) -------------- + + /// A shape-valid grin1 slatepack address (charset excludes 1/b/i/o). + const GRIN1: &str = "grin1qqvqzqzpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz"; + /// A shape-valid npub (the Goblin news key). + const NPUB: &str = "npub15gsytqvs5c78u83yv2agl4twjkk6qgem7gtwe2agu7s90tkelxys0xxely"; + + #[test] + fn parses_all_three_proof_params() { + let uri = format!( + "nostr:{NPROFILE}?amount=1.5&memo=Coffee&proof={GRIN1}&order=MM-ABC123¬ify={NPUB}" + ); + let out = parse(&uri); + assert_eq!(out.recipient, NPROFILE); + assert_eq!(out.amount.as_deref(), Some("1.5")); + assert_eq!(out.memo.as_deref(), Some("Coffee")); + assert_eq!(out.proof.as_deref(), Some(GRIN1)); + assert_eq!(out.order.as_deref(), Some("MM-ABC123")); + assert_eq!(out.notify.as_deref(), Some(NPUB)); + } + + #[test] + fn goblin_scheme_carries_proof_params() { + // The clickable `goblin:` deep link must parse identically to `nostr:`. + let query = format!("amount=2&proof={GRIN1}&order=MM-1¬ify={NPUB}"); + let nostr = parse(&format!("nostr:{NPROFILE}?{query}")); + let goblin = parse(&format!("goblin:{NPROFILE}?{query}")); + assert_eq!(goblin, nostr); + assert_eq!(goblin.proof.as_deref(), Some(GRIN1)); + } + + #[test] + fn proof_absent_leaves_none() { + // A normal magick / p2p payment carries no proof params: all three None. + let out = parse(&format!("nostr:{NPROFILE}?amount=1&memo=MM-1")); + assert_eq!(out.proof, None); + assert_eq!(out.order, None); + assert_eq!(out.notify, None); + } + + #[test] + fn tgrin1_proof_accepted() { + let tgrin1 = format!("t{GRIN1}"); + let out = parse(&format!("nostr:{NPROFILE}?proof={tgrin1}")); + assert_eq!(out.proof.as_deref(), Some(tgrin1.as_str())); + } + + #[test] + fn bad_proof_dropped_fail_closed() { + // Not a slatepack address (wrong hrp, too short, or plain garbage) → the + // param is dropped and the payment degrades to a normal proof-less send. + for bad in [ + "npub1abcdef", + "grin1", // hrp only, no data + "grin1short", // data too short + "bc1qxyz", // wrong network + "notanaddress", + "", + ] { + let out = parse(&format!("nostr:{NPROFILE}?amount=1&proof={bad}")); + assert_eq!(out.proof, None, "expected {bad:?} proof to be dropped"); + // Dropping proof never blocks the rest of the payment. + assert_eq!(out.amount.as_deref(), Some("1")); + } + } + + #[test] + fn order_is_control_stripped_and_capped() { + // Percent-encoded control chars are removed; a routing key survives verbatim. + let out = parse(&format!("nostr:{NPROFILE}?order=MM-A%00B%0AC")); + assert_eq!(out.order.as_deref(), Some("MM-ABC")); + // Over-cap orders truncate at 64 bytes. + let long = "M".repeat(200); + let out = parse(&format!("nostr:{NPROFILE}?order={long}")); + assert_eq!(out.order.as_deref().map(|s| s.len()), Some(64)); + } + + #[test] + fn empty_order_dropped() { + assert_eq!(parse(&format!("nostr:{NPROFILE}?order=")).order, None); + // Whitespace-only after decode is also empty. + assert_eq!(parse(&format!("nostr:{NPROFILE}?order=%20%20")).order, None); + } + + #[test] + fn bad_notify_dropped_fail_closed() { + for bad in [ + "nprofile1abc", + "npub1", + "hex0123", + "grin1qqqqqqqqqqqqqqqqqqqqqq", + "", + ] { + let out = parse(&format!("nostr:{NPROFILE}?amount=1¬ify={bad}")); + assert_eq!(out.notify, None, "expected {bad:?} notify to be dropped"); + assert_eq!(out.amount.as_deref(), Some("1")); + } + } + + #[test] + fn proof_params_first_occurrence_wins() { + let out = parse(&format!( + "nostr:{NPROFILE}?order=MM-1&order=MM-EVIL&proof={GRIN1}&proof=grin1short" + )); + assert_eq!(out.order.as_deref(), Some("MM-1")); + assert_eq!(out.proof.as_deref(), Some(GRIN1)); + } + + #[test] + fn old_wallet_forward_compat_unaffected() { + // The pre-existing recipient/amount/memo contract is unchanged when the + // new params ride alongside (a shipped old wallet just ignores them). + let out = parse(&format!( + "nostr:{NPROFILE}?amount=1.5&memo=MM-1&proof={GRIN1}&order=MM-1¬ify={NPUB}" + )); + assert_eq!(out.recipient, NPROFILE); + assert_eq!(out.amount.as_deref(), Some("1.5")); + assert_eq!(out.memo.as_deref(), Some("MM-1")); + } } diff --git a/src/nostr/types.rs b/src/nostr/types.rs index ef4fcb25..1660af74 100644 --- a/src/nostr/types.rs +++ b/src/nostr/types.rs @@ -81,6 +81,31 @@ pub struct TxNostrMeta { pub received_rumor_id: Option, pub created_at: i64, pub updated_at: i64, + /// Proof-on-request (frozen contract section 4.3): a proof address was + /// threaded into this send, so on finalize the wallet must produce and + /// deliver a real Grin payment proof. `false` for every ordinary send, so a + /// person-to-person payment carries and delivers nothing extra. + #[serde(default)] + pub proof_mode: bool, + /// The opaque order handle (the `order=` URI param, magick's `MM-` + /// invoice number). Echoed verbatim into the delivery events' + /// `payment-request` tag. The wallet never learns magick's internal orderId. + #[serde(default)] + pub proof_order: Option, + /// The watcher's npub (the `notify=` URI param): the gift-wrap target for the + /// full proof delivery. `None` = no encrypted delivery target (plain receipt + /// still publishes). + #[serde(default)] + pub proof_notify: Option, + /// The payment amount in integer nanogrin, persisted so the crash-safe + /// reconcile pass can rebuild the delivery-event `amount` tag without the + /// 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. + #[serde(default)] + pub proof_delivered: bool, } /// A contact: another nostr user we can pay. diff --git a/src/nostr/wrapv3.rs b/src/nostr/wrapv3.rs index 1dc258c3..92ad0851 100644 --- a/src/nostr/wrapv3.rs +++ b/src/nostr/wrapv3.rs @@ -73,9 +73,31 @@ pub fn wrap( content: String, rumor_extra_tags: Vec, ) -> Result { - // Rumor: kind 14, receiver p-tag first, then the extra tags — the same - // shape `EventBuilder::private_msg_rumor` builds. Never signed (NIP-59). - let mut rumor: UnsignedEvent = EventBuilder::new(Kind::PrivateDirectMessage, content) + wrap_kind( + sender, + receiver, + Kind::PrivateDirectMessage, + content, + rumor_extra_tags, + ) +} + +/// Build a NIP-59 gift wrap around a rumor of an ARBITRARY `rumor_kind`, +/// encrypted with NIP-44 v3. `wrap` is the kind-14 private-message case; the +/// proof-on-request delivery (frozen contract 4.3.2) uses this with a kind-17 +/// rumor to hand the watcher the full proof JSON privately. The watcher unwraps +/// with the same `unwrap` below (it reuses the wallet's crypto), so v3 here is +/// consistent end to end. +pub fn wrap_kind( + sender: &Keys, + receiver: &PublicKey, + rumor_kind: Kind, + content: String, + rumor_extra_tags: Vec, +) -> Result { + // Rumor: receiver p-tag first, then the extra tags, the same shape + // `EventBuilder::private_msg_rumor` builds. Never signed (NIP-59). + let mut rumor: UnsignedEvent = EventBuilder::new(rumor_kind, content) .tag(Tag::public_key(*receiver)) .tags(rumor_extra_tags) .build(sender.public_key()); diff --git a/src/wallet/types.rs b/src/wallet/types.rs index c8de83fd..8333837a 100644 --- a/src/wallet/types.rs +++ b/src/wallet/types.rs @@ -433,7 +433,20 @@ pub enum WalletTask { /// * amount /// * receiver public key (hex) /// * optional note (subject line) - NostrSend(u64, String, Option, Vec), + /// * relay hints + /// * optional proof address (grin1/tgrin1): proof-on-request turns ON when + /// present (frozen contract W2); threaded as `payment_proof_recipient_address` + /// * optional order handle (the `order=` URI param, echoed into delivery events) + /// * optional watcher npub (the `notify=` URI param, gift-wrap target) + NostrSend( + u64, + String, + Option, + Vec, + Option, + Option, + Option, + ), /// Re-dispatch the pending nostr message for transaction. /// * tx id NostrResend(u32), diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index 51bbf238..5376e6fb 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -1871,6 +1871,23 @@ impl Wallet { Ok(proof) } + /// Retrieve a finalized send's payment proof as the `(json, kernel_excess_hex)` + /// pair the proof-on-request delivery needs (frozen contract 4.3.2). `json` is + /// the owner API's verbatim serialization of the proof + /// (`{amount, excess, recipient_address, recipient_sig, sender_address, + /// sender_sig}`); `kernel_excess_hex` is a convenience copy of the excess so + /// the watcher can query the chain before parsing the proof. `None` when no + /// proof exists yet (e.g. a proof-less send, or called before finalize). + pub fn payment_proof_delivery(&self, slate_id: Uuid) -> Option<(String, String)> { + let proof = self + .get_payment_proof(None, Some(slate_id)) + .ok() + .flatten()?; + let json = serde_json::to_string(&proof).ok()?; + let kernel_hex = proof.excess.to_hex(); + Some((json, kernel_hex)) + } + /// Verify payment proof. fn verify_payment_proof(&self, proof: &PaymentProof) -> Result<(u32, bool, bool), Error> { let r_inst = self.instance.as_ref().read(); @@ -2422,19 +2439,31 @@ async fn handle_task(w: &Wallet, t: WalletTask) { w.on_tx_error(*id, Some(e)); } }, - WalletTask::NostrSend(a, receiver, note, relay_hints) => { + WalletTask::NostrSend(a, receiver, note, relay_hints, proof, order, notify) => { let Some(service) = w.nostr_service() else { error!("nostr send: service not available"); return; }; w.send_creating.store(true, Ordering::Relaxed); service.set_send_phase(crate::nostr::send_phase::WORKING); - match w.send(*a, None) { + // Proof-on-request (frozen contract W2): when the payment context + // carries a `proof=` slatepack address, re-parse it authoritatively + // here (a second, fail-closed gate after the URI parser's shape check) + // and thread it as the native `payment_proof_recipient_address`. Absent + // or unparseable → `None`, a normal proof-less send. This is the ONLY + // path that sets a proof recipient over Nostr. + let proof_addr: Option = proof + .as_deref() + .and_then(|p| SlatepackAddress::try_from(p.trim()).ok()); + let proof_mode = proof_addr.is_some(); + match w.send(*a, proof_addr) { Ok(s) => { sync_wallet_data(&w, false); let now = crate::nostr::unix_time(); // Record intent BEFORE the network dispatch so a crash - // is recovered by the service reconcile pass. + // is recovered by the service reconcile pass. The proof context + // (order handle + watcher npub + amount) is persisted now so a + // crash between send and finalize loses nothing. service.store.save_tx_meta(&crate::nostr::TxNostrMeta { ver: 1, slate_id: s.id.to_string(), @@ -2446,6 +2475,11 @@ async fn handle_task(w: &Wallet, t: WalletTask) { received_rumor_id: None, created_at: now, updated_at: now, + proof_mode, + proof_order: if proof_mode { order.clone() } else { None }, + proof_notify: if proof_mode { notify.clone() } else { None }, + proof_amount: if proof_mode { Some(*a) } else { None }, + proof_delivered: false, }); let tx = w.retrieve_tx_by_id(None, Some(s.id)); w.send_creating.store(false, Ordering::Relaxed); @@ -2553,6 +2587,13 @@ async fn handle_task(w: &Wallet, t: WalletTask) { received_rumor_id: None, created_at: now, updated_at: now, + // Grin's invoice flow carries no payment proofs (Fact 3), so a + // request we issued never enters proof mode. + proof_mode: false, + proof_order: None, + proof_notify: None, + proof_amount: None, + proof_delivered: false, }); let tx = w.retrieve_tx_by_id(None, Some(s.id)); w.invoice_creating.store(false, Ordering::Relaxed); @@ -2798,6 +2839,11 @@ async fn handle_task(w: &Wallet, t: WalletTask) { received_rumor_id: Some(request.rumor_id.clone()), created_at: now, updated_at: now, + proof_mode: false, + proof_order: None, + proof_notify: None, + proof_amount: None, + proof_delivered: false, }); match service .send_payment_dm(&request.npub, &text, None, &[]) @@ -2842,6 +2888,11 @@ async fn handle_task(w: &Wallet, t: WalletTask) { received_rumor_id: Some(request.rumor_id.clone()), created_at: now, updated_at: now, + proof_mode: false, + proof_order: None, + proof_notify: None, + proof_amount: None, + proof_delivered: false, }); match service .send_payment_dm(&request.npub, &text, None, &[])