Compare commits
4 Commits
3f5b1fe49b
...
b8434fdd36
| Author | SHA1 | Date | |
|---|---|---|---|
| b8434fdd36 | |||
| 94d0c0edba | |||
| 3a80f7d505 | |||
| c362a9af21 |
@@ -17,9 +17,21 @@ carries the full merchant surface:
|
|||||||
invoice automatically.
|
invoice automatically.
|
||||||
- **Hosted checkout:** 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
|
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) with live status via
|
||||||
and a manual slatepack fallback (paste S1 -> offline `receive_tx` -> copy the
|
`<meta http-equiv=refresh>`. It offers two first-class ways to pay:
|
||||||
S2 back) on every page. The same renderer serves embedded and hosted use.
|
- **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
|
- **Per-user endpubs:** an admin assigns one receiving identity per user
|
||||||
(a derived child keyed by `(user_id, epoch)`; only public keys and the
|
(a derived child keyed by `(user_id, epoch)`; only public keys and the
|
||||||
rotation clock are stored, never private keys), with optional rolling rotation
|
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_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 |
|
||||||
@@ -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_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_URL` | unset | Webhook endpoint for payment events (requires `GP_WEBHOOK_SECRET`) |
|
||||||
| `GP_WEBHOOK_SECRET` | unset | HMAC-SHA256 secret for signing webhooks |
|
| `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_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_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 |
|
| `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_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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -171,7 +180,7 @@ pub struct Config {
|
|||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub webhook_secret: Option<Secret>,
|
pub webhook_secret: Option<Secret>,
|
||||||
/// Center-logo source for checkout QR codes (`GP_QR_LOGO`): unset = the
|
/// 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>,
|
pub qr_logo: Option<String>,
|
||||||
/// Merchant npub for confirmed-payment DMs (`GP_MERCHANT_NPUB`).
|
/// Merchant npub for confirmed-payment DMs (`GP_MERCHANT_NPUB`).
|
||||||
pub merchant_npub: Option<String>,
|
pub merchant_npub: Option<String>,
|
||||||
@@ -213,7 +222,7 @@ pub const DEFAULT_RATE_CACHE_TTL: i64 = 60;
|
|||||||
pub const DEFAULT_QUOTE_TTL: i64 = 900;
|
pub const DEFAULT_QUOTE_TTL: i64 = 900;
|
||||||
|
|
||||||
/// Default center-logo path served by gp-server when `GP_QR_LOGO` is unset.
|
/// 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 {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
@@ -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());
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
//! The hosted, zero-JS checkout: the `/pay/<token>` page (shared renderer for
|
//! The hosted, zero-JS checkout: the `/pay/<token>` page (shared renderer for
|
||||||
//! embedded and hosted use), its live status, and the manual-slatepack
|
//! embedded and hosted use), its live status, and the Slatepack receive flow.
|
||||||
//! fallback.
|
|
||||||
//!
|
//!
|
||||||
//! The page shows the amount, a server-generated QR SVG of the recipient
|
//! The page offers two first-class ways to pay: the Goblin/Nostr path (a QR
|
||||||
//! `nprofile`, the `nprofile`/`npub` strings, live status via a
|
//! SVG of the recipient `nprofile` plus the `nprofile`/`npub` strings) and a
|
||||||
//! `<meta http-equiv="refresh">` while open, and a `<textarea>` POST form to
|
//! Slatepack (`grin1`) path for any Grin wallet (the wallet's stable index-0
|
||||||
//! paste an S1 slatepack when the automatic Nostr flow cannot be used. On
|
//! Slatepack address, its QR, and a `<textarea>` POST form to paste the S1 the
|
||||||
//! submit, the same offline `receive_tx` runs and the S2 reply renders back for
|
//! payer's wallet produces). It also shows the amount and live status via a
|
||||||
//! the payer to copy and finalize. No JavaScript anywhere.
|
//! `<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 actix_web::{web, HttpResponse, Responder};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
@@ -32,6 +34,12 @@ pub struct CheckoutInfo {
|
|||||||
pub npub: String,
|
pub npub: String,
|
||||||
pub nprofile: String,
|
pub nprofile: String,
|
||||||
pub qr_svg: 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 amount_display: String,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub memo: Option<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,
|
/// 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
|
/// and a human amount. Shared by the hosted page and the connector API so both
|
||||||
/// render identically.
|
/// 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 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
|
||||||
Ok(pk) => (gp_nostr::npub_of(pk), gp_nostr::nprofile(pk, &relays)),
|
// enabled (`GP_CHECKOUT_METHODS`). Disabled, the nprofile/npub/QR are left
|
||||||
Err(_) => (String::new(), String::new()),
|
// 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 amount_display = amount_display(inv);
|
||||||
let token = inv.token.clone().unwrap_or_default();
|
let token = inv.token.clone().unwrap_or_default();
|
||||||
CheckoutInfo {
|
CheckoutInfo {
|
||||||
@@ -59,6 +103,8 @@ pub fn build_info(inv: &Invoice, cfg: &Config) -> CheckoutInfo {
|
|||||||
npub,
|
npub,
|
||||||
nprofile,
|
nprofile,
|
||||||
qr_svg,
|
qr_svg,
|
||||||
|
slatepack_address,
|
||||||
|
slatepack_qr_svg,
|
||||||
amount_display,
|
amount_display,
|
||||||
status: inv.status.clone(),
|
status: inv.status.clone(),
|
||||||
memo: inv.memo.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.
|
/// The checkout page template.
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "pay.html")]
|
#[template(path = "pay.html")]
|
||||||
@@ -94,7 +187,6 @@ struct PayPage {
|
|||||||
is_open: bool,
|
is_open: bool,
|
||||||
is_paid: bool,
|
is_paid: bool,
|
||||||
is_expired: bool,
|
is_expired: bool,
|
||||||
wallet_available: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The manual-slatepack result template (S2 to copy back).
|
/// The manual-slatepack result template (S2 to copy back).
|
||||||
@@ -131,12 +223,18 @@ async fn pay_page(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let status = inv.status();
|
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 {
|
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_open: status == InvoiceStatus::Open,
|
||||||
is_paid: status == InvoiceStatus::Paid,
|
is_paid: status == InvoiceStatus::Paid,
|
||||||
is_expired: status == InvoiceStatus::Expired,
|
is_expired: status == InvoiceStatus::Expired,
|
||||||
wallet_available: wallet.get_ref().is_some(),
|
|
||||||
};
|
};
|
||||||
render(page)
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -145,7 +145,9 @@ async fn create_invoice(
|
|||||||
.json(serde_json::json!({"error": "internal error"}));
|
.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))
|
HttpResponse::Ok().json(checkout_json(&info))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +163,7 @@ async fn get_invoice(
|
|||||||
}
|
}
|
||||||
match invoice::get(pool.get_ref(), &path.into_inner()).await {
|
match invoice::get(pool.get_ref(), &path.into_inner()).await {
|
||||||
Ok(Some(inv)) => {
|
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))
|
HttpResponse::Ok().json(checkout_json(&info))
|
||||||
}
|
}
|
||||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "not found"})),
|
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "not found"})),
|
||||||
|
|||||||
@@ -44,19 +44,40 @@ async fn style() -> impl Responder {
|
|||||||
.body(include_str!("../../../static/style.css"))
|
.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 {
|
async fn goblin_mark() -> impl Responder {
|
||||||
HttpResponse::Ok()
|
HttpResponse::Ok()
|
||||||
.content_type("image/svg+xml")
|
.content_type("image/svg+xml")
|
||||||
.body(include_str!("../../../static/goblin-mark.svg"))
|
.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.
|
/// Route table, shared by `main` and the tests.
|
||||||
fn routes(cfg: &mut web::ServiceConfig) {
|
fn routes(cfg: &mut web::ServiceConfig) {
|
||||||
cfg.route("/", web::get().to(index))
|
cfg.route("/", web::get().to(index))
|
||||||
.route("/health", web::get().to(health))
|
.route("/health", web::get().to(health))
|
||||||
.route("/static/style.css", web::get().to(style))
|
.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).
|
// Payment status + signed-receipt reads (public-by-token, M4).
|
||||||
payments::configure(cfg);
|
payments::configure(cfg);
|
||||||
// Hosted checkout + manual slatepack (public-by-token, M5).
|
// Hosted checkout + manual slatepack (public-by-token, M5).
|
||||||
|
|||||||
@@ -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("1.5 GRIN"), "amount shown");
|
||||||
assert!(html.contains("<svg"), "server-rendered QR present");
|
assert!(html.contains("<svg"), "server-rendered QR present");
|
||||||
assert!(html.contains("nprofile1"), "nprofile string present");
|
assert!(html.contains("nprofile1"), "nprofile string present");
|
||||||
|
assert!(
|
||||||
|
!html.contains("Pay by Slatepack"),
|
||||||
|
"no wallet loaded: the grin1 Slatepack option is omitted"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
html.contains("http-equiv=\"refresh\""),
|
html.contains("http-equiv=\"refresh\""),
|
||||||
"live status refresh while open"
|
"live status refresh while open"
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -31,6 +31,9 @@ main {
|
|||||||
|
|
||||||
main.admin { max-width: 60rem; }
|
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; }
|
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; }
|
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 summary { cursor: pointer; color: var(--accent); font-weight: 600; }
|
||||||
details.manual ol { color: var(--dim); font-size: 0.9rem; padding-left: 1.2rem; }
|
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); }
|
a { color: var(--accent); }
|
||||||
|
|
||||||
.footer { margin-top: 2rem; color: var(--dim); font-size: 0.78rem; text-align: center; }
|
.footer { margin-top: 2rem; color: var(--dim); font-size: 0.78rem; text-align: center; }
|
||||||
|
|||||||
+23
-15
@@ -9,6 +9,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="checkout">
|
<main class="checkout">
|
||||||
|
<a class="brand" href="/"><img class="brandmark" src="/static/goblinpay-wordmark.svg" alt="GoblinPay"></a>
|
||||||
<h1>Pay with Goblin</h1>
|
<h1>Pay with Goblin</h1>
|
||||||
<p class="amount">{{ info.amount_display }}</p>
|
<p class="amount">{{ info.amount_display }}</p>
|
||||||
|
|
||||||
@@ -19,30 +20,37 @@
|
|||||||
<p class="status expired">This invoice has expired.</p>
|
<p class="status expired">This invoice has expired.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="status open">Waiting for payment…</p>
|
<p class="status open">Waiting for payment…</p>
|
||||||
<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>
|
{% if !info.nprofile.is_empty() %}
|
||||||
<textarea id="nprofile" class="copybox" rows="3" readonly>{{ info.nprofile }}</textarea>
|
<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">
|
{% if let Some(grin1) = info.slatepack_address %}
|
||||||
<summary>Can't scan? Pay manually with a slatepack</summary>
|
<section class="pay-method">
|
||||||
{% if wallet_available %}
|
<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>
|
<ol>
|
||||||
<li>In your wallet, send {{ info.amount_display }} using the manual / slatepack option.</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>Paste the generated <strong>S1</strong> slatepack below and submit.</li>
|
<li>The pasted <strong>S1</strong> Slatepack is received here and a <strong>response</strong> Slatepack is returned.</li>
|
||||||
<li>Copy the <strong>response</strong> slatepack we return, back into your wallet to finalize and post.</li>
|
<li>Paste that response back into your wallet to finalize and broadcast it, which completes the payment.</li>
|
||||||
</ol>
|
</ol>
|
||||||
<form method="post" action="/pay/{{ info.token }}/slatepack">
|
<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
|
<textarea id="s1" name="slatepack" rows="6" required
|
||||||
placeholder="BEGINSLATEPACK. … ENDSLATEPACK."></textarea>
|
placeholder="BEGINSLATEPACK. … ENDSLATEPACK."></textarea>
|
||||||
<button type="submit">Submit slatepack</button>
|
<button type="submit">Submit slatepack</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
</section>
|
||||||
<p>Manual receive is unavailable on this instance.</p>
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
</details>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if let Some(memo) = info.memo %}<p class="memo">{{ memo }}</p>{% endif %}
|
{% if let Some(memo) = info.memo %}<p class="memo">{{ memo }}</p>{% endif %}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<textarea id="s2" class="copybox" rows="8" readonly>{{ s2_armor }}</textarea>
|
<textarea id="s2" class="copybox" rows="8" readonly>{{ s2_armor }}</textarea>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Select all of the text above and copy it.</li>
|
<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>
|
<li>Your wallet posts it to the chain; GoblinPay confirms it on receipt.</li>
|
||||||
</ol>
|
</ol>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
Reference in New Issue
Block a user