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:
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user