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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user