grin1 rail: operator opt-in, packaged default OFF (owner ruling)
GP_GRIN1_RAIL now defaults OFF. Unset/off, GoblinPay behaves like the pre-rail server: no arti/Tor code path runs, no loopback Foreign API listener, no invoice slates issued, and the pay page shows ONLY 'Pay with Goblin' (no switcher, no grin1 UI, footer unchanged) — even for an invoice armed while the rail was on. Enabled, the two-rail switcher renders with the Goblin tab default-selected; the switcher chrome appears only when BOTH rails are available (a single rail renders its panel directly). - config: default flip + docs; env example + README gain the opt-in block. - checkout: build_info gates all grin1 UI on the flag; tests for both states (off => no rail-tab/rail-radio/panel-grin/grin1 strings; on => switcher with rail-goblin checked). - sqlx 0.8 -> 0.9 (workspace): its libsqlite3-sys range (<0.38) unifies with arti's rusqlite, resolving the one-links-per-native-lib conflict that otherwise forbids sqlx-sqlite + in-process arti in one binary. Feature map: runtime-tokio + sqlite (bundled, as before); dynamic COLUMNS queries wrapped in sqlx::AssertSqlSafe (compile-time composed strings).
This commit is contained in:
Generated
+3588
-257
File diff suppressed because it is too large
Load Diff
+8
-2
@@ -16,8 +16,14 @@ license = "Apache-2.0"
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sqlx = { version = "0.8", default-features = false, features = [
|
||||
"runtime-tokio-rustls",
|
||||
# sqlx 0.9: its libsqlite3-sys range (>=0.30.1, <0.38) unifies with the
|
||||
# rusqlite inside arti's tor-dirmgr (libsqlite3-sys 0.34) — cargo's one-
|
||||
# `links = "sqlite3"` rule otherwise forbids linking sqlx-sqlite and the grin1
|
||||
# rail's in-process arti in one binary. 0.9 split the old runtime-tokio-rustls
|
||||
# feature: TLS is a network-database concern, so SQLite needs none of it.
|
||||
# `sqlite` = bundled, as before.
|
||||
sqlx = { version = "0.9", default-features = false, features = [
|
||||
"runtime-tokio",
|
||||
"sqlite",
|
||||
"migrate",
|
||||
"macros",
|
||||
|
||||
@@ -83,6 +83,8 @@ Everything is environment variables, defaults are safe for local use.
|
||||
| `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` (manual paste). Unset = both. Unknown tokens are ignored; an empty result falls back to both |
|
||||
| `GP_CONFIRMATIONS` | `10` | House standard: on-chain depth the paying kernel must reach before an invoice flips from `paid` to `confirmed` |
|
||||
| `GP_GRIN1_RAIL` | `off` | Operator opt-in grin1/Tor rail. `on` = the till also accepts payments from any Grin wallet over Tor: an onion service (identity = the till's grin1 slatepack address key, so grin1 address == onion address) serves the Grin Foreign API v2, invoices carry a native Grin invoice slatepack, and the pay page gains a two-rail switcher (Goblin stays the default tab). Off/unset = Goblin/Nostr only, byte-for-byte the pre-rail behavior |
|
||||
| `GP_GRIN1_FOREIGN_PORT` | `3416` | Loopback port the Foreign API v2 binds; the onion service proxies `onion:80` to it (only used with `GP_GRIN1_RAIL=on`) |
|
||||
| `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 |
|
||||
|
||||
@@ -232,11 +232,14 @@ pub struct Config {
|
||||
/// many confirmations the invoice advances `paid` -> `confirmed`. Must be
|
||||
/// >= 1.
|
||||
pub confirmations_required: i64,
|
||||
/// Arm the native grin1 payment rail (`GP_GRIN1_RAIL`, default on). When on
|
||||
/// and a wallet is loaded, an exact-amount invoice also issues a Grin
|
||||
/// invoice slate (the "pay with any Grin wallet" primary path) and the Tor
|
||||
/// foreign endpoint is served. Off (or with no wallet) the invoice flow is
|
||||
/// not armed and only the existing rails show.
|
||||
/// Arm the native grin1 payment rail (`GP_GRIN1_RAIL`, packaged default
|
||||
/// OFF, owner ruling). Off (or unset), GoblinPay behaves exactly as before
|
||||
/// the rail existed: no Tor onion service or arti bootstrap, no loopback
|
||||
/// Foreign API listener, no invoice slates issued, and the pay page shows
|
||||
/// ONLY the "Pay with Goblin" (Nostr) method. On (and with a wallet
|
||||
/// loaded), an exact-amount invoice also issues a Grin invoice slate, the
|
||||
/// onion service + Foreign endpoint start, and the pay page gains the
|
||||
/// two-rail switcher ("Pay with Goblin" stays the default tab).
|
||||
pub grin1_rail: bool,
|
||||
/// Loopback port the Grin Foreign API v2 (`/v2/foreign`) binds, which the
|
||||
/// onion service proxies to (`GP_GRIN1_FOREIGN_PORT`, default 3416). Only
|
||||
@@ -300,7 +303,7 @@ impl Default for Config {
|
||||
quote_ttl: DEFAULT_QUOTE_TTL,
|
||||
rate_stale_max: 0,
|
||||
confirmations_required: DEFAULT_CONFIRMATIONS,
|
||||
grin1_rail: true,
|
||||
grin1_rail: false,
|
||||
grin1_foreign_port: DEFAULT_GRIN1_FOREIGN_PORT,
|
||||
}
|
||||
}
|
||||
@@ -437,7 +440,9 @@ impl Config {
|
||||
let quote_ttl = parse_i64(get, "GP_QUOTE_TTL", DEFAULT_QUOTE_TTL)?;
|
||||
let rate_stale_max = parse_i64(get, "GP_RATE_STALE_MAX", 0)?;
|
||||
let confirmations_required = parse_i64(get, "GP_CONFIRMATIONS", DEFAULT_CONFIRMATIONS)?;
|
||||
let grin1_rail = parse_bool(get, "GP_GRIN1_RAIL", true)?;
|
||||
// Packaged default OFF (owner ruling): the grin1/Tor rail is operator
|
||||
// opt-in; unset behaves exactly like the pre-rail server.
|
||||
let grin1_rail = parse_bool(get, "GP_GRIN1_RAIL", false)?;
|
||||
let grin1_foreign_port = match get("GP_GRIN1_FOREIGN_PORT") {
|
||||
None => DEFAULT_GRIN1_FOREIGN_PORT,
|
||||
Some(v) => v
|
||||
@@ -947,13 +952,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grin1_rail_defaults_on_and_overridable() {
|
||||
fn grin1_rail_defaults_off_and_is_operator_opt_in() {
|
||||
// Packaged default OFF (owner ruling): unset and explicit off both
|
||||
// behave like the pre-rail server; only an explicit `on` arms it.
|
||||
let cfg = load(&[]).unwrap();
|
||||
assert!(cfg.grin1_rail, "grin1 rail on by default");
|
||||
assert!(!cfg.grin1_rail, "grin1 rail must default OFF");
|
||||
assert_eq!(cfg.grin1_foreign_port, DEFAULT_GRIN1_FOREIGN_PORT);
|
||||
|
||||
let cfg = load(&[("GP_GRIN1_RAIL", "off"), ("GP_GRIN1_FOREIGN_PORT", "3999")]).unwrap();
|
||||
let cfg = load(&[("GP_GRIN1_RAIL", "off")]).unwrap();
|
||||
assert!(!cfg.grin1_rail);
|
||||
|
||||
let cfg = load(&[("GP_GRIN1_RAIL", "on"), ("GP_GRIN1_FOREIGN_PORT", "3999")]).unwrap();
|
||||
assert!(cfg.grin1_rail);
|
||||
assert_eq!(cfg.grin1_foreign_port, 3999);
|
||||
|
||||
assert!(load(&[("GP_GRIN1_RAIL", "yes")]).is_err());
|
||||
|
||||
@@ -269,7 +269,7 @@ impl Invoice {
|
||||
pub async fn get(pool: &SqlitePool, id: &str) -> Result<Option<Invoice>, sqlx::Error> {
|
||||
expire_if_due_id(pool, id).await?;
|
||||
let sql = format!("SELECT {COLUMNS} FROM invoice WHERE id = ?1");
|
||||
sqlx::query_as::<_, Invoice>(&sql)
|
||||
sqlx::query_as::<_, Invoice>(sqlx::AssertSqlSafe(sql))
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
@@ -288,7 +288,7 @@ pub async fn get_by_token(pool: &SqlitePool, token: &str) -> Result<Option<Invoi
|
||||
.execute(pool)
|
||||
.await?;
|
||||
let sql = format!("SELECT {COLUMNS} FROM invoice WHERE token = ?1");
|
||||
sqlx::query_as::<_, Invoice>(&sql)
|
||||
sqlx::query_as::<_, Invoice>(sqlx::AssertSqlSafe(sql))
|
||||
.bind(token)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
@@ -298,7 +298,7 @@ pub async fn get_by_token(pool: &SqlitePool, token: &str) -> Result<Option<Invoi
|
||||
pub async fn list(pool: &SqlitePool, limit: i64) -> Result<Vec<Invoice>, sqlx::Error> {
|
||||
expire_due(pool).await?;
|
||||
let sql = format!("SELECT {COLUMNS} FROM invoice ORDER BY created_at DESC LIMIT ?1");
|
||||
sqlx::query_as::<_, Invoice>(&sql)
|
||||
sqlx::query_as::<_, Invoice>(sqlx::AssertSqlSafe(sql))
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
@@ -352,7 +352,7 @@ pub async fn get_by_slate_id(
|
||||
slate_id: &str,
|
||||
) -> Result<Option<Invoice>, sqlx::Error> {
|
||||
let sql = format!("SELECT {COLUMNS} FROM invoice WHERE slate_id = ?1");
|
||||
sqlx::query_as::<_, Invoice>(&sql)
|
||||
sqlx::query_as::<_, Invoice>(sqlx::AssertSqlSafe(sql))
|
||||
.bind(slate_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
|
||||
@@ -98,22 +98,26 @@ pub fn build_info(
|
||||
// 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.
|
||||
// The whole "pay with any Grin wallet" rail is operator opt-in
|
||||
// (GP_GRIN1_RAIL, packaged default OFF): with the rail off the page shows
|
||||
// only the Goblin method, even for an invoice armed while it was on.
|
||||
// The plain-send fallback (an exact-amount receive to the stable grin1
|
||||
// address) shows only when the operator enabled the slatepack method, a
|
||||
// wallet address is available, AND the render gate permits it (no amount
|
||||
// collision with an earlier open grin1 invoice; Phase 3, no jitter).
|
||||
// address) additionally needs the slatepack method enabled, a wallet
|
||||
// address, AND the render gate (no amount collision with an earlier open
|
||||
// grin1 invoice; Phase 3, no jitter).
|
||||
let grin_rail_on = cfg.grin1_rail && cfg.checkout_slatepack;
|
||||
let (slatepack_address, slatepack_qr_svg) = match slatepack_addr {
|
||||
Some(addr) if cfg.checkout_slatepack && plain_send_allowed && !addr.is_empty() => {
|
||||
Some(addr) if grin_rail_on && plain_send_allowed && !addr.is_empty() => {
|
||||
let qr = qr::svg(addr, cfg.qr_logo()).unwrap_or_default();
|
||||
(Some(addr.to_string()), Some(qr))
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
// The native invoice slatepack (I1) is the primary Grin-wallet method; it is
|
||||
// shown whenever the invoice is armed on the grin1 rail and the slatepack
|
||||
// method is enabled. Its QR carries the armored slatepack text verbatim.
|
||||
// shown whenever the invoice is armed on the grin1 rail (and the rail is
|
||||
// still on). Its QR carries the armored slatepack text verbatim.
|
||||
let (invoice_slatepack, invoice_slatepack_qr_svg) = match invoice_slatepack {
|
||||
Some(armor) if cfg.checkout_slatepack && !armor.is_empty() => {
|
||||
Some(armor) if grin_rail_on && !armor.is_empty() => {
|
||||
let qr = qr::svg(armor, cfg.qr_logo()).unwrap_or_default();
|
||||
(Some(armor.to_string()), Some(qr))
|
||||
}
|
||||
@@ -437,12 +441,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A config with the grin1 rail armed (it is OFF by default, owner ruling).
|
||||
fn grin1_cfg() -> Config {
|
||||
Config {
|
||||
grin1_rail: true,
|
||||
..Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[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 cfg = grin1_cfg();
|
||||
// Plain-send permitted (render gate allowed): the address surfaces.
|
||||
let info = build_info(&inv, &cfg, Some("grin1qtestaddress"), None, true);
|
||||
assert_eq!(info.slatepack_address.as_deref(), Some("grin1qtestaddress"));
|
||||
@@ -450,12 +462,92 @@ mod tests {
|
||||
assert!(qr.contains("<svg"), "grin1 QR is an SVG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grin1_rail_off_strips_all_grin_ui_and_switcher() {
|
||||
// Owner requirement: with GP_GRIN1_RAIL off (the packaged default),
|
||||
// the page shows ONLY "Pay with Goblin" — no switcher, no grin1 UI —
|
||||
// even when a wallet address and an armed invoice slatepack exist.
|
||||
let inv = invoice(Some(1_500_000_000), None);
|
||||
let cfg = Config::default(); // grin1_rail defaults OFF
|
||||
assert!(!cfg.grin1_rail, "packaged default must be off");
|
||||
let info = build_info(
|
||||
&inv,
|
||||
&cfg,
|
||||
Some("grin1qtestaddress"),
|
||||
Some("BEGINSLATEPACK.inv.ENDSLATEPACK."),
|
||||
true,
|
||||
);
|
||||
assert!(info.slatepack_address.is_none(), "no plain-send when off");
|
||||
assert!(info.slatepack_qr_svg.is_none());
|
||||
assert!(info.invoice_slatepack.is_none(), "no invoice pack when off");
|
||||
assert!(info.invoice_slatepack_qr_svg.is_none());
|
||||
assert!(!info.nprofile.is_empty(), "Goblin method still present");
|
||||
|
||||
let page = PayPage {
|
||||
info,
|
||||
is_open: true,
|
||||
is_paid: false,
|
||||
is_confirmed: false,
|
||||
is_expired: false,
|
||||
confirmations: 0,
|
||||
confirmations_required: 10,
|
||||
};
|
||||
let html = page.render().unwrap();
|
||||
assert!(!html.contains("rail-tab"), "no switcher when rail off");
|
||||
assert!(!html.contains("rail-radio"), "no switcher radios either");
|
||||
assert!(!html.contains("id=\"panel-grin\""), "no grin panel");
|
||||
assert!(!html.contains("grin1qtestaddress"), "no grin1 address");
|
||||
assert!(!html.contains("BEGINSLATEPACK"), "no invoice slatepack");
|
||||
assert!(html.contains("id=\"panel-goblin\""), "Goblin panel present");
|
||||
assert!(
|
||||
html.contains("Nostr</p>"),
|
||||
"footer reads like the pre-rail page (no 'and Tor')"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grin1_rail_on_shows_switcher_with_goblin_default_selected() {
|
||||
// Owner requirement: rail enabled => the two-rail switcher renders and
|
||||
// the Goblin tab is the default-selected one.
|
||||
let inv = invoice(Some(1_500_000_000), None);
|
||||
let cfg = grin1_cfg();
|
||||
let info = build_info(
|
||||
&inv,
|
||||
&cfg,
|
||||
Some("grin1qtestaddress"),
|
||||
Some("BEGINSLATEPACK.inv.ENDSLATEPACK."),
|
||||
true,
|
||||
);
|
||||
let page = PayPage {
|
||||
info,
|
||||
is_open: true,
|
||||
is_paid: false,
|
||||
is_confirmed: false,
|
||||
is_expired: false,
|
||||
confirmations: 0,
|
||||
confirmations_required: 10,
|
||||
};
|
||||
let html = page.render().unwrap();
|
||||
assert!(
|
||||
html.contains(r#"id="rail-goblin" checked"#),
|
||||
"Goblin rail is the default-selected tab"
|
||||
);
|
||||
assert!(
|
||||
!html.contains(r#"id="rail-grin" checked"#),
|
||||
"Grin rail is the alternative, not preselected"
|
||||
);
|
||||
assert!(html.contains("Pay with Goblin"), "Goblin tab label");
|
||||
assert!(html.contains("Pay with any Grin wallet"), "Grin tab label");
|
||||
assert!(html.contains("id=\"panel-goblin\""));
|
||||
assert!(html.contains("id=\"panel-grin\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_gate_hides_plain_send_address_but_keeps_invoice_slatepack() {
|
||||
// The gate denies the plain-send address (amount collision), but the
|
||||
// primary invoice slatepack still renders.
|
||||
let inv = invoice(Some(1_500_000_000), None);
|
||||
let cfg = Config::default();
|
||||
let cfg = grin1_cfg();
|
||||
let info = build_info(
|
||||
&inv,
|
||||
&cfg,
|
||||
@@ -481,7 +573,7 @@ mod tests {
|
||||
// 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 cfg = grin1_cfg();
|
||||
let info = build_info(&inv, &cfg, None, None, true);
|
||||
assert!(info.slatepack_address.is_none());
|
||||
assert!(info.slatepack_qr_svg.is_none());
|
||||
@@ -499,6 +591,7 @@ mod tests {
|
||||
let cfg = Config {
|
||||
checkout_nostr: false,
|
||||
checkout_slatepack: true,
|
||||
grin1_rail: true,
|
||||
..Config::default()
|
||||
};
|
||||
let info = build_info(&inv, &cfg, Some("grin1qtestaddress"), None, true);
|
||||
@@ -537,6 +630,7 @@ mod tests {
|
||||
let cfg = Config {
|
||||
checkout_nostr: true,
|
||||
checkout_slatepack: false,
|
||||
grin1_rail: true,
|
||||
..Config::default()
|
||||
};
|
||||
let info = build_info(
|
||||
|
||||
@@ -31,3 +31,13 @@ GP_ADMIN_TOKEN=change-me-admin-token
|
||||
|
||||
# --- default payment-matching mode: memo | derived | amount ---
|
||||
GP_MATCH_MODE=derived
|
||||
|
||||
# --- grin1 / Tor rail (operator opt-in; PACKAGED DEFAULT OFF) ---
|
||||
# When off (or unset) GoblinPay is Goblin/Nostr-only: no Tor onion service, no
|
||||
# local Foreign API listener, and the pay page shows only "Pay with Goblin".
|
||||
# Set to `on` to also accept payments from ANY Grin wallet over Tor: the till
|
||||
# publishes an onion service whose identity IS its grin1 slatepack address key
|
||||
# (grin1 address == onion address), serving the Grin Foreign API v2.
|
||||
#GP_GRIN1_RAIL=on
|
||||
# Loopback port the Foreign API binds (the onion service proxies to it).
|
||||
#GP_GRIN1_FOREIGN_PORT=3416
|
||||
|
||||
+6
-5
@@ -103,7 +103,10 @@ details.manual ol { color: var(--dim); font-size: 0.9rem; padding-left: 1.2rem;
|
||||
|
||||
/* Two-rail switcher (CSS only, no JavaScript): hidden radios drive which
|
||||
panel shows; the tab labels are the control. Radios precede the panels so the
|
||||
general-sibling combinator can target them. */
|
||||
general-sibling combinator can target them. The radios and tabs are rendered
|
||||
only when BOTH rails are available (grin1 rail is operator opt-in, default
|
||||
off); a single rail renders its panel directly with no switcher chrome, so
|
||||
panels are visible by default and the radios hide the unselected one. */
|
||||
.rails { margin-top: 1.25rem; }
|
||||
.rail-radio { position: absolute; width: 0; height: 0; opacity: 0; pointer-events: none; }
|
||||
.rail-tabs { display: flex; gap: 0.4rem; margin-bottom: 1rem; }
|
||||
@@ -112,14 +115,12 @@ details.manual ol { color: var(--dim); font-size: 0.9rem; padding-left: 1.2rem;
|
||||
padding: 0.55rem 0.5rem; border: 1px solid var(--line); border-radius: var(--radius);
|
||||
color: var(--dim); font-size: 0.9rem; font-weight: 600; background: var(--panel);
|
||||
}
|
||||
.rail-tab:only-child { flex: none; display: inline-block; }
|
||||
#rail-goblin:checked ~ .rail-tabs [for="rail-goblin"],
|
||||
#rail-grin:checked ~ .rail-tabs [for="rail-grin"] {
|
||||
color: var(--bg); background: var(--accent); border-color: var(--accent);
|
||||
}
|
||||
.rail-panel { display: none; }
|
||||
#rail-goblin:checked ~ #panel-goblin,
|
||||
#rail-grin:checked ~ #panel-grin { display: block; }
|
||||
#rail-goblin:checked ~ #panel-grin,
|
||||
#rail-grin:checked ~ #panel-goblin { display: none; }
|
||||
.grin-sub { margin-top: 1.5rem; border-top: 1px solid var(--line); padding-top: 1rem; }
|
||||
.grin-sub:first-child { margin-top: 0; border-top: none; padding-top: 0; }
|
||||
.grin-sub h3 { margin: 0 0 0.5rem; font-size: 1rem; }
|
||||
|
||||
+10
-6
@@ -28,13 +28,17 @@
|
||||
{% let has_grin = info.invoice_slatepack.is_some() || info.slatepack_address.is_some() %}
|
||||
|
||||
<div class="rails">
|
||||
{% if has_goblin %}<input class="rail-radio" type="radio" name="rail" id="rail-goblin" checked>{% endif %}
|
||||
{% if has_grin %}<input class="rail-radio" type="radio" name="rail" id="rail-grin"{% if !has_goblin %} checked{% endif %}>{% endif %}
|
||||
|
||||
{% if has_goblin && has_grin %}
|
||||
{# The CSS-only switcher appears only when both rails are available; the
|
||||
Goblin rail is the default-selected tab. A single available rail
|
||||
renders its panel directly, with no switcher chrome at all. #}
|
||||
<input class="rail-radio" type="radio" name="rail" id="rail-goblin" checked>
|
||||
<input class="rail-radio" type="radio" name="rail" id="rail-grin">
|
||||
<div class="rail-tabs">
|
||||
{% if has_goblin %}<label class="rail-tab" for="rail-goblin">Pay with Goblin</label>{% endif %}
|
||||
{% if has_grin %}<label class="rail-tab" for="rail-grin">Pay with any Grin wallet</label>{% endif %}
|
||||
<label class="rail-tab" for="rail-goblin">Pay with Goblin</label>
|
||||
<label class="rail-tab" for="rail-grin">Pay with any Grin wallet</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if has_goblin %}
|
||||
<section class="rail-panel" id="panel-goblin">
|
||||
@@ -78,7 +82,7 @@
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(memo) = info.memo %}<p class="memo">{{ memo }}</p>{% endif %}
|
||||
<p class="footer">Powered by GoblinPay · receive-only Grin over Nostr and Tor</p>
|
||||
<p class="footer">Powered by GoblinPay · receive-only Grin over Nostr{% if info.invoice_slatepack.is_some() || info.slatepack_address.is_some() %} and Tor{% endif %}</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user