From a6d1a601b90d3dcda9fce6fbcda01e5993b6715a Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:49:06 -0400 Subject: [PATCH] gp-core: grin1 rail invoice model + render gate + config flag (Phase 2/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 0009 adds invoice.rail/slate_id/slatepack. New invoice functions: - attach_grin1: arm an invoice on the grin1 rail with the issued I1 slate id and armored slatepack. - get_by_slate_id: look up the invoice a returning finalize settles. - plain_send_allowed: the Phase-3 render gate (no jitter) — only the earliest open grin1 invoice for a given exact amount may show the plain-send address panel; newer amount collisions hide it. Ordered by rowid (true insertion order), amountless/off-rail invoices never show plain-send. Config: GP_GRIN1_RAIL (default on) + GP_GRIN1_FOREIGN_PORT (default 3416). --- crates/gp-core/src/config.rs | 44 ++++++++- crates/gp-core/src/invoice.rs | 170 ++++++++++++++++++++++++++++++++- migrations/0009_grin1_rail.sql | 18 ++++ 3 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 migrations/0009_grin1_rail.sql diff --git a/crates/gp-core/src/config.rs b/crates/gp-core/src/config.rs index d3743c8..5a64da6 100644 --- a/crates/gp-core/src/config.rs +++ b/crates/gp-core/src/config.rs @@ -232,6 +232,16 @@ 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. + 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 + /// used when `grin1_rail` is on. + pub grin1_foreign_port: u16, } /// Default supported fiat currency when `GP_RATE_CURRENCIES` is unset. @@ -244,6 +254,9 @@ pub const DEFAULT_QUOTE_TTL: i64 = 900; /// (house standard). An invoice advances `paid` -> `confirmed` once its /// kernel reaches this many confirmations. pub const DEFAULT_CONFIRMATIONS: i64 = 10; +/// Default loopback port for the Grin Foreign API v2 the onion service proxies +/// to. 3416 sits just past the stock wallet listener (3415) / owner (3420). +pub const DEFAULT_GRIN1_FOREIGN_PORT: u16 = 3416; /// Sentinel `qr_logo` value selecting the inlined Goblin mark /// (`GP_QR_LOGO=builtin`, opt-in; the default is a plain QR). Kept out of the @@ -287,6 +300,8 @@ impl Default for Config { quote_ttl: DEFAULT_QUOTE_TTL, rate_stale_max: 0, confirmations_required: DEFAULT_CONFIRMATIONS, + grin1_rail: true, + grin1_foreign_port: DEFAULT_GRIN1_FOREIGN_PORT, } } } @@ -422,6 +437,14 @@ 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)?; + let grin1_foreign_port = match get("GP_GRIN1_FOREIGN_PORT") { + None => DEFAULT_GRIN1_FOREIGN_PORT, + Some(v) => v + .trim() + .parse::() + .map_err(|_| format!("GP_GRIN1_FOREIGN_PORT must be a port 1-65535 (got `{v}`)"))?, + }; let cfg = Config { bind, @@ -458,6 +481,8 @@ impl Config { quote_ttl, rate_stale_max, confirmations_required, + grin1_rail, + grin1_foreign_port, }; cfg.validate()?; Ok(cfg) @@ -552,7 +577,7 @@ impl Config { webhook_secret={} qr_logo={} merchant_npub={} notify_merchant_dm={} \ notify_payer_receipt={} endpub_rotate_interval={} endpub_overlap_epochs={} \ rate_source={} rate_currencies={:?} rate_cache_ttl={} quote_ttl={} \ - rate_stale_max={} confirmations_required={}", + rate_stale_max={} confirmations_required={} grin1_rail={} grin1_foreign_port={}", self.bind, match &self.tls { Tls::Off => "off".to_string(), @@ -593,6 +618,8 @@ impl Config { self.quote_ttl, self.rate_stale_max, self.confirmations_required, + if self.grin1_rail { "on" } else { "off" }, + self.grin1_foreign_port, ) } } @@ -919,6 +946,21 @@ mod tests { assert_eq!(cfg.confirmations_required, 3); } + #[test] + fn grin1_rail_defaults_on_and_overridable() { + let cfg = load(&[]).unwrap(); + assert!(cfg.grin1_rail, "grin1 rail on by default"); + assert_eq!(cfg.grin1_foreign_port, DEFAULT_GRIN1_FOREIGN_PORT); + + let cfg = load(&[("GP_GRIN1_RAIL", "off"), ("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()); + assert!(load(&[("GP_GRIN1_FOREIGN_PORT", "notaport")]).is_err()); + assert!(load(&[("GP_GRIN1_FOREIGN_PORT", "70000")]).is_err()); + } + #[test] fn confirmations_must_be_at_least_one() { assert!(load(&[("GP_CONFIRMATIONS", "0")]).is_err()); diff --git a/crates/gp-core/src/invoice.rs b/crates/gp-core/src/invoice.rs index 15fa8cb..41fc958 100644 --- a/crates/gp-core/src/invoice.rs +++ b/crates/gp-core/src/invoice.rs @@ -128,6 +128,14 @@ pub struct Invoice { /// When the invoice crossed the confirmation threshold (paid -> confirmed), /// else NULL. pub confirmed_at: Option, + /// Payment rail: `None` (Nostr-only / legacy) or `Some("grin1")` when the + /// native Grin invoice flow is armed for this invoice. + pub rail: Option, + /// The issued I1 invoice-slate UUID (grin1 rail); the settlement key a + /// returning finalize is matched on. `None` off the grin1 rail. + pub slate_id: Option, + /// The armored I1 invoice slatepack the pay page renders (grin1 rail). + pub slatepack: Option, } impl Invoice { @@ -245,7 +253,17 @@ pub async fn create( const COLUMNS: &str = "id, ref, expected_amount, expiry, status, created_at, token, memo, \ recipient_pubkey, fiat_amount, fiat_currency, match_mode, paid_payment_id, paid_at, \ - quote_rate, quote_source, confirmed_at"; + quote_rate, quote_source, confirmed_at, rail, slate_id, slatepack"; + +/// The grin1 rail marker value stored in `invoice.rail`. +pub const RAIL_GRIN1: &str = "grin1"; + +impl Invoice { + /// Whether this invoice is armed on the native grin1 invoice-flow rail. + pub fn is_grin1(&self) -> bool { + self.rail.as_deref() == Some(RAIL_GRIN1) + } +} /// Fetch an invoice by id, marking it expired first if its expiry has passed. pub async fn get(pool: &SqlitePool, id: &str) -> Result, sqlx::Error> { @@ -305,6 +323,77 @@ pub async fn mark_paid( Ok(result.rows_affected() > 0) } +/// Arm an invoice on the grin1 rail: record the issued I1 slate id and its +/// armored slatepack, and mark the invoice `rail = 'grin1'`. Called right after +/// the till wallet issues the invoice slate at creation. +pub async fn attach_grin1( + pool: &SqlitePool, + invoice_id: &str, + slate_id: &str, + slatepack: &str, +) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE invoice SET rail = ?2, slate_id = ?3, slatepack = ?4 WHERE id = ?1", + ) + .bind(invoice_id) + .bind(RAIL_GRIN1) + .bind(slate_id) + .bind(slatepack) + .execute(pool) + .await?; + Ok(()) +} + +/// Fetch a grin1-rail invoice by its issued I1 slate id (the settlement key a +/// returning finalize is matched on). Does not touch expiry lazily: settlement +/// is decided against the invoice's live status by the caller. +pub async fn get_by_slate_id( + pool: &SqlitePool, + slate_id: &str, +) -> Result, sqlx::Error> { + let sql = format!("SELECT {COLUMNS} FROM invoice WHERE slate_id = ?1"); + sqlx::query_as::<_, Invoice>(&sql) + .bind(slate_id) + .fetch_optional(pool) + .await +} + +/// Render gate for the plain-send fallback panel (Phase 3, no jitter). The +/// plain-send `grin1` address matches by exact amount, so two OPEN grin1 +/// invoices sharing an `expected_amount` would be ambiguous. The gate lets only +/// the *earliest* open grin1 invoice for a given amount render the plain-send +/// panel; a newer collision hides it (the invoice-flow panel always shows). +/// +/// Returns `true` when `invoice` may render the plain-send panel: it is grin1, +/// has a positive exact amount, and no earlier open grin1 invoice holds the +/// same amount. An amountless invoice never shows plain-send (nothing to match). +pub async fn plain_send_allowed(pool: &SqlitePool, invoice: &Invoice) -> Result { + if !invoice.is_grin1() { + return Ok(false); + } + let Some(amount) = invoice.expected_amount else { + return Ok(false); + }; + if amount <= 0 { + return Ok(false); + } + // Any earlier-inserted open grin1 invoice with the same amount claims the + // plain-send panel; the newer collision hides it. Order by SQLite rowid + // (monotonic with insertion), which reflects true creation order even when + // `created_at` (second resolution) ties. + let earlier: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM invoice \ + WHERE rail = ?1 AND status = 'open' AND expected_amount = ?2 AND id <> ?3 \ + AND rowid < (SELECT rowid FROM invoice WHERE id = ?3)", + ) + .bind(RAIL_GRIN1) + .bind(amount) + .bind(&invoice.id) + .fetch_one(pool) + .await?; + Ok(earlier == 0) +} + /// Mark an invoice confirmed: the terminal `paid` -> `confirmed` transition, /// driven by the confirmation poll once the paying kernel reaches the /// configured depth. Idempotent: only a `paid` invoice transitions, so a @@ -507,6 +596,85 @@ mod tests { assert!(!mark_confirmed(&pool, &inv.id).await.unwrap()); } + async fn open_grin1(pool: &SqlitePool, nano: u64) -> Invoice { + let inv = create(pool, grin(nano), &MASTER, MASTER_PUB, MatchMode::Amount) + .await + .unwrap(); + attach_grin1(pool, &inv.id, &format!("slate-{}", inv.id), "BEGINSLATEPACK.x.ENDSLATEPACK.") + .await + .unwrap(); + get(pool, &inv.id).await.unwrap().unwrap() + } + + #[tokio::test] + async fn attach_grin1_arms_the_rail_and_is_lookupable_by_slate() { + let pool = pool().await; + let inv = open_grin1(&pool, 1_000_000_000).await; + assert!(inv.is_grin1()); + assert_eq!(inv.slate_id.as_deref(), Some(format!("slate-{}", inv.id).as_str())); + assert!(inv.slatepack.as_deref().unwrap().starts_with("BEGINSLATEPACK")); + + let by_slate = get_by_slate_id(&pool, &format!("slate-{}", inv.id)) + .await + .unwrap() + .unwrap(); + assert_eq!(by_slate.id, inv.id); + } + + #[tokio::test] + async fn render_gate_shows_plain_send_only_for_the_earliest_amount_collision() { + let pool = pool().await; + // First open grin1 invoice for 2 GRIN: plain-send allowed. + let first = open_grin1(&pool, 2_000_000_000).await; + assert!(plain_send_allowed(&pool, &first).await.unwrap()); + + // A second open grin1 invoice at the SAME amount: gate hides its panel, + // the first still shows. + let second = open_grin1(&pool, 2_000_000_000).await; + assert!(!plain_send_allowed(&pool, &second).await.unwrap()); + assert!(plain_send_allowed(&pool, &first).await.unwrap()); + + // A different amount is unaffected. + let other = open_grin1(&pool, 3_000_000_000).await; + assert!(plain_send_allowed(&pool, &other).await.unwrap()); + + // Once the first is no longer open, the second becomes the earliest. + mark_paid(&pool, &first.id, "pay-x").await.unwrap(); + let second = get(&pool, &second.id).await.unwrap().unwrap(); + assert!(plain_send_allowed(&pool, &second).await.unwrap()); + } + + #[tokio::test] + async fn plain_send_gate_off_rail_and_amountless_is_false() { + let pool = pool().await; + // A non-grin1 invoice never shows plain-send. + let plain = create(&pool, grin(5), &MASTER, MASTER_PUB, MatchMode::Amount) + .await + .unwrap(); + assert!(!plain_send_allowed(&pool, &plain).await.unwrap()); + + // A grin1 invoice with no expected amount never shows plain-send. + let p = NewInvoice { + order_ref: None, + amount: AmountSpec::Fiat { + amount: "9.99".into(), + currency: "USD".into(), + }, + memo: None, + match_mode: None, + expiry_secs: None, + }; + let inv = create(&pool, p, &MASTER, MASTER_PUB, MatchMode::Amount) + .await + .unwrap(); + attach_grin1(&pool, &inv.id, "slate-amountless", "BEGINSLATEPACK.x.ENDSLATEPACK.") + .await + .unwrap(); + let inv = get(&pool, &inv.id).await.unwrap().unwrap(); + assert!(inv.is_grin1() && inv.expected_amount.is_none()); + assert!(!plain_send_allowed(&pool, &inv).await.unwrap()); + } + #[tokio::test] async fn confirmations_reads_the_paying_payments_depth() { let pool = pool().await; diff --git a/migrations/0009_grin1_rail.sql b/migrations/0009_grin1_rail.sql new file mode 100644 index 0000000..4cd21c2 --- /dev/null +++ b/migrations/0009_grin1_rail.sql @@ -0,0 +1,18 @@ +-- grin1 payment rail: the native Grin invoice flow (receiver-initiated) as the +-- primary "pay with any Grin wallet" path, plus the plain-send fallback. +-- +-- On a grin1-rail invoice the till wallet issues an invoice slate (I1) at +-- creation; the payer imports the armored I1, pays it (producing an I2), and +-- returns the I2 to the Tor foreign endpoint, which finalizes + posts. The +-- returning I2 is matched back to this invoice by its slate id. +-- +-- rail payment rail marker: NULL (legacy / Nostr-only) or 'grin1'. +-- slate_id the issued I1 slate UUID (invoice flow); the settlement key a +-- returning finalize is matched on. NULL for non-grin1 invoices. +-- slatepack the armored I1 invoice slatepack the pay page renders (text + +-- QR). Contains no secret: it is the invoice the payer gets anyway. +ALTER TABLE invoice ADD COLUMN rail TEXT; +ALTER TABLE invoice ADD COLUMN slate_id TEXT; +ALTER TABLE invoice ADD COLUMN slatepack TEXT; + +CREATE INDEX idx_invoice_slate ON invoice (slate_id);