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:
2ro
2026-07-04 22:27:10 -04:00
parent f6fed1f3c3
commit 34a44043f2
9 changed files with 3752 additions and 294 deletions
Generated
+3588 -257
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -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",
+2
View File
@@ -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 |
+20 -10
View File
@@ -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());
+4 -4
View File
@@ -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
+104 -10
View File
@@ -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(
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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 &middot; receive-only Grin over Nostr and Tor</p>
<p class="footer">Powered by GoblinPay &middot; receive-only Grin over Nostr{% if info.invoice_slatepack.is_some() || info.slatepack_address.is_some() %} and Tor{% endif %}</p>
</main>
</body>
</html>