gp-core: grin1 rail invoice model + render gate + config flag (Phase 2/3)

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).
This commit is contained in:
2ro
2026-07-04 21:49:06 -04:00
parent 2b791a18a6
commit a6d1a601b9
3 changed files with 230 additions and 2 deletions
+43 -1
View File
@@ -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::<u16>()
.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());
+169 -1
View File
@@ -128,6 +128,14 @@ pub struct Invoice {
/// When the invoice crossed the confirmation threshold (paid -> confirmed),
/// else NULL.
pub confirmed_at: Option<String>,
/// Payment rail: `None` (Nostr-only / legacy) or `Some("grin1")` when the
/// native Grin invoice flow is armed for this invoice.
pub rail: Option<String>,
/// 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<String>,
/// The armored I1 invoice slatepack the pay page renders (grin1 rail).
pub slatepack: Option<String>,
}
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<Option<Invoice>, 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<Option<Invoice>, 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<bool, sqlx::Error> {
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;
+18
View File
@@ -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);