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.
|
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 GoblinPay-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
|
||||||
|
|||||||
@@ -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,7 +49,13 @@ 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) {
|
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)
|
pay_uri(&nprofile, inv)
|
||||||
};
|
};
|
||||||
let qr_svg = qr::svg(&qr_payload, cfg.qr_logo_href()).unwrap_or_default();
|
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 amount_display = amount_display(inv);
|
||||||
let token = inv.token.clone().unwrap_or_default();
|
let token = inv.token.clone().unwrap_or_default();
|
||||||
CheckoutInfo {
|
CheckoutInfo {
|
||||||
@@ -68,6 +93,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(),
|
||||||
@@ -150,7 +177,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).
|
||||||
@@ -187,12 +213,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)
|
||||||
}
|
}
|
||||||
@@ -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]
|
#[test]
|
||||||
fn amount_invoice_encodes_amount() {
|
fn amount_invoice_encodes_amount() {
|
||||||
// 1.5 GRIN → nostr:<nprofile>?amount=1.5
|
// 1.5 GRIN → nostr:<nprofile>?amount=1.5
|
||||||
|
|||||||
@@ -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"})),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 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; }
|
||||||
|
|||||||
+16
-11
@@ -20,30 +20,35 @@
|
|||||||
<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>
|
||||||
|
|
||||||
|
<section class="pay-method">
|
||||||
|
<h2>Pay with Goblin Wallet</h2>
|
||||||
<div class="qr">{{ info.qr_svg|safe }}</div>
|
<div class="qr">{{ info.qr_svg|safe }}</div>
|
||||||
<p class="hint">Scan with your Goblin Wallet, or copy the address below.</p>
|
<p class="hint">Scan with your Goblin Wallet, or copy the address below.</p>
|
||||||
|
|
||||||
<label for="nprofile">Payment address (nprofile)</label>
|
<label for="nprofile">Payment address (nprofile)</label>
|
||||||
<textarea id="nprofile" class="copybox" rows="3" readonly>{{ info.nprofile }}</textarea>
|
<textarea id="nprofile" class="copybox" rows="3" readonly>{{ info.nprofile }}</textarea>
|
||||||
|
</section>
|
||||||
|
|
||||||
<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