diff --git a/crates/gp-server/src/checkout.rs b/crates/gp-server/src/checkout.rs index a5a841f..ba691fe 100644 --- a/crates/gp-server/src/checkout.rs +++ b/crates/gp-server/src/checkout.rs @@ -48,7 +48,16 @@ pub fn build_info(inv: &Invoice, cfg: &Config) -> CheckoutInfo { Ok(pk) => (gp_nostr::npub_of(pk), gp_nostr::nprofile(pk, &relays)), Err(_) => (String::new(), String::new()), }; - let qr_svg = qr::svg(&nprofile, cfg.qr_logo_href()).unwrap_or_default(); + // The QR carries a pay-URI so a scanning wallet can auto-fill the amount + // (and memo). The human-readable nprofile/npub strings on the page are + // unchanged — only the QR payload gains the query. An invalid pubkey yields + // an empty nprofile; keep that empty (no useless `nostr:` QR). + let qr_payload = if nprofile.is_empty() { + nprofile.clone() + } else { + pay_uri(&nprofile, inv) + }; + let qr_svg = qr::svg(&qr_payload, cfg.qr_logo_href()).unwrap_or_default(); let amount_display = amount_display(inv); let token = inv.token.clone().unwrap_or_default(); CheckoutInfo { @@ -86,6 +95,53 @@ fn amount_display(inv: &Invoice) -> String { } } +/// Build the QR pay-URI for an invoice: `nostr:`, plus `?amount=` +/// when the invoice has an exact expected amount, plus `&memo=` when it carries +/// a human memo. A scanning Goblin wallet auto-fills the amount (and note) from +/// this; open-amount invoices stay a bare `nostr:`. +/// +/// The URI never carries the invoice token or any key — only the already-public +/// recipient nprofile, relay hints, the amount, and the human memo shown on the +/// page. `expected_amount` is a locked nanogrin quote (i64 in the DB, always +/// non-negative here); only strictly positive amounts are emitted. +fn pay_uri(nprofile: &str, inv: &Invoice) -> String { + let mut uri = format!("nostr:{nprofile}"); + let mut sep = '?'; + if let Some(nano) = inv.expected_amount { + if nano > 0 { + uri.push(sep); + uri.push_str("amount="); + uri.push_str(&nanogrin_to_grin(nano as u64)); + sep = '&'; + } + } + if let Some(memo) = inv.memo.as_deref() { + let memo = memo.trim(); + if !memo.is_empty() { + uri.push(sep); + uri.push_str("memo="); + uri.push_str(&percent_encode(memo)); + } + } + uri +} + +/// Minimal RFC-3986 percent-encoding for a query value: keep the unreserved set +/// (`A-Z a-z 0-9 - . _ ~`), percent-escape every other byte. Small and +/// dependency-free (gp-core has no percent-encoding crate). +fn percent_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for &b in s.as_bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + /// The checkout page template. #[derive(Template)] #[template(path = "pay.html")] @@ -243,3 +299,93 @@ fn render(page: T) -> HttpResponse { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal invoice fixture: only the fields the QR pay-URI reads matter. + fn invoice(expected_amount: Option, memo: Option<&str>) -> Invoice { + Invoice { + id: "inv_1".into(), + order_ref: None, + expected_amount, + expiry: None, + status: "open".into(), + created_at: "2026-01-01T00:00:00Z".into(), + token: Some("secret-token-should-never-leak".into()), + memo: memo.map(str::to_string), + recipient_pubkey: Some("aa".repeat(32)), + fiat_amount: None, + fiat_currency: None, + match_mode: None, + paid_payment_id: None, + paid_at: None, + quote_rate: None, + quote_source: None, + } + } + + #[test] + fn amount_invoice_encodes_amount() { + // 1.5 GRIN → nostr:?amount=1.5 + let inv = invoice(Some(1_500_000_000), None); + assert_eq!( + pay_uri("nprofile1abc", &inv), + "nostr:nprofile1abc?amount=1.5" + ); + } + + #[test] + fn open_amount_invoice_stays_bare() { + // Open amount (no expected_amount, no memo) → bare nostr:. + let inv = invoice(None, None); + assert_eq!(pay_uri("nprofile1abc", &inv), "nostr:nprofile1abc"); + } + + #[test] + fn amount_and_memo_encoded() { + let inv = invoice(Some(1_000_000_000), Some("Coffee & cake")); + assert_eq!( + pay_uri("nprofile1abc", &inv), + "nostr:nprofile1abc?amount=1&memo=Coffee%20%26%20cake" + ); + } + + #[test] + fn memo_only_uses_question_mark() { + // No amount but a memo → the memo is the first (and only) query param. + let inv = invoice(None, Some("hi")); + assert_eq!(pay_uri("nprofile1abc", &inv), "nostr:nprofile1abc?memo=hi"); + } + + #[test] + fn zero_and_blank_are_treated_as_open() { + assert_eq!( + pay_uri("nprofile1abc", &invoice(Some(0), None)), + "nostr:nprofile1abc" + ); + // A whitespace-only memo is dropped. + assert_eq!( + pay_uri("nprofile1abc", &invoice(None, Some(" "))), + "nostr:nprofile1abc" + ); + } + + #[test] + fn uri_never_leaks_token_or_key() { + // The token and recipient private material must never appear in the QR. + let inv = invoice(Some(2_000_000_000), Some("order 42")); + let uri = pay_uri("nprofile1abc", &inv); + assert!(!uri.contains("secret-token-should-never-leak")); + assert!(!uri.contains("token")); + } + + #[test] + fn percent_encode_covers_reserved_and_unicode() { + assert_eq!(percent_encode("a-b_c.d~e"), "a-b_c.d~e"); + assert_eq!(percent_encode("a b&c=d"), "a%20b%26c%3Dd"); + // Multi-byte UTF-8 is percent-encoded byte-by-byte. + assert_eq!(percent_encode("é"), "%C3%A9"); + } +}