checkout: first-class grin1 / Slatepack payment method
The hosted /pay page now shows the wallet's grin1 Slatepack address (with a QR and the exact amount) as a payment method alongside the Goblin/Nostr option. A payer sends the amount from any Grin wallet via the Slatepack or file method, pastes the S1 into the existing paste box, receives an S2, and finalizes to complete the payment. Reuses the existing offline receive_tx flow bound to the invoice token; the Nostr gift-wrap path, the invoice matcher, and the proof/confirm logic are unchanged. No Tor listener. The grin1 address is the wallet's stable index-0 address.
This commit is contained in:
@@ -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 GoblinPay-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
|
||||
|
||||
@@ -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,7 +49,13 @@ 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) {
|
||||
@@ -58,6 +72,17 @@ pub fn build_info(inv: &Invoice, cfg: &Config) -> CheckoutInfo {
|
||||
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.
|
||||
let (slatepack_address, slatepack_qr_svg) = match slatepack_addr {
|
||||
Some(addr) if !addr.is_empty() => {
|
||||
let qr = qr::svg(addr, cfg.qr_logo_href()).unwrap_or_default();
|
||||
(Some(addr.to_string()), Some(qr))
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
let amount_display = amount_display(inv);
|
||||
let token = inv.token.clone().unwrap_or_default();
|
||||
CheckoutInfo {
|
||||
@@ -68,6 +93,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(),
|
||||
@@ -150,7 +177,6 @@ struct PayPage {
|
||||
is_open: bool,
|
||||
is_paid: bool,
|
||||
is_expired: bool,
|
||||
wallet_available: bool,
|
||||
}
|
||||
|
||||
/// The manual-slatepack result template (S2 to copy back).
|
||||
@@ -187,12 +213,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)
|
||||
}
|
||||
@@ -326,6 +358,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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 amount_invoice_encodes_amount() {
|
||||
// 1.5 GRIN → nostr:<nprofile>?amount=1.5
|
||||
|
||||
@@ -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"})),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -95,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; }
|
||||
|
||||
+20
-15
@@ -20,30 +20,35 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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