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.
This commit is contained in:
2ro
2026-07-02 19:29:43 -04:00
parent 94d0c0edba
commit b8434fdd36
4 changed files with 185 additions and 5 deletions
+14
View File
@@ -77,6 +77,7 @@ Everything is environment variables, defaults are safe for local use.
| `GP_RELAYS` | unset | Comma-separated relay URLs | | `GP_RELAYS` | unset | Comma-separated relay URLs |
| `GP_NYM` | `on` | Route Nostr traffic over the Nym mixnet (`on` or `off`) | | `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_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_MATCH_MODE` | `memo` | Default matching mode: `memo`, `derived`, `amount` |
| `GP_MNEMONIC` | unset | Grin seed mnemonic (money secret) | | `GP_MNEMONIC` | unset | Grin seed mnemonic (money secret) |
| `GP_WALLET_PASSWORD` | unset | Password encrypting the wallet seed and the Nostr identity at rest | | `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_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) | | `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) ### Conversion rates (optional)
A store that prices in fiat (for example cryptodrip.com prices in USD) sends A store that prices in fiat (for example cryptodrip.com prices in USD) sends
+90 -1
View File
@@ -136,6 +136,15 @@ pub struct Config {
/// Run the Nostr ingest service (`GP_INGEST`: `on` or `off`, default on). /// Run the Nostr ingest service (`GP_INGEST`: `on` or `off`, default on).
/// When on, the wallet and identity secrets are required at boot. /// When on, the wallet and identity secrets are required at boot.
pub ingest: bool, 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`). /// Global default matching mode (`GP_MATCH_MODE`).
pub match_mode: MatchMode, pub match_mode: MatchMode,
/// Grin seed mnemonic (`GP_MNEMONIC` or `GP_MNEMONIC_FILE`). Money secret. /// Grin seed mnemonic (`GP_MNEMONIC` or `GP_MNEMONIC_FILE`). Money secret.
@@ -228,6 +237,8 @@ impl Default for Config {
relays: Vec::new(), relays: Vec::new(),
nym: true, nym: true,
ingest: true, ingest: true,
checkout_nostr: true,
checkout_slatepack: true,
match_mode: MatchMode::Memo, match_mode: MatchMode::Memo,
mnemonic: None, mnemonic: None,
wallet_password: None, wallet_password: None,
@@ -322,6 +333,9 @@ impl Config {
other => return Err(format!("GP_INGEST must be `on` or `off` (got `{other}`)")), 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") { let match_mode = match get("GP_MATCH_MODE").as_deref().unwrap_or("memo") {
"memo" => MatchMode::Memo, "memo" => MatchMode::Memo,
"derived" => MatchMode::Derived, "derived" => MatchMode::Derived,
@@ -394,6 +408,8 @@ impl Config {
relays, relays,
nym, nym,
ingest, ingest,
checkout_nostr,
checkout_slatepack,
match_mode, match_mode,
mnemonic, mnemonic,
wallet_password, wallet_password,
@@ -425,6 +441,18 @@ impl Config {
self.qr_logo.as_deref() 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. /// Fail-fast consistency checks.
fn validate(&self) -> Result<(), String> { fn validate(&self) -> Result<(), String> {
if self.bind.is_empty() { if self.bind.is_empty() {
@@ -479,7 +507,8 @@ impl Config {
let set = |o: bool| if o { "set" } else { "unset" }; let set = |o: bool| if o { "set" } else { "unset" };
format!( format!(
"bind={} tls={} db={} data_dir={} node={} chain={:?} relay_mode={:?} \ "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={} \ nsec={} ncryptsec={} public_url={} api_token={} admin_token={} webhook_url={} \
webhook_secret={} qr_logo={} merchant_npub={} notify_merchant_dm={} \ webhook_secret={} qr_logo={} merchant_npub={} notify_merchant_dm={} \
notify_payer_receipt={} endpub_rotate_interval={} endpub_overlap_epochs={} \ notify_payer_receipt={} endpub_rotate_interval={} endpub_overlap_epochs={} \
@@ -498,6 +527,7 @@ impl Config {
self.relays, self.relays,
if self.nym { "on" } else { "off" }, if self.nym { "on" } else { "off" },
if self.ingest { "on" } else { "off" }, if self.ingest { "on" } else { "off" },
self.checkout_methods_str(),
self.match_mode, self.match_mode,
set(self.mnemonic.is_some()), set(self.mnemonic.is_some()),
set(self.wallet_password.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. /// Parse an `on`/`off` flag with a default.
fn parse_bool( fn parse_bool(
get: &dyn Fn(&str) -> Option<String>, get: &dyn Fn(&str) -> Option<String>,
@@ -637,6 +696,36 @@ mod tests {
assert_eq!(cfg.match_mode, MatchMode::Derived); 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] #[test]
fn tls_rustls_requires_cert_and_key() { fn tls_rustls_requires_cert_and_key() {
assert!(load(&[("GP_TLS", "rustls")]).is_err()); assert!(load(&[("GP_TLS", "rustls")]).is_err());
+77 -2
View File
@@ -58,9 +58,17 @@ pub struct CheckoutInfo {
pub fn build_info(inv: &Invoice, cfg: &Config, slatepack_addr: Option<&str>) -> CheckoutInfo { pub fn build_info(inv: &Invoice, cfg: &Config, slatepack_addr: Option<&str>) -> CheckoutInfo {
let relays = gp_nostr::relays::resolve(&cfg.relays); let relays = gp_nostr::relays::resolve(&cfg.relays);
let recipient_pubkey = inv.recipient_pubkey.clone().unwrap_or_default(); 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)), Ok(pk) => (gp_nostr::npub_of(pk), gp_nostr::nprofile(pk, &relays)),
Err(_) => (String::new(), String::new()), 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 // 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 // (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 // 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 // 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. // 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 { 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(); let qr = qr::svg(addr, cfg.qr_logo_href()).unwrap_or_default();
(Some(addr.to_string()), Some(qr)) (Some(addr.to_string()), Some(qr))
} }
@@ -384,6 +394,71 @@ mod tests {
assert!(blank.slatepack_qr_svg.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] #[test]
fn amount_invoice_encodes_amount() { fn amount_invoice_encodes_amount() {
// 1.5 GRIN → nostr:<nprofile>?amount=1.5 // 1.5 GRIN → nostr:<nprofile>?amount=1.5
+2
View File
@@ -21,6 +21,7 @@
{% else %} {% else %}
<p class="status open">Waiting for payment&#8230;</p> <p class="status open">Waiting for payment&#8230;</p>
{% if !info.nprofile.is_empty() %}
<section class="pay-method"> <section class="pay-method">
<h2>Pay with Goblin Wallet</h2> <h2>Pay with Goblin Wallet</h2>
<div class="qr">{{ info.qr_svg|safe }}</div> <div class="qr">{{ info.qr_svg|safe }}</div>
@@ -28,6 +29,7 @@
<label for="nprofile">Payment address (nprofile)</label> <label for="nprofile">Payment address (nprofile)</label>
<textarea id="nprofile" class="copybox" rows="3" readonly>{{ info.nprofile }}</textarea> <textarea id="nprofile" class="copybox" rows="3" readonly>{{ info.nprofile }}</textarea>
</section> </section>
{% endif %}
{% if let Some(grin1) = info.slatepack_address %} {% if let Some(grin1) = info.slatepack_address %}
<section class="pay-method"> <section class="pay-method">