checkout: encode amount (and memo) in the invoice QR pay-URI

The checkout QR now carries the payable amount so a scanning Goblin
wallet auto-fills it:
  nostr:<nprofile>?amount=<decimal GRIN>&memo=<percent-encoded>
The amount param is added only for an exact-amount invoice; open-amount
invoices stay a bare nostr:<nprofile>. The memo is percent-encoded. The
URI never includes the invoice token or any key - only the already-public
recipient nprofile, relay hints, amount, and the memo shown on the page.
The human-readable nprofile/npub page strings are unchanged.
This commit is contained in:
2ro
2026-07-02 15:26:14 -04:00
parent 3f5b1fe49b
commit c362a9af21
+147 -1
View File
@@ -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:<nprofile>`, 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:<nprofile>`.
///
/// 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<T: Template>(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<i64>, 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:<nprofile>?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:<nprofile>.
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");
}
}