Compare commits

...

4 Commits

Author SHA1 Message Date
2ro b8434fdd36 config: GP_CHECKOUT_METHODS to pick which checkout methods show
Operators can now choose which payment methods the hosted /pay page
offers: GP_CHECKOUT_METHODS=nostr, =slatepack, or nostr,slatepack
(unset = both, current behavior). Gates the two checkout sections
independently; slatepack still also requires a loaded wallet. The Nostr
ingest service (GP_INGEST) and the /invoice JSON API are unchanged.
2026-07-02 19:29:43 -04:00
2ro 94d0c0edba checkout: first-class grin1 / Slatepack payment method
The hosted /pay page now shows the wallet's grin1 Slatepack address (with
a QR and the exact amount) as a payment method alongside the Goblin/Nostr
option. A payer sends the amount from any Grin wallet via the Slatepack or
file method, pastes the S1 into the existing paste box, receives an S2,
and finalizes to complete the payment. Reuses the existing offline
receive_tx flow bound to the invoice token; the Nostr gift-wrap path, the
invoice matcher, and the proof/confirm logic are unchanged. No Tor
listener. The grin1 address is the wallet's stable index-0 address.
2026-07-02 19:22:35 -04:00
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
11 changed files with 535 additions and 43 deletions
+30 -4
View File
@@ -17,9 +17,21 @@ carries the full merchant surface:
invoice automatically.
- **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>`,
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.
optional GoblinPay-mark center logo) with live status via
`<meta http-equiv=refresh>`. It offers two first-class ways to pay:
- **Goblin Wallet (Nostr):** scan the `nprofile` QR (or copy it) and the
payment auto-receives over Nostr.
- **Slatepack (`grin1`):** pay from any Grin wallet, no Nostr needed. The
page shows the wallet's stable index-0 Slatepack address (`grin1...`) plus
its QR and the exact amount to send. The payer sends that amount to the
address using their wallet's Slatepack/file method, pastes the resulting S1
into the page (offline `receive_tx`), then copies the returned S2 back into
their wallet to finalize and broadcast it. There is no Tor listener; the
`grin1` address is stable and reused across invoices, and the existing
invoice matcher and on-chain confirmation handle the received payment like
any other. The Slatepack option only appears when a wallet is loaded.
The same renderer serves embedded and hosted use.
- **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
@@ -65,6 +77,7 @@ Everything is environment variables, defaults are safe for local use.
| `GP_RELAYS` | unset | Comma-separated relay URLs |
| `GP_NYM` | `on` | Route Nostr traffic over the Nym mixnet (`on` or `off`) |
| `GP_INGEST` | `on` | Nostr ingest service (`off` = HTTP surface only, for debugging) |
| `GP_CHECKOUT_METHODS` | `nostr,slatepack` | Which payment methods the hosted `/pay/<token>` page shows: comma list of `nostr` (Goblin Wallet) and `slatepack` (`grin1`). Unset = both. Unknown tokens are ignored; an empty result falls back to both |
| `GP_MATCH_MODE` | `memo` | Default matching mode: `memo`, `derived`, `amount` |
| `GP_MNEMONIC` | unset | Grin seed mnemonic (money secret) |
| `GP_WALLET_PASSWORD` | unset | Password encrypting the wallet seed and the Nostr identity at rest |
@@ -75,7 +88,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 |
@@ -87,6 +100,19 @@ Everything is environment variables, defaults are safe for local use.
| `GP_QUOTE_TTL` | `900` | Seconds a created fiat invoice locks its Grin quote (its expiry window) |
| `GP_RATE_STALE_MAX` | `0` | Bounded stale-rate fallback in seconds if a live fetch fails (0 = off) |
### Checkout methods
`GP_CHECKOUT_METHODS` only controls what the hosted `/pay/<token>` page
advertises to a payer; it does not turn any payment processing on or off. The
Slatepack (`grin1`) method also needs a loaded wallet to appear, so an enabled
method that cannot work is simply hidden. Keep this consistent with `GP_INGEST`:
`GP_INGEST` runs the Nostr ingest service that actually receives and matches
Goblin Wallet payments, so `GP_INGEST=off` with `GP_CHECKOUT_METHODS=nostr`
would advertise a Nostr method that nothing is listening for. If you disable
ingest, drop `nostr` from `GP_CHECKOUT_METHODS`; if you advertise `nostr`, keep
ingest on. The connector `POST /invoice` JSON response still returns the
`nprofile` regardless of this setting, which affects only the hosted page.
### Conversion rates (optional)
A store that prices in fiat (for example cryptodrip.com prices in USD) sends
+92 -3
View File
@@ -136,6 +136,15 @@ pub struct Config {
/// Run the Nostr ingest service (`GP_INGEST`: `on` or `off`, default on).
/// When on, the wallet and identity secrets are required at boot.
pub ingest: bool,
/// Show the Nostr (Goblin Wallet, `nprofile`) method on the hosted checkout
/// page. Part of `GP_CHECKOUT_METHODS` (comma list of `nostr`/`slatepack`;
/// unset = both). This gates only the hosted PAGE display, not the connector
/// API or the ingest service.
pub checkout_nostr: bool,
/// Show the Slatepack (`grin1`) method on the hosted checkout page. Part of
/// `GP_CHECKOUT_METHODS`. Still requires a loaded wallet to actually appear:
/// an enabled method that cannot work is simply hidden.
pub checkout_slatepack: bool,
/// Global default matching mode (`GP_MATCH_MODE`).
pub match_mode: MatchMode,
/// Grin seed mnemonic (`GP_MNEMONIC` or `GP_MNEMONIC_FILE`). Money secret.
@@ -171,7 +180,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 +222,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 {
@@ -228,6 +237,8 @@ impl Default for Config {
relays: Vec::new(),
nym: true,
ingest: true,
checkout_nostr: true,
checkout_slatepack: true,
match_mode: MatchMode::Memo,
mnemonic: None,
wallet_password: None,
@@ -322,6 +333,9 @@ impl Config {
other => return Err(format!("GP_INGEST must be `on` or `off` (got `{other}`)")),
};
let (checkout_nostr, checkout_slatepack) =
parse_checkout_methods(get("GP_CHECKOUT_METHODS"));
let match_mode = match get("GP_MATCH_MODE").as_deref().unwrap_or("memo") {
"memo" => MatchMode::Memo,
"derived" => MatchMode::Derived,
@@ -394,6 +408,8 @@ impl Config {
relays,
nym,
ingest,
checkout_nostr,
checkout_slatepack,
match_mode,
mnemonic,
wallet_password,
@@ -425,6 +441,18 @@ impl Config {
self.qr_logo.as_deref()
}
/// The enabled checkout methods as a stable comma list, for the startup log.
fn checkout_methods_str(&self) -> String {
let mut methods = Vec::new();
if self.checkout_nostr {
methods.push("nostr");
}
if self.checkout_slatepack {
methods.push("slatepack");
}
methods.join(",")
}
/// Fail-fast consistency checks.
fn validate(&self) -> Result<(), String> {
if self.bind.is_empty() {
@@ -479,7 +507,8 @@ impl Config {
let set = |o: bool| if o { "set" } else { "unset" };
format!(
"bind={} tls={} db={} data_dir={} node={} chain={:?} relay_mode={:?} \
relays={:?} nym={} ingest={} match_mode={:?} mnemonic={} wallet_password={} \
relays={:?} nym={} ingest={} checkout_methods={} match_mode={:?} mnemonic={} \
wallet_password={} \
nsec={} ncryptsec={} public_url={} api_token={} admin_token={} webhook_url={} \
webhook_secret={} qr_logo={} merchant_npub={} notify_merchant_dm={} \
notify_payer_receipt={} endpub_rotate_interval={} endpub_overlap_epochs={} \
@@ -498,6 +527,7 @@ impl Config {
self.relays,
if self.nym { "on" } else { "off" },
if self.ingest { "on" } else { "off" },
self.checkout_methods_str(),
self.match_mode,
set(self.mnemonic.is_some()),
set(self.wallet_password.is_some()),
@@ -527,6 +557,35 @@ impl Config {
}
}
/// Parse `GP_CHECKOUT_METHODS` (comma list of `nostr`/`slatepack`) into the two
/// display flags. Parsing is lenient: tokens are trimmed, lowercased, and
/// unknown tokens are ignored with a warning. Unset (`None`) enables both, which
/// preserves the historical default of showing every available method. If a set
/// value parses to no known methods, both are enabled (a checkout must offer at
/// least one way to pay) and a warning is logged.
fn parse_checkout_methods(raw: Option<String>) -> (bool, bool) {
let Some(raw) = raw else {
return (true, true);
};
let mut nostr = false;
let mut slatepack = false;
for tok in raw.split(',') {
match tok.trim().to_lowercase().as_str() {
"" => {}
"nostr" => nostr = true,
"slatepack" => slatepack = true,
other => log::warn!("GP_CHECKOUT_METHODS: ignoring unknown method `{other}`"),
}
}
if !nostr && !slatepack {
log::warn!(
"GP_CHECKOUT_METHODS enabled no known methods; defaulting to both (nostr,slatepack)"
);
return (true, true);
}
(nostr, slatepack)
}
/// Parse an `on`/`off` flag with a default.
fn parse_bool(
get: &dyn Fn(&str) -> Option<String>,
@@ -637,6 +696,36 @@ mod tests {
assert_eq!(cfg.match_mode, MatchMode::Derived);
}
#[test]
fn checkout_methods_default_and_parsing() {
// Unset: both methods on (unchanged historical behavior).
let cfg = load(&[]).unwrap();
assert!(cfg.checkout_nostr);
assert!(cfg.checkout_slatepack);
// Single method selects only that method.
let cfg = load(&[("GP_CHECKOUT_METHODS", "nostr")]).unwrap();
assert!(cfg.checkout_nostr);
assert!(!cfg.checkout_slatepack);
let cfg = load(&[("GP_CHECKOUT_METHODS", "slatepack")]).unwrap();
assert!(!cfg.checkout_nostr);
assert!(cfg.checkout_slatepack);
// Both, order/whitespace/case insensitive, unknown tokens ignored.
let cfg = load(&[("GP_CHECKOUT_METHODS", " Slatepack , NOSTR ,bogus,")]).unwrap();
assert!(cfg.checkout_nostr);
assert!(cfg.checkout_slatepack);
// Empty or all-garbage parses to no methods -> defaults to both.
let cfg = load(&[("GP_CHECKOUT_METHODS", "")]).unwrap();
assert!(cfg.checkout_nostr);
assert!(cfg.checkout_slatepack);
let cfg = load(&[("GP_CHECKOUT_METHODS", "lightning, bitcoin")]).unwrap();
assert!(cfg.checkout_nostr);
assert!(cfg.checkout_slatepack);
}
#[test]
fn tls_rustls_requires_cert_and_key() {
assert!(load(&[("GP_TLS", "rustls")]).is_err());
+293 -14
View File
@@ -1,13 +1,15 @@
//! The hosted, zero-JS checkout: the `/pay/<token>` page (shared renderer for
//! embedded and hosted use), its live status, and the manual-slatepack
//! fallback.
//! embedded and hosted use), its live status, and the Slatepack receive flow.
//!
//! The page shows the amount, a server-generated QR SVG of the recipient
//! `nprofile`, the `nprofile`/`npub` strings, live status via a
//! `<meta http-equiv="refresh">` while open, and a `<textarea>` POST form to
//! paste an S1 slatepack when the automatic Nostr flow cannot be used. On
//! submit, the same offline `receive_tx` runs and the S2 reply renders back for
//! the payer to copy and finalize. No JavaScript anywhere.
//! The page offers two first-class ways to pay: the Goblin/Nostr path (a QR
//! SVG of the recipient `nprofile` plus the `nprofile`/`npub` strings) and a
//! Slatepack (`grin1`) path for any Grin wallet (the wallet's stable index-0
//! Slatepack address, its QR, and a `<textarea>` POST form to paste the S1 the
//! payer's wallet produces). It also shows the amount and live status via a
//! `<meta http-equiv="refresh">` while open. On submit, the same offline
//! `receive_tx` runs and the S2 reply renders back for the payer to finalize
//! and broadcast. The Slatepack path only appears when a wallet is loaded. No
//! JavaScript anywhere.
use actix_web::{web, HttpResponse, Responder};
use askama::Template;
@@ -32,6 +34,12 @@ pub struct CheckoutInfo {
pub npub: String,
pub nprofile: String,
pub qr_svg: String,
/// The wallet's stable `grin1` Slatepack address, when a wallet is loaded.
/// `None` (and no Slatepack option shown) when the instance runs with no
/// wallet, mirroring how the manual receive handler degrades.
pub slatepack_address: Option<String>,
/// QR SVG of the `grin1` Slatepack address (present with the address).
pub slatepack_qr_svg: Option<String>,
pub amount_display: String,
pub status: String,
pub memo: Option<String>,
@@ -41,14 +49,50 @@ pub struct CheckoutInfo {
/// Build the presentation for an invoice: the nprofile, its QR, the pay URL,
/// and a human amount. Shared by the hosted page and the connector API so both
/// render identically.
pub fn build_info(inv: &Invoice, cfg: &Config) -> CheckoutInfo {
///
/// `slatepack_addr` is the wallet's stable `grin1` Slatepack address when a
/// wallet is loaded (the hosted page passes it so a payer can pay from any Grin
/// wallet without Nostr); pass `None` when no wallet is available or the
/// caller does not surface the Slatepack option (e.g. the JSON connector API),
/// in which case no Slatepack address or QR is produced.
pub fn build_info(inv: &Invoice, cfg: &Config, slatepack_addr: Option<&str>) -> CheckoutInfo {
let relays = gp_nostr::relays::resolve(&cfg.relays);
let recipient_pubkey = inv.recipient_pubkey.clone().unwrap_or_default();
let (npub, nprofile) = match PublicKey::from_hex(&recipient_pubkey) {
// The Nostr (Goblin Wallet) method is only surfaced when the operator has it
// enabled (`GP_CHECKOUT_METHODS`). Disabled, the nprofile/npub/QR are left
// empty and the template omits the whole section. This gates only the hosted
// PAGE display; the connector API and ingest are unaffected.
let (npub, nprofile) = if cfg.checkout_nostr {
match PublicKey::from_hex(&recipient_pubkey) {
Ok(pk) => (gp_nostr::npub_of(pk), gp_nostr::nprofile(pk, &relays)),
Err(_) => (String::new(), String::new()),
}
} else {
(String::new(), String::new())
};
// 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();
// The Slatepack (grin1) address is stable and reused across invoices; its
// QR carries the bare address (a Grin wallet reads no amount from it, so
// the page states the amount to send in text next to it). No address means
// no wallet loaded: the page simply omits the Slatepack option.
// The Slatepack method needs both operator opt-in (`GP_CHECKOUT_METHODS`)
// and a loaded wallet: an enabled method that cannot work is simply hidden.
let (slatepack_address, slatepack_qr_svg) = match slatepack_addr {
Some(addr) if cfg.checkout_slatepack && !addr.is_empty() => {
let qr = qr::svg(addr, cfg.qr_logo_href()).unwrap_or_default();
(Some(addr.to_string()), Some(qr))
}
_ => (None, None),
};
let qr_svg = qr::svg(&nprofile, cfg.qr_logo_href()).unwrap_or_default();
let amount_display = amount_display(inv);
let token = inv.token.clone().unwrap_or_default();
CheckoutInfo {
@@ -59,6 +103,8 @@ pub fn build_info(inv: &Invoice, cfg: &Config) -> CheckoutInfo {
npub,
nprofile,
qr_svg,
slatepack_address,
slatepack_qr_svg,
amount_display,
status: inv.status.clone(),
memo: inv.memo.clone(),
@@ -86,6 +132,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")]
@@ -94,7 +187,6 @@ struct PayPage {
is_open: bool,
is_paid: bool,
is_expired: bool,
wallet_available: bool,
}
/// The manual-slatepack result template (S2 to copy back).
@@ -131,12 +223,18 @@ async fn pay_page(
}
};
let status = inv.status();
// Surface the wallet's stable grin1 Slatepack address (same wallet handle
// the manual receive uses). No wallet loaded, or the address cannot be
// derived, means no Slatepack option is shown.
let slatepack_addr = wallet
.get_ref()
.as_ref()
.and_then(|w| w.slatepack_address().ok());
let page = PayPage {
info: build_info(&inv, cfg.get_ref()),
info: build_info(&inv, cfg.get_ref(), slatepack_addr.as_deref()),
is_open: status == InvoiceStatus::Open,
is_paid: status == InvoiceStatus::Paid,
is_expired: status == InvoiceStatus::Expired,
wallet_available: wallet.get_ref().is_some(),
};
render(page)
}
@@ -243,3 +341,184 @@ 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 build_info_surfaces_slatepack_address_when_wallet_loaded() {
// A loaded wallet passes its grin1 address: build_info exposes it plus
// a QR for it, so the hosted page can show the Slatepack option.
let inv = invoice(Some(1_500_000_000), None);
let cfg = Config::default();
let info = build_info(&inv, &cfg, Some("grin1qtestaddress"));
assert_eq!(info.slatepack_address.as_deref(), Some("grin1qtestaddress"));
let qr = info.slatepack_qr_svg.expect("slatepack QR present");
assert!(qr.contains("<svg"), "grin1 QR is an SVG");
}
#[test]
fn build_info_omits_slatepack_when_no_wallet() {
// No wallet (None) or a blank address: no Slatepack address or QR, so
// the page simply does not show the Slatepack option.
let inv = invoice(Some(1_500_000_000), None);
let cfg = Config::default();
let info = build_info(&inv, &cfg, None);
assert!(info.slatepack_address.is_none());
assert!(info.slatepack_qr_svg.is_none());
let blank = build_info(&inv, &cfg, Some(""));
assert!(blank.slatepack_address.is_none());
assert!(blank.slatepack_qr_svg.is_none());
}
#[test]
fn checkout_nostr_disabled_hides_nostr_section() {
// GP_CHECKOUT_METHODS=slatepack: the Nostr method is off, so build_info
// leaves the nprofile/npub empty and the page omits the Nostr section
// while still showing the Slatepack one.
let inv = invoice(Some(1_500_000_000), None);
let mut cfg = Config::default();
cfg.checkout_nostr = false;
cfg.checkout_slatepack = true;
let info = build_info(&inv, &cfg, Some("grin1qtestaddress"));
assert!(info.nprofile.is_empty(), "nprofile empty when nostr off");
assert!(info.npub.is_empty(), "npub empty when nostr off");
assert_eq!(info.slatepack_address.as_deref(), Some("grin1qtestaddress"));
let page = PayPage {
info,
is_open: true,
is_paid: false,
is_expired: false,
};
let html = page.render().unwrap();
assert!(
!html.contains("Pay with Goblin Wallet"),
"Nostr section absent when checkout_nostr=false"
);
assert!(
html.contains("Pay by Slatepack"),
"Slatepack section still present"
);
}
#[test]
fn checkout_slatepack_disabled_hides_slatepack_section() {
// GP_CHECKOUT_METHODS=nostr: the Slatepack method is off, so even with a
// wallet address available, build_info drops it and the page omits the
// Slatepack section while still showing the Nostr one.
let inv = invoice(Some(1_500_000_000), None);
let mut cfg = Config::default();
cfg.checkout_nostr = true;
cfg.checkout_slatepack = false;
let info = build_info(&inv, &cfg, Some("grin1qtestaddress"));
assert!(
info.slatepack_address.is_none(),
"slatepack dropped when method off"
);
assert!(info.slatepack_qr_svg.is_none());
assert!(!info.nprofile.is_empty(), "nprofile present when nostr on");
let page = PayPage {
info,
is_open: true,
is_paid: false,
is_expired: false,
};
let html = page.render().unwrap();
assert!(
html.contains("Pay with Goblin Wallet"),
"Nostr section present"
);
assert!(
!html.contains("Pay by Slatepack"),
"Slatepack section absent when checkout_slatepack=false"
);
}
#[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");
}
}
+4 -2
View File
@@ -145,7 +145,9 @@ async fn create_invoice(
.json(serde_json::json!({"error": "internal error"}));
}
};
let info = build_info(&inv, cfg.get_ref());
// The JSON connector API surfaces the Nostr checkout fields only; the
// grin1 Slatepack option is presented on the hosted /pay page.
let info = build_info(&inv, cfg.get_ref(), None);
HttpResponse::Ok().json(checkout_json(&info))
}
@@ -161,7 +163,7 @@ async fn get_invoice(
}
match invoice::get(pool.get_ref(), &path.into_inner()).await {
Ok(Some(inv)) => {
let info = build_info(&inv, cfg.get_ref());
let info = build_info(&inv, cfg.get_ref(), None);
HttpResponse::Ok().json(checkout_json(&info))
}
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "not found"})),
+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
@@ -164,6 +164,10 @@ async fn pay_page_renders_zero_js_with_qr_and_nprofile() {
assert!(html.contains("1.5 GRIN"), "amount shown");
assert!(html.contains("<svg"), "server-rendered QR present");
assert!(html.contains("nprofile1"), "nprofile string present");
assert!(
!html.contains("Pay by Slatepack"),
"no wallet loaded: the grin1 Slatepack option is omitted"
);
assert!(
html.contains("http-equiv=\"refresh\""),
"live status refresh while open"
+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

+8
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; }
@@ -92,6 +95,11 @@ details.manual { margin-top: 1.5rem; border-top: 1px solid var(--line); padding-
details.manual summary { cursor: pointer; color: var(--accent); font-weight: 600; }
details.manual ol { color: var(--dim); font-size: 0.9rem; padding-left: 1.2rem; }
/* Each first-class way to pay (Goblin/Nostr, Slatepack/grin1). */
.pay-method { margin-top: 1.5rem; border-top: 1px solid var(--line); padding-top: 0.75rem; }
.pay-method:first-of-type { border-top: none; padding-top: 0; }
.pay-method ol { color: var(--dim); font-size: 0.9rem; padding-left: 1.2rem; }
a { color: var(--accent); }
.footer { margin-top: 2rem; color: var(--dim); font-size: 0.78rem; text-align: center; }
+19 -11
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>
@@ -19,30 +20,37 @@
<p class="status expired">This invoice has expired.</p>
{% else %}
<p class="status open">Waiting for payment&#8230;</p>
{% if !info.nprofile.is_empty() %}
<section class="pay-method">
<h2>Pay with Goblin Wallet</h2>
<div class="qr">{{ info.qr_svg|safe }}</div>
<p class="hint">Scan with your Goblin Wallet, or copy the address below.</p>
<label for="nprofile">Payment address (nprofile)</label>
<textarea id="nprofile" class="copybox" rows="3" readonly>{{ info.nprofile }}</textarea>
</section>
{% endif %}
<details class="manual">
<summary>Can&#39;t scan? Pay manually with a slatepack</summary>
{% if wallet_available %}
{% if let Some(grin1) = info.slatepack_address %}
<section class="pay-method">
<h2>Pay by Slatepack (grin1)</h2>
<p class="hint">Pay from any Grin wallet, no Nostr needed. Send <strong>{{ info.amount_display }}</strong> to the address below.</p>
{% if let Some(grin1_qr) = info.slatepack_qr_svg %}<div class="qr">{{ grin1_qr|safe }}</div>{% endif %}
<label for="grin1">Slatepack address (grin1)</label>
<textarea id="grin1" class="copybox" rows="3" readonly>{{ grin1 }}</textarea>
<ol>
<li>In your wallet, send {{ info.amount_display }} using the manual / slatepack option.</li>
<li>Paste the generated <strong>S1</strong> slatepack below and submit.</li>
<li>Copy the <strong>response</strong> slatepack we return, back into your wallet to finalize and post.</li>
<li>In your Grin wallet, send {{ info.amount_display }} to this address using the Slatepack / file method, then paste the Slatepack it produces below.</li>
<li>The pasted <strong>S1</strong> Slatepack is received here and a <strong>response</strong> Slatepack is returned.</li>
<li>Paste that response back into your wallet to finalize and broadcast it, which completes the payment.</li>
</ol>
<form method="post" action="/pay/{{ info.token }}/slatepack">
<label for="s1">Your slatepack (S1)</label>
<label for="s1">Your Slatepack (S1)</label>
<textarea id="s1" name="slatepack" rows="6" required
placeholder="BEGINSLATEPACK. &#8230; ENDSLATEPACK."></textarea>
<button type="submit">Submit slatepack</button>
</form>
{% else %}
<p>Manual receive is unavailable on this instance.</p>
</section>
{% endif %}
</details>
{% endif %}
{% if let Some(memo) = info.memo %}<p class="memo">{{ memo }}</p>{% endif %}
+1 -1
View File
@@ -16,7 +16,7 @@
<textarea id="s2" class="copybox" rows="8" readonly>{{ s2_armor }}</textarea>
<ol>
<li>Select all of the text above and copy it.</li>
<li>Paste it back into your wallet to finalize the transaction.</li>
<li>Paste it back into your wallet to finalize and broadcast the transaction; this completes the payment.</li>
<li>Your wallet posts it to the chain; GoblinPay confirms it on receipt.</li>
</ol>
{% endif %}