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)),
|
Ok(pk) => (gp_nostr::npub_of(pk), gp_nostr::nprofile(pk, &relays)),
|
||||||
Err(_) => (String::new(), String::new()),
|
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 amount_display = amount_display(inv);
|
||||||
let token = inv.token.clone().unwrap_or_default();
|
let token = inv.token.clone().unwrap_or_default();
|
||||||
CheckoutInfo {
|
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.
|
/// The checkout page template.
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "pay.html")]
|
#[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