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:
@@ -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/<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 |
|
||||
@@ -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/<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
|
||||
|
||||
@@ -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<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());
|
||||
|
||||
@@ -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:<nprofile>?amount=1.5
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
{% else %}
|
||||
<p class="status open">Waiting for payment…</p>
|
||||
|
||||
{% if !info.nprofile.is_empty() %}
|
||||
<section class="pay-method">
|
||||
<h2>Pay with Goblin Wallet</h2>
|
||||
<div class="qr">{{ info.qr_svg|safe }}</div>
|
||||
@@ -28,6 +29,7 @@
|
||||
<label for="nprofile">Payment address (nprofile)</label>
|
||||
<textarea id="nprofile" class="copybox" rows="3" readonly>{{ info.nprofile }}</textarea>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(grin1) = info.slatepack_address %}
|
||||
<section class="pay-method">
|
||||
|
||||
Reference in New Issue
Block a user