From b8434fdd3664f0c003a59e7e010a41ba2717d0c7 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:29:43 -0400 Subject: [PATCH] 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. --- README.md | 14 +++++ crates/gp-core/src/config.rs | 91 +++++++++++++++++++++++++++++++- crates/gp-server/src/checkout.rs | 83 +++++++++++++++++++++++++++-- templates/pay.html | 2 + 4 files changed, 185 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9d68e85..0df1ebd 100644 --- a/README.md +++ b/README.md @@ -77,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/` 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 | @@ -99,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/` 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 diff --git a/crates/gp-core/src/config.rs b/crates/gp-core/src/config.rs index 35a0fb1..ac7af39 100644 --- a/crates/gp-core/src/config.rs +++ b/crates/gp-core/src/config.rs @@ -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. @@ -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) -> (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, @@ -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()); diff --git a/crates/gp-server/src/checkout.rs b/crates/gp-server/src/checkout.rs index d860038..300979d 100644 --- a/crates/gp-server/src/checkout.rs +++ b/crates/gp-server/src/checkout.rs @@ -58,9 +58,17 @@ pub struct CheckoutInfo { 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) { - Ok(pk) => (gp_nostr::npub_of(pk), gp_nostr::nprofile(pk, &relays)), - Err(_) => (String::new(), String::new()), + // 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 @@ -76,8 +84,10 @@ pub fn build_info(inv: &Invoice, cfg: &Config, slatepack_addr: Option<&str>) -> // 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 !addr.is_empty() => { + 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)) } @@ -384,6 +394,71 @@ mod tests { 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:?amount=1.5 diff --git a/templates/pay.html b/templates/pay.html index 9ed7950..1d2a17e 100644 --- a/templates/pay.html +++ b/templates/pay.html @@ -21,6 +21,7 @@ {% else %}

Waiting for payment…

+ {% if !info.nprofile.is_empty() %}

Pay with Goblin Wallet

{{ info.qr_svg|safe }}
@@ -28,6 +29,7 @@
+ {% endif %} {% if let Some(grin1) = info.slatepack_address %}