Compare commits

...

3 Commits

Author SHA1 Message Date
2ro 3a80f7d505 checkout: show the GoblinPay mark on the QR and the pay page
The hosted checkout page gains the GoblinPay wordmark header, and the QR
center logo defaults to a new high-contrast GoblinPay mark (dark P on
brand gold) instead of the generic goblin mark. Still overridable via
GP_QR_LOGO (url/path/off); the legacy /static/goblin-mark.svg route is
kept for operators who pinned it.
2026-07-02 17:00:36 -04:00
2ro c362a9af21 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.
2026-07-02 15:26:14 -04:00
2ro 3f5b1fe49b docs: drop internal milestone references from the README
Strip the internal milestone scaffolding (Status: milestone N, the (M4)/(M5)/
(M5b)/(M6) feature tags, and the milestone-11 roadmap line) from the public
README. The feature docs, config, API, and webhook contract are unchanged --
only the internal planning labels are removed.
2026-07-02 13:20:48 -04:00
8 changed files with 241 additions and 18 deletions
+10 -13
View File
@@ -6,36 +6,33 @@ travels as a gift-wrapped slatepack over Nostr (optionally over the Nym
mixnet). GoblinPay auto-receives, returns the S2 reply so the payer can
finalize, confirms the transaction on chain, and signals paid.
**Status: milestone 6 (invoices, hosted checkout, per-user endpubs,
notifications).** On top of the milestone 2-4 wallet + transport + confirmation
path, GoblinPay now carries the full merchant surface:
Beyond the core wallet + transport + on-chain confirmation path, GoblinPay
carries the full merchant surface:
- **Invoices + matching (M5):** create an invoice against an order, matched by
- **Invoices + matching:** create an invoice against an order, matched by
any of three modes (per-invoice override or the `GP_MATCH_MODE` default):
the payer's memo, a per-invoice derived Nostr identity (a stateless child of
the server nsec, recommended for stores), or an exact amount. The matcher
runs inside the ingest pipeline, so a gift-wrapped payment resolves to its
invoice automatically.
- **Hosted checkout (M5):** a zero-JS `/pay/<token>` page (server-rendered
- **Hosted checkout:** a zero-JS `/pay/<token>` page (server-rendered
Askama + one CSS file + a server-generated QR SVG at ECC level H with an
optional Goblin-mark center logo), live status via `<meta http-equiv=refresh>`,
optional GoblinPay-mark center logo), live status via `<meta http-equiv=refresh>`,
and a manual slatepack fallback (paste S1 -> offline `receive_tx` -> copy the
S2 back) on every page. The same renderer serves embedded and hosted use.
- **Per-user endpubs (M5b):** an admin assigns one receiving identity per user
- **Per-user endpubs:** an admin assigns one receiving identity per user
(a derived child keyed by `(user_id, epoch)`; only public keys and the
rotation clock are stored, never private keys), with optional rolling rotation
and an overlap window so a just-rotated endpub still lands. All funds still
land in the one Grin wallet.
- **Notifications (M6, all optional):** an HMAC-signed, idempotent, retried
- **Notifications (all optional):** an HMAC-signed, idempotent, retried
HTTP webhook (the WooCommerce contract), an authenticated admin dashboard +
JSON API, and NIP-17 DMs to the merchant / payer.
All relay traffic rides an in-process Nym mixnet tunnel (smolmix, auto-selected
exit, mix-dns; `GP_NYM=off` is a debugging escape hatch only). Encryption
negotiates NIP-44 v3 (the NIP-17 extension, via the companion `nip44` crate) per
recipient, with v2 as the mandatory baseline. Store connectors and the
conversion oracle arrive in later milestones; comprehensive documentation lands
at milestone 11.
recipient, with v2 as the mandatory baseline.
## Workspace
@@ -78,7 +75,7 @@ Everything is environment variables, defaults are safe for local use.
| `GP_ADMIN_TOKEN` | unset | Bearer token for the admin dashboard + endpub/webhook API |
| `GP_WEBHOOK_URL` | unset | Webhook endpoint for payment events (requires `GP_WEBHOOK_SECRET`) |
| `GP_WEBHOOK_SECRET` | unset | HMAC-SHA256 secret for signing webhooks |
| `GP_QR_LOGO` | Goblin mark | Checkout QR center logo: unset = Goblin mark, `off`/`none` = plain, else a URL/path |
| `GP_QR_LOGO` | GoblinPay mark | Checkout QR center logo: unset = GoblinPay mark, `off`/`none` = plain, else a URL/path |
| `GP_MERCHANT_NPUB` | unset | Merchant npub for the NIP-17 confirmed-payment DM |
| `GP_NOTIFY_MERCHANT_DM` | `off` | Send a NIP-17 DM to the merchant on a received payment |
| `GP_NOTIFY_PAYER_RECEIPT` | `off` | Send a NIP-17 receipt DM to the payer |
@@ -133,7 +130,7 @@ variant works for `GP_API_TOKEN`, `GP_ADMIN_TOKEN`, and `GP_WEBHOOK_SECRET` too.
| GET | `/pay/{token}/status` | token | Invoice status JSON (for polling) |
| POST | `/pay/{token}/slatepack` | token | Manual fallback: paste S1, returns the S2 page |
| GET | `/payment/{id}` | token | Payment status JSON |
| GET | `/payment/{id}/receipt` | token | Server-signed verifiable receipt (M4) |
| GET | `/payment/{id}/receipt` | token | Server-signed verifiable receipt |
| GET | `/admin` | admin | Dashboard (payments, balances, config) |
| GET | `/admin/payments` | admin | Recent payments JSON |
| GET/POST | `/admin/users` | admin | List users / create a user + endpub |
+2 -2
View File
@@ -171,7 +171,7 @@ pub struct Config {
#[serde(skip)]
pub webhook_secret: Option<Secret>,
/// Center-logo source for checkout QR codes (`GP_QR_LOGO`): unset = the
/// bundled Goblin mark, `off`/`none` = no logo, else a URL or static path.
/// bundled GoblinPay mark, `off`/`none` = no logo, else a URL or static path.
pub qr_logo: Option<String>,
/// Merchant npub for confirmed-payment DMs (`GP_MERCHANT_NPUB`).
pub merchant_npub: Option<String>,
@@ -213,7 +213,7 @@ pub const DEFAULT_RATE_CACHE_TTL: i64 = 60;
pub const DEFAULT_QUOTE_TTL: i64 = 900;
/// Default center-logo path served by gp-server when `GP_QR_LOGO` is unset.
pub const DEFAULT_QR_LOGO: &str = "/static/goblin-mark.svg";
pub const DEFAULT_QR_LOGO: &str = "/static/goblinpay-mark.svg";
impl Default for Config {
fn default() -> Self {
+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");
}
}
+23 -2
View File
@@ -44,19 +44,40 @@ async fn style() -> impl Responder {
.body(include_str!("../../../static/style.css"))
}
/// The bundled Goblin mark, the default QR center logo.
/// The bundled Goblin mark (legacy default QR center logo; still served so an
/// operator can keep `GP_QR_LOGO=/static/goblin-mark.svg`).
async fn goblin_mark() -> impl Responder {
HttpResponse::Ok()
.content_type("image/svg+xml")
.body(include_str!("../../../static/goblin-mark.svg"))
}
/// The GoblinPay mark, the default QR center logo (dark "P" on the brand gold,
/// sized for contrast on the QR's white backing).
async fn goblinpay_mark() -> impl Responder {
HttpResponse::Ok()
.content_type("image/svg+xml")
.body(include_str!("../../../static/goblinpay-mark.svg"))
}
/// The GoblinPay wordmark (white), shown as the checkout page header logo.
async fn goblinpay_wordmark() -> impl Responder {
HttpResponse::Ok()
.content_type("image/svg+xml")
.body(include_str!("../../../static/goblinpay-wordmark.svg"))
}
/// Route table, shared by `main` and the tests.
fn routes(cfg: &mut web::ServiceConfig) {
cfg.route("/", web::get().to(index))
.route("/health", web::get().to(health))
.route("/static/style.css", web::get().to(style))
.route("/static/goblin-mark.svg", web::get().to(goblin_mark));
.route("/static/goblin-mark.svg", web::get().to(goblin_mark))
.route("/static/goblinpay-mark.svg", web::get().to(goblinpay_mark))
.route(
"/static/goblinpay-wordmark.svg",
web::get().to(goblinpay_wordmark),
);
// Payment status + signed-receipt reads (public-by-token, M4).
payments::configure(cfg);
// Hosted checkout + manual slatepack (public-by-token, M5).
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="GoblinPay">
<rect width="64" height="64" rx="14" fill="#e9c542"/>
<path fill="#201d09" fill-rule="evenodd" d="M22 14H35a12 12 0 0 1 0 24H30V50H22ZM30 21H34a6 6 0 0 1 0 12H30Z"/>
</svg>

After

Width:  |  Height:  |  Size: 272 B

+51
View File
@@ -0,0 +1,51 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="600.000000pt" height="232.000000pt" viewBox="0 0 600.000000 232.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,232.000000) scale(0.050000,-0.050000)"
fill="#ffffff" stroke="none">
<path d="M52 4522 c-131 -444 37 -874 416 -1064 l122 -61 -95 11 c-111 13
-226 67 -324 154 -83 72 -86 66 -28 -48 140 -278 398 -434 715 -434 197 0 331
45 362 120 62 150 316 340 520 391 271 67 273 570 2 813 -194 175 -626 253
-842 152 l-70 -33 91 -1 c191 -4 428 -112 506 -231 36 -53 15 -72 -26 -23 -89
108 -361 154 -623 104 -384 -72 -552 -22 -664 197 l-30 60 -32 -107z"/>
<path d="M4560 2533 l0 -1610 285 3 285 4 5 573 6 573 484 9 c534 10 636 29
838 158 583 370 592 1348 15 1718 -240 155 -320 167 -1163 175 l-755 7 0
-1610z m1450 1080 c389 -170 416 -801 42 -998 -75 -40 -118 -44 -497 -51
l-415 -8 0 554 0 554 395 -8 c319 -7 410 -15 475 -43z"/>
<path d="M3139 3938 c-92 -222 -202 -268 -639 -268 -462 0 -625 -70 -783 -338
-58 -100 -60 -71 -4 66 37 92 27 95 -96 29 -298 -160 -537 -548 -537 -871 0
-117 -1 -117 -150 -4 -124 94 -359 217 -456 240 l-55 13 43 -108 c23 -59 67
-230 98 -381 87 -422 215 -636 412 -686 l94 -23 -4 -152 c-8 -255 71 -431 252
-564 108 -79 133 -87 119 -36 -6 19 -16 75 -24 125 l-14 90 86 -87 c155 -155
391 -243 648 -243 238 1 257 17 94 80 -148 58 -352 193 -453 300 l-60 63 150
-81 c278 -149 618 -181 915 -86 79 25 130 33 138 20 43 -71 324 -122 433 -79
31 12 26 23 -37 83 -138 133 -143 166 -41 260 116 107 160 201 145 310 -11 78
-6 89 69 162 122 118 151 178 228 468 39 147 96 319 127 383 65 137 62 139
-143 85 -158 -41 -258 -93 -362 -188 l-84 -77 -33 95 c-42 118 -118 202 -181
202 -63 0 -316 127 -366 183 -21 23 -29 39 -18 34 11 -5 54 -25 96 -44 600
-273 1151 104 1154 791 l0 94 -101 -107 c-197 -209 -486 -299 -738 -231 l-88
23 64 51 c95 74 147 190 147 330 0 142 -10 158 -45 74z m-172 -1574 c115 -128
161 -505 71 -595 -162 -162 -289 -89 -286 164 3 327 112 544 215 431z m-1030
-33 c101 -52 201 -243 241 -459 71 -385 -574 -317 -668 70 -55 226 234 489
427 389z m1703 -77 c0 -181 -102 -341 -141 -222 l-24 74 -27 -63 c-47 -112
-128 -19 -128 146 0 76 37 97 54 31 12 -47 46 -54 46 -9 0 48 62 61 92 19 26
-35 27 -35 41 2 37 103 87 116 87 22z m-2770 -34 c16 -73 41 -77 58 -10 33
129 140 -19 128 -177 l-6 -83 -43 66 -42 66 -13 -51 c-14 -56 -66 -69 -85 -21
-17 45 -36 36 -61 -30 -50 -132 -103 -48 -68 108 39 175 108 243 132 132z
m1350 -837 c0 -123 205 -183 399 -116 109 38 122 31 46 -24 -212 -151 -633
-30 -516 148 43 65 71 62 71 -8z"/>
<path d="M7910 3299 c-533 -109 -818 -523 -817 -1189 0 -436 84 -696 300 -929
341 -369 1014 -413 1335 -88 l90 92 -13 -78 c-7 -42 -13 -99 -14 -127 l-1 -50
250 -6 c138 -3 270 -1 295 6 l45 11 0 1159 0 1160 -281 0 -281 0 14 -125 14
-125 -82 84 c-183 188 -531 271 -854 205z m590 -485 c333 -171 432 -878 177
-1262 -222 -336 -773 -264 -938 121 -297 691 196 1430 761 1141z"/>
<path d="M9680 3249 c0 -6 211 -496 469 -1090 l469 -1078 -84 -201 c-162 -386
-315 -476 -665 -388 l-49 12 0 -231 c0 -269 -11 -255 217 -268 402 -22 736
151 909 470 43 81 553 1429 994 2630 l57 155 -296 0 -295 0 -256 -730 c-141
-402 -260 -726 -265 -722 -5 5 -146 331 -314 725 l-305 717 -293 6 c-161 3
-293 0 -293 -7z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

+3
View File
@@ -31,6 +31,9 @@ main {
main.admin { max-width: 60rem; }
.brand { display: block; }
.brandmark { height: 30px; width: auto; display: block; margin: 0 0 1.25rem; }
h1 { font-size: 1.5rem; margin: 0 0 0.5rem; }
h2 { font-size: 1.05rem; margin: 1.75rem 0 0.5rem; color: var(--dim); text-transform: uppercase; letter-spacing: 0.04em; }
+1
View File
@@ -9,6 +9,7 @@
</head>
<body>
<main class="checkout">
<a class="brand" href="/"><img class="brandmark" src="/static/goblinpay-wordmark.svg" alt="GoblinPay"></a>
<h1>Pay with Goblin</h1>
<p class="amount">{{ info.amount_display }}</p>