1
0
forked from GRIN/grim

Goblin: proof-on-request payments (wallet side of the frozen contract)

Implements the Goblin-wallet half of the proof-on-request architecture
(magick payment infra spec section 4/5): the buyer's wallet threads a
native Grin payment proof per transaction, off by default and on only
when the scanned pay URI asks for it, and auto-delivers the proof to the
market and watcher on finalize. Person-to-person sends are unchanged.

W1 pay-URI parser (payuri.rs): PayUri gains proof/order/notify, each
fail-closed (a bad value drops to None and never blocks the payment).
proof is a grin1/tgrin1 slatepack-address shape check, order is an opaque
control-stripped routing key capped at 64 bytes, notify is an npub shape
check. Unknown-param forward-compat and the magick interop tests still
hold. 12 new parser tests.

W2 proof threading: WalletTask::NostrSend carries proof/order/notify; the
handler re-parses proof authoritatively into a SlatepackAddress and calls
w.send(a, Some(addr)) instead of the hard-coded None, setting
payment_proof_recipient_address. The order handle, watcher npub, and
amount persist in TxNostrMeta (new serde-default fields) before dispatch,
so a crash between send and finalize loses nothing.

W3 review indicator: the send-review screen shows a small "proof included"
row when proof mode is active. New t!() keys row_proof / row_proof_val in
all six locales (drift test green).

W4 proof delivery on finalize: when a finalized SEND was sent in proof
mode, the wallet publishes the two contract events: a plain unencrypted
kind-17 "payment sent" receipt to the app relays (payment-request tag =
order handle; proof and kernel deliberately omitted to avoid leaking the
buyer's sender address; flips the page to "detected", never "paid"), and
a NIP-59 gift-wrapped kind-17 rumor to the notify npub carrying the full
proof JSON plus a kernel-excess tag. Both are enrolled in the existing
crash-safe reconcile pass and retried until a relay accepts them.
wrapv3 gains wrap_kind() to gift-wrap an arbitrary rumor kind.

Matching is by the payment-request (invoice number) tag alone; the wallet
never learns magick's internal orderId. No merchant-side auto-receipt is
built: the rewritten spec has the buyer publish on finalize instead.
This commit is contained in:
2ro
2026-07-05 03:00:13 -04:00
parent 9caa7f7ffe
commit 5352afee4e
14 changed files with 584 additions and 7 deletions
+2
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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: "Доставка"
+2
View File
@@ -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"
+2
View File
@@ -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: "传输"
+33
View File
@@ -145,6 +145,13 @@ pub struct SendFlow {
fee_requested_for: Option<u64>,
/// 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<String>,
order: Option<String>,
notify: Option<String>,
}
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(),
));
}
}
+187
View File
@@ -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<String> = 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<String> = 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<NostrService>, 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<NostrService>, wallet: &Wallet) {
let now = unix_time();
@@ -1456,6 +1606,23 @@ 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
{
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<NostrService>, 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<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
// 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) => {
+5
View File
@@ -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,
}
}
+229
View File
@@ -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-<hex>` 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<String>,
pub memo: Option<String>,
/// 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<String>,
/// The opaque order handle (magick's `MM-<hex>` 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<String>,
/// 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<String>,
}
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<String> {
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<String> {
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<String> {
let decoded = percent_decode(raw);
let cleaned: Vec<u8> = 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<String> {
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&notify={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&notify={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&notify={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&notify={NPUB}"
));
assert_eq!(out.recipient, NPROFILE);
assert_eq!(out.amount.as_deref(), Some("1.5"));
assert_eq!(out.memo.as_deref(), Some("MM-1"));
}
}
+25
View File
@@ -81,6 +81,31 @@ pub struct TxNostrMeta {
pub received_rumor_id: Option<String>,
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-<hex>`
/// 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<String>,
/// 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<String>,
/// 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<u64>,
/// 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.
+25 -3
View File
@@ -73,9 +73,31 @@ pub fn wrap(
content: String,
rumor_extra_tags: Vec<Tag>,
) -> Result<Event, String> {
// 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<Tag>,
) -> Result<Event, String> {
// 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());
+14 -1
View File
@@ -433,7 +433,20 @@ pub enum WalletTask {
/// * amount
/// * receiver public key (hex)
/// * optional note (subject line)
NostrSend(u64, String, Option<String>, Vec<String>),
/// * 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<String>,
Vec<String>,
Option<String>,
Option<String>,
Option<String>,
),
/// Re-dispatch the pending nostr message for transaction.
/// * tx id
NostrResend(u32),
+54 -3
View File
@@ -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<SlatepackAddress> = 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, &[])