Compare commits
4 Commits
3f5b1fe49b
...
b8434fdd36
| Author | SHA1 | Date | |
|---|---|---|---|
| b8434fdd36 | |||
| 94d0c0edba | |||
| 3a80f7d505 | |||
| c362a9af21 |
@@ -17,9 +17,21 @@ carries the full merchant surface:
|
||||
invoice automatically.
|
||||
- **Hosted checkout:** a zero-JS `/pay/<token>` page (server-rendered
|
||||
Askama + one CSS file + a server-generated QR SVG at ECC level H with an
|
||||
optional Goblin-mark center logo), live status via `<meta http-equiv=refresh>`,
|
||||
and a manual slatepack fallback (paste S1 -> offline `receive_tx` -> copy the
|
||||
S2 back) on every page. The same renderer serves embedded and hosted use.
|
||||
optional GoblinPay-mark center logo) with live status via
|
||||
`<meta http-equiv=refresh>`. It offers two first-class ways to pay:
|
||||
- **Goblin Wallet (Nostr):** scan the `nprofile` QR (or copy it) and the
|
||||
payment auto-receives over Nostr.
|
||||
- **Slatepack (`grin1`):** pay from any Grin wallet, no Nostr needed. The
|
||||
page shows the wallet's stable index-0 Slatepack address (`grin1...`) plus
|
||||
its QR and the exact amount to send. The payer sends that amount to the
|
||||
address using their wallet's Slatepack/file method, pastes the resulting S1
|
||||
into the page (offline `receive_tx`), then copies the returned S2 back into
|
||||
their wallet to finalize and broadcast it. There is no Tor listener; the
|
||||
`grin1` address is stable and reused across invoices, and the existing
|
||||
invoice matcher and on-chain confirmation handle the received payment like
|
||||
any other. The Slatepack option only appears when a wallet is loaded.
|
||||
|
||||
The same renderer serves embedded and hosted use.
|
||||
- **Per-user endpubs:** an admin assigns one receiving identity per user
|
||||
(a derived child keyed by `(user_id, epoch)`; only public keys and the
|
||||
rotation clock are stored, never private keys), with optional rolling rotation
|
||||
@@ -65,6 +77,7 @@ Everything is environment variables, defaults are safe for local use.
|
||||
| `GP_RELAYS` | unset | Comma-separated relay URLs |
|
||||
| `GP_NYM` | `on` | Route Nostr traffic over the Nym mixnet (`on` or `off`) |
|
||||
| `GP_INGEST` | `on` | Nostr ingest service (`off` = HTTP surface only, for debugging) |
|
||||
| `GP_CHECKOUT_METHODS` | `nostr,slatepack` | Which payment methods the hosted `/pay/<token>` page shows: comma list of `nostr` (Goblin Wallet) and `slatepack` (`grin1`). Unset = both. Unknown tokens are ignored; an empty result falls back to both |
|
||||
| `GP_MATCH_MODE` | `memo` | Default matching mode: `memo`, `derived`, `amount` |
|
||||
| `GP_MNEMONIC` | unset | Grin seed mnemonic (money secret) |
|
||||
| `GP_WALLET_PASSWORD` | unset | Password encrypting the wallet seed and the Nostr identity at rest |
|
||||
@@ -75,7 +88,7 @@ Everything is environment variables, defaults are safe for local use.
|
||||
| `GP_ADMIN_TOKEN` | unset | Bearer token for the admin dashboard + endpub/webhook API |
|
||||
| `GP_WEBHOOK_URL` | unset | Webhook endpoint for payment events (requires `GP_WEBHOOK_SECRET`) |
|
||||
| `GP_WEBHOOK_SECRET` | unset | HMAC-SHA256 secret for signing webhooks |
|
||||
| `GP_QR_LOGO` | Goblin mark | Checkout QR center logo: unset = Goblin mark, `off`/`none` = plain, else a URL/path |
|
||||
| `GP_QR_LOGO` | GoblinPay mark | Checkout QR center logo: unset = GoblinPay mark, `off`/`none` = plain, else a URL/path |
|
||||
| `GP_MERCHANT_NPUB` | unset | Merchant npub for the NIP-17 confirmed-payment DM |
|
||||
| `GP_NOTIFY_MERCHANT_DM` | `off` | Send a NIP-17 DM to the merchant on a received payment |
|
||||
| `GP_NOTIFY_PAYER_RECEIPT` | `off` | Send a NIP-17 receipt DM to the payer |
|
||||
@@ -87,6 +100,19 @@ Everything is environment variables, defaults are safe for local use.
|
||||
| `GP_QUOTE_TTL` | `900` | Seconds a created fiat invoice locks its Grin quote (its expiry window) |
|
||||
| `GP_RATE_STALE_MAX` | `0` | Bounded stale-rate fallback in seconds if a live fetch fails (0 = off) |
|
||||
|
||||
### Checkout methods
|
||||
|
||||
`GP_CHECKOUT_METHODS` only controls what the hosted `/pay/<token>` page
|
||||
advertises to a payer; it does not turn any payment processing on or off. The
|
||||
Slatepack (`grin1`) method also needs a loaded wallet to appear, so an enabled
|
||||
method that cannot work is simply hidden. Keep this consistent with `GP_INGEST`:
|
||||
`GP_INGEST` runs the Nostr ingest service that actually receives and matches
|
||||
Goblin Wallet payments, so `GP_INGEST=off` with `GP_CHECKOUT_METHODS=nostr`
|
||||
would advertise a Nostr method that nothing is listening for. If you disable
|
||||
ingest, drop `nostr` from `GP_CHECKOUT_METHODS`; if you advertise `nostr`, keep
|
||||
ingest on. The connector `POST /invoice` JSON response still returns the
|
||||
`nprofile` regardless of this setting, which affects only the hosted page.
|
||||
|
||||
### Conversion rates (optional)
|
||||
|
||||
A store that prices in fiat (for example cryptodrip.com prices in USD) sends
|
||||
|
||||
@@ -136,6 +136,15 @@ pub struct Config {
|
||||
/// Run the Nostr ingest service (`GP_INGEST`: `on` or `off`, default on).
|
||||
/// When on, the wallet and identity secrets are required at boot.
|
||||
pub ingest: bool,
|
||||
/// Show the Nostr (Goblin Wallet, `nprofile`) method on the hosted checkout
|
||||
/// page. Part of `GP_CHECKOUT_METHODS` (comma list of `nostr`/`slatepack`;
|
||||
/// unset = both). This gates only the hosted PAGE display, not the connector
|
||||
/// API or the ingest service.
|
||||
pub checkout_nostr: bool,
|
||||
/// Show the Slatepack (`grin1`) method on the hosted checkout page. Part of
|
||||
/// `GP_CHECKOUT_METHODS`. Still requires a loaded wallet to actually appear:
|
||||
/// an enabled method that cannot work is simply hidden.
|
||||
pub checkout_slatepack: bool,
|
||||
/// Global default matching mode (`GP_MATCH_MODE`).
|
||||
pub match_mode: MatchMode,
|
||||
/// Grin seed mnemonic (`GP_MNEMONIC` or `GP_MNEMONIC_FILE`). Money secret.
|
||||
@@ -171,7 +180,7 @@ pub struct Config {
|
||||
#[serde(skip)]
|
||||
pub webhook_secret: Option<Secret>,
|
||||
/// Center-logo source for checkout QR codes (`GP_QR_LOGO`): unset = the
|
||||
/// bundled Goblin mark, `off`/`none` = no logo, else a URL or static path.
|
||||
/// bundled GoblinPay mark, `off`/`none` = no logo, else a URL or static path.
|
||||
pub qr_logo: Option<String>,
|
||||
/// Merchant npub for confirmed-payment DMs (`GP_MERCHANT_NPUB`).
|
||||
pub merchant_npub: Option<String>,
|
||||
@@ -213,7 +222,7 @@ pub const DEFAULT_RATE_CACHE_TTL: i64 = 60;
|
||||
pub const DEFAULT_QUOTE_TTL: i64 = 900;
|
||||
|
||||
/// Default center-logo path served by gp-server when `GP_QR_LOGO` is unset.
|
||||
pub const DEFAULT_QR_LOGO: &str = "/static/goblin-mark.svg";
|
||||
pub const DEFAULT_QR_LOGO: &str = "/static/goblinpay-mark.svg";
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
@@ -228,6 +237,8 @@ impl Default for Config {
|
||||
relays: Vec::new(),
|
||||
nym: true,
|
||||
ingest: true,
|
||||
checkout_nostr: true,
|
||||
checkout_slatepack: true,
|
||||
match_mode: MatchMode::Memo,
|
||||
mnemonic: None,
|
||||
wallet_password: None,
|
||||
@@ -322,6 +333,9 @@ impl Config {
|
||||
other => return Err(format!("GP_INGEST must be `on` or `off` (got `{other}`)")),
|
||||
};
|
||||
|
||||
let (checkout_nostr, checkout_slatepack) =
|
||||
parse_checkout_methods(get("GP_CHECKOUT_METHODS"));
|
||||
|
||||
let match_mode = match get("GP_MATCH_MODE").as_deref().unwrap_or("memo") {
|
||||
"memo" => MatchMode::Memo,
|
||||
"derived" => MatchMode::Derived,
|
||||
@@ -394,6 +408,8 @@ impl Config {
|
||||
relays,
|
||||
nym,
|
||||
ingest,
|
||||
checkout_nostr,
|
||||
checkout_slatepack,
|
||||
match_mode,
|
||||
mnemonic,
|
||||
wallet_password,
|
||||
@@ -425,6 +441,18 @@ impl Config {
|
||||
self.qr_logo.as_deref()
|
||||
}
|
||||
|
||||
/// The enabled checkout methods as a stable comma list, for the startup log.
|
||||
fn checkout_methods_str(&self) -> String {
|
||||
let mut methods = Vec::new();
|
||||
if self.checkout_nostr {
|
||||
methods.push("nostr");
|
||||
}
|
||||
if self.checkout_slatepack {
|
||||
methods.push("slatepack");
|
||||
}
|
||||
methods.join(",")
|
||||
}
|
||||
|
||||
/// Fail-fast consistency checks.
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
if self.bind.is_empty() {
|
||||
@@ -479,7 +507,8 @@ impl Config {
|
||||
let set = |o: bool| if o { "set" } else { "unset" };
|
||||
format!(
|
||||
"bind={} tls={} db={} data_dir={} node={} chain={:?} relay_mode={:?} \
|
||||
relays={:?} nym={} ingest={} match_mode={:?} mnemonic={} wallet_password={} \
|
||||
relays={:?} nym={} ingest={} checkout_methods={} match_mode={:?} mnemonic={} \
|
||||
wallet_password={} \
|
||||
nsec={} ncryptsec={} public_url={} api_token={} admin_token={} webhook_url={} \
|
||||
webhook_secret={} qr_logo={} merchant_npub={} notify_merchant_dm={} \
|
||||
notify_payer_receipt={} endpub_rotate_interval={} endpub_overlap_epochs={} \
|
||||
@@ -498,6 +527,7 @@ impl Config {
|
||||
self.relays,
|
||||
if self.nym { "on" } else { "off" },
|
||||
if self.ingest { "on" } else { "off" },
|
||||
self.checkout_methods_str(),
|
||||
self.match_mode,
|
||||
set(self.mnemonic.is_some()),
|
||||
set(self.wallet_password.is_some()),
|
||||
@@ -527,6 +557,35 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `GP_CHECKOUT_METHODS` (comma list of `nostr`/`slatepack`) into the two
|
||||
/// display flags. Parsing is lenient: tokens are trimmed, lowercased, and
|
||||
/// unknown tokens are ignored with a warning. Unset (`None`) enables both, which
|
||||
/// preserves the historical default of showing every available method. If a set
|
||||
/// value parses to no known methods, both are enabled (a checkout must offer at
|
||||
/// least one way to pay) and a warning is logged.
|
||||
fn parse_checkout_methods(raw: Option<String>) -> (bool, bool) {
|
||||
let Some(raw) = raw else {
|
||||
return (true, true);
|
||||
};
|
||||
let mut nostr = false;
|
||||
let mut slatepack = false;
|
||||
for tok in raw.split(',') {
|
||||
match tok.trim().to_lowercase().as_str() {
|
||||
"" => {}
|
||||
"nostr" => nostr = true,
|
||||
"slatepack" => slatepack = true,
|
||||
other => log::warn!("GP_CHECKOUT_METHODS: ignoring unknown method `{other}`"),
|
||||
}
|
||||
}
|
||||
if !nostr && !slatepack {
|
||||
log::warn!(
|
||||
"GP_CHECKOUT_METHODS enabled no known methods; defaulting to both (nostr,slatepack)"
|
||||
);
|
||||
return (true, true);
|
||||
}
|
||||
(nostr, slatepack)
|
||||
}
|
||||
|
||||
/// Parse an `on`/`off` flag with a default.
|
||||
fn parse_bool(
|
||||
get: &dyn Fn(&str) -> Option<String>,
|
||||
@@ -637,6 +696,36 @@ mod tests {
|
||||
assert_eq!(cfg.match_mode, MatchMode::Derived);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_methods_default_and_parsing() {
|
||||
// Unset: both methods on (unchanged historical behavior).
|
||||
let cfg = load(&[]).unwrap();
|
||||
assert!(cfg.checkout_nostr);
|
||||
assert!(cfg.checkout_slatepack);
|
||||
|
||||
// Single method selects only that method.
|
||||
let cfg = load(&[("GP_CHECKOUT_METHODS", "nostr")]).unwrap();
|
||||
assert!(cfg.checkout_nostr);
|
||||
assert!(!cfg.checkout_slatepack);
|
||||
|
||||
let cfg = load(&[("GP_CHECKOUT_METHODS", "slatepack")]).unwrap();
|
||||
assert!(!cfg.checkout_nostr);
|
||||
assert!(cfg.checkout_slatepack);
|
||||
|
||||
// Both, order/whitespace/case insensitive, unknown tokens ignored.
|
||||
let cfg = load(&[("GP_CHECKOUT_METHODS", " Slatepack , NOSTR ,bogus,")]).unwrap();
|
||||
assert!(cfg.checkout_nostr);
|
||||
assert!(cfg.checkout_slatepack);
|
||||
|
||||
// Empty or all-garbage parses to no methods -> defaults to both.
|
||||
let cfg = load(&[("GP_CHECKOUT_METHODS", "")]).unwrap();
|
||||
assert!(cfg.checkout_nostr);
|
||||
assert!(cfg.checkout_slatepack);
|
||||
let cfg = load(&[("GP_CHECKOUT_METHODS", "lightning, bitcoin")]).unwrap();
|
||||
assert!(cfg.checkout_nostr);
|
||||
assert!(cfg.checkout_slatepack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_rustls_requires_cert_and_key() {
|
||||
assert!(load(&[("GP_TLS", "rustls")]).is_err());
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
//! The hosted, zero-JS checkout: the `/pay/<token>` page (shared renderer for
|
||||
//! embedded and hosted use), its live status, and the manual-slatepack
|
||||
//! fallback.
|
||||
//! embedded and hosted use), its live status, and the Slatepack receive flow.
|
||||
//!
|
||||
//! The page shows the amount, a server-generated QR SVG of the recipient
|
||||
//! `nprofile`, the `nprofile`/`npub` strings, live status via a
|
||||
//! `<meta http-equiv="refresh">` while open, and a `<textarea>` POST form to
|
||||
//! paste an S1 slatepack when the automatic Nostr flow cannot be used. On
|
||||
//! submit, the same offline `receive_tx` runs and the S2 reply renders back for
|
||||
//! the payer to copy and finalize. No JavaScript anywhere.
|
||||
//! The page offers two first-class ways to pay: the Goblin/Nostr path (a QR
|
||||
//! SVG of the recipient `nprofile` plus the `nprofile`/`npub` strings) and a
|
||||
//! Slatepack (`grin1`) path for any Grin wallet (the wallet's stable index-0
|
||||
//! Slatepack address, its QR, and a `<textarea>` POST form to paste the S1 the
|
||||
//! payer's wallet produces). It also shows the amount and live status via a
|
||||
//! `<meta http-equiv="refresh">` while open. On submit, the same offline
|
||||
//! `receive_tx` runs and the S2 reply renders back for the payer to finalize
|
||||
//! and broadcast. The Slatepack path only appears when a wallet is loaded. No
|
||||
//! JavaScript anywhere.
|
||||
|
||||
use actix_web::{web, HttpResponse, Responder};
|
||||
use askama::Template;
|
||||
@@ -32,6 +34,12 @@ pub struct CheckoutInfo {
|
||||
pub npub: String,
|
||||
pub nprofile: String,
|
||||
pub qr_svg: String,
|
||||
/// The wallet's stable `grin1` Slatepack address, when a wallet is loaded.
|
||||
/// `None` (and no Slatepack option shown) when the instance runs with no
|
||||
/// wallet, mirroring how the manual receive handler degrades.
|
||||
pub slatepack_address: Option<String>,
|
||||
/// QR SVG of the `grin1` Slatepack address (present with the address).
|
||||
pub slatepack_qr_svg: Option<String>,
|
||||
pub amount_display: String,
|
||||
pub status: String,
|
||||
pub memo: Option<String>,
|
||||
@@ -41,14 +49,50 @@ pub struct CheckoutInfo {
|
||||
/// Build the presentation for an invoice: the nprofile, its QR, the pay URL,
|
||||
/// and a human amount. Shared by the hosted page and the connector API so both
|
||||
/// render identically.
|
||||
pub fn build_info(inv: &Invoice, cfg: &Config) -> CheckoutInfo {
|
||||
///
|
||||
/// `slatepack_addr` is the wallet's stable `grin1` Slatepack address when a
|
||||
/// wallet is loaded (the hosted page passes it so a payer can pay from any Grin
|
||||
/// wallet without Nostr); pass `None` when no wallet is available or the
|
||||
/// caller does not surface the Slatepack option (e.g. the JSON connector API),
|
||||
/// in which case no Slatepack address or QR is produced.
|
||||
pub fn build_info(inv: &Invoice, cfg: &Config, slatepack_addr: Option<&str>) -> CheckoutInfo {
|
||||
let relays = gp_nostr::relays::resolve(&cfg.relays);
|
||||
let recipient_pubkey = inv.recipient_pubkey.clone().unwrap_or_default();
|
||||
let (npub, nprofile) = match PublicKey::from_hex(&recipient_pubkey) {
|
||||
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
|
||||
// unchanged — only the QR payload gains the query. An invalid pubkey yields
|
||||
// an empty nprofile; keep that empty (no useless `nostr:` QR).
|
||||
let qr_payload = if nprofile.is_empty() {
|
||||
nprofile.clone()
|
||||
} else {
|
||||
pay_uri(&nprofile, inv)
|
||||
};
|
||||
let qr_svg = qr::svg(&qr_payload, cfg.qr_logo_href()).unwrap_or_default();
|
||||
// The Slatepack (grin1) address is stable and reused across invoices; its
|
||||
// QR carries the bare address (a Grin wallet reads no amount from it, so
|
||||
// the page states the amount to send in text next to it). No address means
|
||||
// no wallet loaded: the page simply omits the Slatepack option.
|
||||
// The Slatepack method needs both operator opt-in (`GP_CHECKOUT_METHODS`)
|
||||
// and a loaded wallet: an enabled method that cannot work is simply hidden.
|
||||
let (slatepack_address, slatepack_qr_svg) = match slatepack_addr {
|
||||
Some(addr) if cfg.checkout_slatepack && !addr.is_empty() => {
|
||||
let qr = qr::svg(addr, cfg.qr_logo_href()).unwrap_or_default();
|
||||
(Some(addr.to_string()), Some(qr))
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
let qr_svg = qr::svg(&nprofile, cfg.qr_logo_href()).unwrap_or_default();
|
||||
let amount_display = amount_display(inv);
|
||||
let token = inv.token.clone().unwrap_or_default();
|
||||
CheckoutInfo {
|
||||
@@ -59,6 +103,8 @@ pub fn build_info(inv: &Invoice, cfg: &Config) -> CheckoutInfo {
|
||||
npub,
|
||||
nprofile,
|
||||
qr_svg,
|
||||
slatepack_address,
|
||||
slatepack_qr_svg,
|
||||
amount_display,
|
||||
status: inv.status.clone(),
|
||||
memo: inv.memo.clone(),
|
||||
@@ -86,6 +132,53 @@ fn amount_display(inv: &Invoice) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the QR pay-URI for an invoice: `nostr:<nprofile>`, plus `?amount=`
|
||||
/// when the invoice has an exact expected amount, plus `&memo=` when it carries
|
||||
/// a human memo. A scanning Goblin wallet auto-fills the amount (and note) from
|
||||
/// this; open-amount invoices stay a bare `nostr:<nprofile>`.
|
||||
///
|
||||
/// The URI never carries the invoice token or any key — only the already-public
|
||||
/// recipient nprofile, relay hints, the amount, and the human memo shown on the
|
||||
/// page. `expected_amount` is a locked nanogrin quote (i64 in the DB, always
|
||||
/// non-negative here); only strictly positive amounts are emitted.
|
||||
fn pay_uri(nprofile: &str, inv: &Invoice) -> String {
|
||||
let mut uri = format!("nostr:{nprofile}");
|
||||
let mut sep = '?';
|
||||
if let Some(nano) = inv.expected_amount {
|
||||
if nano > 0 {
|
||||
uri.push(sep);
|
||||
uri.push_str("amount=");
|
||||
uri.push_str(&nanogrin_to_grin(nano as u64));
|
||||
sep = '&';
|
||||
}
|
||||
}
|
||||
if let Some(memo) = inv.memo.as_deref() {
|
||||
let memo = memo.trim();
|
||||
if !memo.is_empty() {
|
||||
uri.push(sep);
|
||||
uri.push_str("memo=");
|
||||
uri.push_str(&percent_encode(memo));
|
||||
}
|
||||
}
|
||||
uri
|
||||
}
|
||||
|
||||
/// Minimal RFC-3986 percent-encoding for a query value: keep the unreserved set
|
||||
/// (`A-Z a-z 0-9 - . _ ~`), percent-escape every other byte. Small and
|
||||
/// dependency-free (gp-core has no percent-encoding crate).
|
||||
fn percent_encode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for &b in s.as_bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||
out.push(b as char)
|
||||
}
|
||||
_ => out.push_str(&format!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The checkout page template.
|
||||
#[derive(Template)]
|
||||
#[template(path = "pay.html")]
|
||||
@@ -94,7 +187,6 @@ struct PayPage {
|
||||
is_open: bool,
|
||||
is_paid: bool,
|
||||
is_expired: bool,
|
||||
wallet_available: bool,
|
||||
}
|
||||
|
||||
/// The manual-slatepack result template (S2 to copy back).
|
||||
@@ -131,12 +223,18 @@ async fn pay_page(
|
||||
}
|
||||
};
|
||||
let status = inv.status();
|
||||
// Surface the wallet's stable grin1 Slatepack address (same wallet handle
|
||||
// the manual receive uses). No wallet loaded, or the address cannot be
|
||||
// derived, means no Slatepack option is shown.
|
||||
let slatepack_addr = wallet
|
||||
.get_ref()
|
||||
.as_ref()
|
||||
.and_then(|w| w.slatepack_address().ok());
|
||||
let page = PayPage {
|
||||
info: build_info(&inv, cfg.get_ref()),
|
||||
info: build_info(&inv, cfg.get_ref(), slatepack_addr.as_deref()),
|
||||
is_open: status == InvoiceStatus::Open,
|
||||
is_paid: status == InvoiceStatus::Paid,
|
||||
is_expired: status == InvoiceStatus::Expired,
|
||||
wallet_available: wallet.get_ref().is_some(),
|
||||
};
|
||||
render(page)
|
||||
}
|
||||
@@ -243,3 +341,184 @@ fn render<T: Template>(page: T) -> HttpResponse {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A minimal invoice fixture: only the fields the QR pay-URI reads matter.
|
||||
fn invoice(expected_amount: Option<i64>, memo: Option<&str>) -> Invoice {
|
||||
Invoice {
|
||||
id: "inv_1".into(),
|
||||
order_ref: None,
|
||||
expected_amount,
|
||||
expiry: None,
|
||||
status: "open".into(),
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
token: Some("secret-token-should-never-leak".into()),
|
||||
memo: memo.map(str::to_string),
|
||||
recipient_pubkey: Some("aa".repeat(32)),
|
||||
fiat_amount: None,
|
||||
fiat_currency: None,
|
||||
match_mode: None,
|
||||
paid_payment_id: None,
|
||||
paid_at: None,
|
||||
quote_rate: None,
|
||||
quote_source: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_info_surfaces_slatepack_address_when_wallet_loaded() {
|
||||
// A loaded wallet passes its grin1 address: build_info exposes it plus
|
||||
// a QR for it, so the hosted page can show the Slatepack option.
|
||||
let inv = invoice(Some(1_500_000_000), None);
|
||||
let cfg = Config::default();
|
||||
let info = build_info(&inv, &cfg, Some("grin1qtestaddress"));
|
||||
assert_eq!(info.slatepack_address.as_deref(), Some("grin1qtestaddress"));
|
||||
let qr = info.slatepack_qr_svg.expect("slatepack QR present");
|
||||
assert!(qr.contains("<svg"), "grin1 QR is an SVG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_info_omits_slatepack_when_no_wallet() {
|
||||
// No wallet (None) or a blank address: no Slatepack address or QR, so
|
||||
// the page simply does not show the Slatepack option.
|
||||
let inv = invoice(Some(1_500_000_000), None);
|
||||
let cfg = Config::default();
|
||||
let info = build_info(&inv, &cfg, None);
|
||||
assert!(info.slatepack_address.is_none());
|
||||
assert!(info.slatepack_qr_svg.is_none());
|
||||
let blank = build_info(&inv, &cfg, Some(""));
|
||||
assert!(blank.slatepack_address.is_none());
|
||||
assert!(blank.slatepack_qr_svg.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_nostr_disabled_hides_nostr_section() {
|
||||
// GP_CHECKOUT_METHODS=slatepack: the Nostr method is off, so build_info
|
||||
// leaves the nprofile/npub empty and the page omits the Nostr section
|
||||
// while still showing the Slatepack one.
|
||||
let inv = invoice(Some(1_500_000_000), None);
|
||||
let mut cfg = Config::default();
|
||||
cfg.checkout_nostr = false;
|
||||
cfg.checkout_slatepack = true;
|
||||
let info = build_info(&inv, &cfg, Some("grin1qtestaddress"));
|
||||
assert!(info.nprofile.is_empty(), "nprofile empty when nostr off");
|
||||
assert!(info.npub.is_empty(), "npub empty when nostr off");
|
||||
assert_eq!(info.slatepack_address.as_deref(), Some("grin1qtestaddress"));
|
||||
|
||||
let page = PayPage {
|
||||
info,
|
||||
is_open: true,
|
||||
is_paid: false,
|
||||
is_expired: false,
|
||||
};
|
||||
let html = page.render().unwrap();
|
||||
assert!(
|
||||
!html.contains("Pay with Goblin Wallet"),
|
||||
"Nostr section absent when checkout_nostr=false"
|
||||
);
|
||||
assert!(
|
||||
html.contains("Pay by Slatepack"),
|
||||
"Slatepack section still present"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_slatepack_disabled_hides_slatepack_section() {
|
||||
// GP_CHECKOUT_METHODS=nostr: the Slatepack method is off, so even with a
|
||||
// wallet address available, build_info drops it and the page omits the
|
||||
// Slatepack section while still showing the Nostr one.
|
||||
let inv = invoice(Some(1_500_000_000), None);
|
||||
let mut cfg = Config::default();
|
||||
cfg.checkout_nostr = true;
|
||||
cfg.checkout_slatepack = false;
|
||||
let info = build_info(&inv, &cfg, Some("grin1qtestaddress"));
|
||||
assert!(
|
||||
info.slatepack_address.is_none(),
|
||||
"slatepack dropped when method off"
|
||||
);
|
||||
assert!(info.slatepack_qr_svg.is_none());
|
||||
assert!(!info.nprofile.is_empty(), "nprofile present when nostr on");
|
||||
|
||||
let page = PayPage {
|
||||
info,
|
||||
is_open: true,
|
||||
is_paid: false,
|
||||
is_expired: false,
|
||||
};
|
||||
let html = page.render().unwrap();
|
||||
assert!(
|
||||
html.contains("Pay with Goblin Wallet"),
|
||||
"Nostr section present"
|
||||
);
|
||||
assert!(
|
||||
!html.contains("Pay by Slatepack"),
|
||||
"Slatepack section absent when checkout_slatepack=false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn amount_invoice_encodes_amount() {
|
||||
// 1.5 GRIN → nostr:<nprofile>?amount=1.5
|
||||
let inv = invoice(Some(1_500_000_000), None);
|
||||
assert_eq!(
|
||||
pay_uri("nprofile1abc", &inv),
|
||||
"nostr:nprofile1abc?amount=1.5"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_amount_invoice_stays_bare() {
|
||||
// Open amount (no expected_amount, no memo) → bare nostr:<nprofile>.
|
||||
let inv = invoice(None, None);
|
||||
assert_eq!(pay_uri("nprofile1abc", &inv), "nostr:nprofile1abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn amount_and_memo_encoded() {
|
||||
let inv = invoice(Some(1_000_000_000), Some("Coffee & cake"));
|
||||
assert_eq!(
|
||||
pay_uri("nprofile1abc", &inv),
|
||||
"nostr:nprofile1abc?amount=1&memo=Coffee%20%26%20cake"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memo_only_uses_question_mark() {
|
||||
// No amount but a memo → the memo is the first (and only) query param.
|
||||
let inv = invoice(None, Some("hi"));
|
||||
assert_eq!(pay_uri("nprofile1abc", &inv), "nostr:nprofile1abc?memo=hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_and_blank_are_treated_as_open() {
|
||||
assert_eq!(
|
||||
pay_uri("nprofile1abc", &invoice(Some(0), None)),
|
||||
"nostr:nprofile1abc"
|
||||
);
|
||||
// A whitespace-only memo is dropped.
|
||||
assert_eq!(
|
||||
pay_uri("nprofile1abc", &invoice(None, Some(" "))),
|
||||
"nostr:nprofile1abc"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uri_never_leaks_token_or_key() {
|
||||
// The token and recipient private material must never appear in the QR.
|
||||
let inv = invoice(Some(2_000_000_000), Some("order 42"));
|
||||
let uri = pay_uri("nprofile1abc", &inv);
|
||||
assert!(!uri.contains("secret-token-should-never-leak"));
|
||||
assert!(!uri.contains("token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_encode_covers_reserved_and_unicode() {
|
||||
assert_eq!(percent_encode("a-b_c.d~e"), "a-b_c.d~e");
|
||||
assert_eq!(percent_encode("a b&c=d"), "a%20b%26c%3Dd");
|
||||
// Multi-byte UTF-8 is percent-encoded byte-by-byte.
|
||||
assert_eq!(percent_encode("é"), "%C3%A9");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +145,9 @@ async fn create_invoice(
|
||||
.json(serde_json::json!({"error": "internal error"}));
|
||||
}
|
||||
};
|
||||
let info = build_info(&inv, cfg.get_ref());
|
||||
// The JSON connector API surfaces the Nostr checkout fields only; the
|
||||
// grin1 Slatepack option is presented on the hosted /pay page.
|
||||
let info = build_info(&inv, cfg.get_ref(), None);
|
||||
HttpResponse::Ok().json(checkout_json(&info))
|
||||
}
|
||||
|
||||
@@ -161,7 +163,7 @@ async fn get_invoice(
|
||||
}
|
||||
match invoice::get(pool.get_ref(), &path.into_inner()).await {
|
||||
Ok(Some(inv)) => {
|
||||
let info = build_info(&inv, cfg.get_ref());
|
||||
let info = build_info(&inv, cfg.get_ref(), None);
|
||||
HttpResponse::Ok().json(checkout_json(&info))
|
||||
}
|
||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "not found"})),
|
||||
|
||||
@@ -44,19 +44,40 @@ async fn style() -> impl Responder {
|
||||
.body(include_str!("../../../static/style.css"))
|
||||
}
|
||||
|
||||
/// The bundled Goblin mark, the default QR center logo.
|
||||
/// The bundled Goblin mark (legacy default QR center logo; still served so an
|
||||
/// operator can keep `GP_QR_LOGO=/static/goblin-mark.svg`).
|
||||
async fn goblin_mark() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.content_type("image/svg+xml")
|
||||
.body(include_str!("../../../static/goblin-mark.svg"))
|
||||
}
|
||||
|
||||
/// The GoblinPay mark, the default QR center logo (dark "P" on the brand gold,
|
||||
/// sized for contrast on the QR's white backing).
|
||||
async fn goblinpay_mark() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.content_type("image/svg+xml")
|
||||
.body(include_str!("../../../static/goblinpay-mark.svg"))
|
||||
}
|
||||
|
||||
/// The GoblinPay wordmark (white), shown as the checkout page header logo.
|
||||
async fn goblinpay_wordmark() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.content_type("image/svg+xml")
|
||||
.body(include_str!("../../../static/goblinpay-wordmark.svg"))
|
||||
}
|
||||
|
||||
/// Route table, shared by `main` and the tests.
|
||||
fn routes(cfg: &mut web::ServiceConfig) {
|
||||
cfg.route("/", web::get().to(index))
|
||||
.route("/health", web::get().to(health))
|
||||
.route("/static/style.css", web::get().to(style))
|
||||
.route("/static/goblin-mark.svg", web::get().to(goblin_mark));
|
||||
.route("/static/goblin-mark.svg", web::get().to(goblin_mark))
|
||||
.route("/static/goblinpay-mark.svg", web::get().to(goblinpay_mark))
|
||||
.route(
|
||||
"/static/goblinpay-wordmark.svg",
|
||||
web::get().to(goblinpay_wordmark),
|
||||
);
|
||||
// Payment status + signed-receipt reads (public-by-token, M4).
|
||||
payments::configure(cfg);
|
||||
// Hosted checkout + manual slatepack (public-by-token, M5).
|
||||
|
||||
@@ -164,6 +164,10 @@ async fn pay_page_renders_zero_js_with_qr_and_nprofile() {
|
||||
assert!(html.contains("1.5 GRIN"), "amount shown");
|
||||
assert!(html.contains("<svg"), "server-rendered QR present");
|
||||
assert!(html.contains("nprofile1"), "nprofile string present");
|
||||
assert!(
|
||||
!html.contains("Pay by Slatepack"),
|
||||
"no wallet loaded: the grin1 Slatepack option is omitted"
|
||||
);
|
||||
assert!(
|
||||
html.contains("http-equiv=\"refresh\""),
|
||||
"live status refresh while open"
|
||||
|
||||
@@ -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; }
|
||||
|
||||
.brand { display: block; }
|
||||
.brandmark { height: 30px; width: auto; display: block; margin: 0 0 1.25rem; }
|
||||
|
||||
h1 { font-size: 1.5rem; margin: 0 0 0.5rem; }
|
||||
h2 { font-size: 1.05rem; margin: 1.75rem 0 0.5rem; color: var(--dim); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
|
||||
@@ -92,6 +95,11 @@ details.manual { margin-top: 1.5rem; border-top: 1px solid var(--line); padding-
|
||||
details.manual summary { cursor: pointer; color: var(--accent); font-weight: 600; }
|
||||
details.manual ol { color: var(--dim); font-size: 0.9rem; padding-left: 1.2rem; }
|
||||
|
||||
/* Each first-class way to pay (Goblin/Nostr, Slatepack/grin1). */
|
||||
.pay-method { margin-top: 1.5rem; border-top: 1px solid var(--line); padding-top: 0.75rem; }
|
||||
.pay-method:first-of-type { border-top: none; padding-top: 0; }
|
||||
.pay-method ol { color: var(--dim); font-size: 0.9rem; padding-left: 1.2rem; }
|
||||
|
||||
a { color: var(--accent); }
|
||||
|
||||
.footer { margin-top: 2rem; color: var(--dim); font-size: 0.78rem; text-align: center; }
|
||||
|
||||
+23
-15
@@ -9,6 +9,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<main class="checkout">
|
||||
<a class="brand" href="/"><img class="brandmark" src="/static/goblinpay-wordmark.svg" alt="GoblinPay"></a>
|
||||
<h1>Pay with Goblin</h1>
|
||||
<p class="amount">{{ info.amount_display }}</p>
|
||||
|
||||
@@ -19,30 +20,37 @@
|
||||
<p class="status expired">This invoice has expired.</p>
|
||||
{% else %}
|
||||
<p class="status open">Waiting for payment…</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>
|
||||
<textarea id="nprofile" class="copybox" rows="3" readonly>{{ info.nprofile }}</textarea>
|
||||
{% if !info.nprofile.is_empty() %}
|
||||
<section class="pay-method">
|
||||
<h2>Pay with Goblin Wallet</h2>
|
||||
<div class="qr">{{ info.qr_svg|safe }}</div>
|
||||
<p class="hint">Scan with your Goblin Wallet, or copy the address below.</p>
|
||||
<label for="nprofile">Payment address (nprofile)</label>
|
||||
<textarea id="nprofile" class="copybox" rows="3" readonly>{{ info.nprofile }}</textarea>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<details class="manual">
|
||||
<summary>Can't scan? Pay manually with a slatepack</summary>
|
||||
{% if wallet_available %}
|
||||
{% if let Some(grin1) = info.slatepack_address %}
|
||||
<section class="pay-method">
|
||||
<h2>Pay by Slatepack (grin1)</h2>
|
||||
<p class="hint">Pay from any Grin wallet, no Nostr needed. Send <strong>{{ info.amount_display }}</strong> to the address below.</p>
|
||||
{% if let Some(grin1_qr) = info.slatepack_qr_svg %}<div class="qr">{{ grin1_qr|safe }}</div>{% endif %}
|
||||
<label for="grin1">Slatepack address (grin1)</label>
|
||||
<textarea id="grin1" class="copybox" rows="3" readonly>{{ grin1 }}</textarea>
|
||||
<ol>
|
||||
<li>In your wallet, send {{ info.amount_display }} using the manual / slatepack option.</li>
|
||||
<li>Paste the generated <strong>S1</strong> slatepack below and submit.</li>
|
||||
<li>Copy the <strong>response</strong> slatepack we return, back into your wallet to finalize and post.</li>
|
||||
<li>In your Grin wallet, send {{ info.amount_display }} to this address using the Slatepack / file method, then paste the Slatepack it produces below.</li>
|
||||
<li>The pasted <strong>S1</strong> Slatepack is received here and a <strong>response</strong> Slatepack is returned.</li>
|
||||
<li>Paste that response back into your wallet to finalize and broadcast it, which completes the payment.</li>
|
||||
</ol>
|
||||
<form method="post" action="/pay/{{ info.token }}/slatepack">
|
||||
<label for="s1">Your slatepack (S1)</label>
|
||||
<label for="s1">Your Slatepack (S1)</label>
|
||||
<textarea id="s1" name="slatepack" rows="6" required
|
||||
placeholder="BEGINSLATEPACK. … ENDSLATEPACK."></textarea>
|
||||
<button type="submit">Submit slatepack</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>Manual receive is unavailable on this instance.</p>
|
||||
{% endif %}
|
||||
</details>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% 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>
|
||||
<ol>
|
||||
<li>Select all of the text above and copy it.</li>
|
||||
<li>Paste it back into your wallet to finalize the transaction.</li>
|
||||
<li>Paste it back into your wallet to finalize and broadcast the transaction; this completes the payment.</li>
|
||||
<li>Your wallet posts it to the chain; GoblinPay confirms it on receipt.</li>
|
||||
</ol>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user