From 975d1bd6ce6d8c32efb9e6c42dd071374ea02099 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:59:32 -0400 Subject: [PATCH] grin1 rail: cancel stored context on invoice expiry (Phase 2) A late payer I2 must not settle an expired invoice. Add a periodic sweep that, for each open grin1 invoice past its expiry, cancel_tx's its stored wallet context (finalize then fails with no context) and only then marks it expired. Cancel-first ordering means a node hiccup leaves the invoice open and retries next pass rather than expiring it un-cancelled. - gp-core: due_grin1_slates (select open+due grin1, no status flip) + mark_expired. - gp-server: foreign::spawn_expiry_cancel sweep (60s), launched with the rail. --- crates/gp-core/src/invoice.rs | 66 +++++++++++++++++++++++++++++++++ crates/gp-server/src/foreign.rs | 43 +++++++++++++++++++++ crates/gp-server/src/main.rs | 6 ++- 3 files changed, 114 insertions(+), 1 deletion(-) diff --git a/crates/gp-core/src/invoice.rs b/crates/gp-core/src/invoice.rs index 41fc958..76a8d85 100644 --- a/crates/gp-core/src/invoice.rs +++ b/crates/gp-core/src/invoice.rs @@ -439,6 +439,33 @@ pub async fn expire_due(pool: &SqlitePool) -> Result { Ok(result.rows_affected()) } +/// The still-open grin1-rail invoices whose expiry has passed, as +/// `(invoice_id, slate_id)`. These need their stored wallet context cancelled +/// before being expired: the caller `cancel_tx`s each, then calls +/// [`mark_expired`] on success. Deliberately does NOT flip status here, so a +/// cancel that fails (e.g. the node is briefly unreachable) leaves the invoice +/// open and is retried on the next sweep rather than silently un-cancelled. +pub async fn due_grin1_slates(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT id, slate_id FROM invoice \ + WHERE rail = ?1 AND status = 'open' AND slate_id IS NOT NULL \ + AND expiry IS NOT NULL AND expiry <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')", + ) + .bind(RAIL_GRIN1) + .fetch_all(pool) + .await +} + +/// Flip a still-open invoice to `expired` (used by the grin1 sweep after its +/// context is cancelled). Idempotent: only an `open` invoice transitions. +pub async fn mark_expired(pool: &SqlitePool, invoice_id: &str) -> Result { + let result = sqlx::query("UPDATE invoice SET status = 'expired' WHERE id = ?1 AND status = 'open'") + .bind(invoice_id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + async fn expire_if_due_id(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> { sqlx::query( "UPDATE invoice SET status = 'expired' \ @@ -644,6 +671,45 @@ mod tests { assert!(plain_send_allowed(&pool, &second).await.unwrap()); } + #[tokio::test] + async fn due_grin1_slates_lists_then_mark_expired_clears_them() { + let pool = pool().await; + // A grin1 invoice, then force it to a due-but-still-open state (a real + // sweep sees a live invoice whose expiry has just passed; create()'s + // lazy expiry would otherwise flip a negative expiry immediately). + let inv = create(&pool, grin(1_000_000_000), &MASTER, MASTER_PUB, MatchMode::Amount) + .await + .unwrap(); + attach_grin1(&pool, &inv.id, "slate-exp", "BEGINSLATEPACK.x.ENDSLATEPACK.") + .await + .unwrap(); + sqlx::query( + "UPDATE invoice SET status = 'open', \ + expiry = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-10 seconds') WHERE id = ?1", + ) + .bind(&inv.id) + .execute(&pool) + .await + .unwrap(); + + // Listed while still open (not yet flipped), so a failed cancel retries. + let due = due_grin1_slates(&pool).await.unwrap(); + assert_eq!(due, vec![(inv.id.clone(), "slate-exp".to_string())]); + // Still open until we explicitly expire it after a successful cancel. + // (get() would lazily expire it, so read status directly.) + let raw: String = sqlx::query_scalar("SELECT status FROM invoice WHERE id = ?1") + .bind(&inv.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(raw, "open"); + + // After cancel succeeds, expire it; the next sweep then lists nothing. + assert!(mark_expired(&pool, &inv.id).await.unwrap()); + assert!(due_grin1_slates(&pool).await.unwrap().is_empty()); + assert!(!mark_expired(&pool, &inv.id).await.unwrap(), "idempotent"); + } + #[tokio::test] async fn plain_send_gate_off_rail_and_amountless_is_false() { let pool = pool().await; diff --git a/crates/gp-server/src/foreign.rs b/crates/gp-server/src/foreign.rs index f57a568..da50a02 100644 --- a/crates/gp-server/src/foreign.rs +++ b/crates/gp-server/src/foreign.rs @@ -41,6 +41,49 @@ pub fn configure(cfg: &mut web::ServiceConfig) { cfg.route("/v2/foreign", web::post().to(handle)); } +/// How often the grin1 expiry sweep runs. A cancel only matters before a late +/// I2 arrives, and finalize also rechecks the invoice status, so a slow sweep is +/// plenty. +const EXPIRY_SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60); + +/// Spawn the grin1 expiry sweep: periodically expire due grin1 invoices and +/// `cancel_tx` their stored wallet contexts, so a late payer I2 for an expired +/// invoice fails cleanly instead of settling. `cancel` contacts the node and +/// blocks, so it runs off the async workers. +pub fn spawn_expiry_cancel(pool: SqlitePool, wallet: GpWallet) { + actix_web::rt::spawn(async move { + log::info!("grin1: expiry-cancel sweep every {EXPIRY_SWEEP_INTERVAL:?}"); + loop { + actix_web::rt::time::sleep(EXPIRY_SWEEP_INTERVAL).await; + let due = match invoice::due_grin1_slates(&pool).await { + Ok(s) => s, + Err(e) => { + warn!("grin1 expiry sweep: {e}"); + continue; + } + }; + for (inv_id, slate_id) in due { + let w = wallet.clone(); + let sid = slate_id.clone(); + // Cancel first; only expire the invoice once its context is gone, + // so a node hiccup leaves it open and the next pass retries. + let res = actix_web::rt::task::spawn_blocking(move || w.cancel(&sid)).await; + match res { + Ok(Ok(())) => { + if let Err(e) = invoice::mark_expired(&pool, &inv_id).await { + warn!("grin1: mark_expired {inv_id} failed: {e}"); + } else { + log::info!("grin1: cancelled expired invoice slate {slate_id}"); + } + } + Ok(Err(e)) => warn!("grin1: cancel {slate_id} failed (retries next pass): {e}"), + Err(e) => warn!("grin1: cancel task panicked for {slate_id}: {e}"), + } + } + } + }); +} + /// A JSON-RPC success envelope. fn ok(id: Value, result: Value) -> HttpResponse { HttpResponse::Ok().json(json!({"jsonrpc": "2.0", "id": id, "result": result})) diff --git a/crates/gp-server/src/main.rs b/crates/gp-server/src/main.rs index f9c6d72..86ea113 100644 --- a/crates/gp-server/src/main.rs +++ b/crates/gp-server/src/main.rs @@ -258,7 +258,11 @@ async fn main() -> io::Result<()> { // deploy/ and the grim tor.rs port); the wallet's index-0 slatepack address // key IS the onion identity, so grin1 address == onion address. if cfg.grin1_rail { - if wallet_data.get_ref().is_some() { + if let Some(wallet) = wallet_data.get_ref().as_ref() { + // Expiry sweep: cancel the stored context of expired grin1 invoices + // so a late payer I2 cannot settle an expired invoice. + foreign::spawn_expiry_cancel(pool.clone(), wallet.clone()); + let foreign_bind = format!("127.0.0.1:{}", cfg.grin1_foreign_port); let pool_f = pool.clone(); let cfg_f = cfg_data.clone();