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.
This commit is contained in:
2ro
2026-07-04 21:59:32 -04:00
parent 8f9bad98b0
commit 975d1bd6ce
3 changed files with 114 additions and 1 deletions
+66
View File
@@ -439,6 +439,33 @@ pub async fn expire_due(pool: &SqlitePool) -> Result<u64, sqlx::Error> {
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<Vec<(String, String)>, 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<bool, sqlx::Error> {
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;
+43
View File
@@ -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}))
+5 -1
View File
@@ -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();