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