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:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: "Доставка"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: "传输"
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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¬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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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, &[])
|
||||
|
||||
Reference in New Issue
Block a user