From 430f33383bd971ede9d45854394f1c5168461c33 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 2 Jun 2026 10:16:14 -0700 Subject: [PATCH] Fix a few possible concurrency bugs --- backend/src/billing.rs | 13 +++++++++++++ backend/src/command.rs | 30 ++++++++++++++++++++---------- frontend/src/pages/Home.tsx | 1 - 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 65a6a54..5c71f63 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -469,6 +469,14 @@ impl Billing { pub async fn ensure_bolt11_for_invoice(&self, invoice: &Invoice) -> Result { let now = chrono::Utc::now().timestamp(); + // A resolved (paid or voided) invoice must never mint a new payable + // bolt11; return the existing one if present, otherwise refuse. + if invoice.paid_at.is_some() || invoice.voided_at.is_some() { + return query::get_bolt11_for_invoice(&invoice.id) + .await? + .ok_or_else(|| anyhow!("invoice is not open")); + } + if let Some(existing) = query::get_bolt11_for_invoice(&invoice.id).await? && (existing.settled_at.is_none() || now < existing.expires_at) { @@ -487,6 +495,11 @@ impl Billing { pub async fn reconcile_bolt11_for_invoice(&self, invoice: &Invoice) -> Result> { if let Some(bolt11) = query::get_bolt11_for_invoice(&invoice.id).await? { + // Don't settle an invoice that is already resolved + if invoice.paid_at.is_some() || invoice.voided_at.is_some() { + return Ok(Some(bolt11)); + } + return self.reconcile_bolt11(&bolt11).await; }; diff --git a/backend/src/command.rs b/backend/src/command.rs index cfb1fb5..5b5b58d 100644 --- a/backend/src/command.rs +++ b/backend/src/command.rs @@ -393,7 +393,8 @@ pub async fn settle_invoice_via_nwc( with_tx(async |tx| { clear_tenant_nwc_error_tx(tx, tenant_pubkey).await?; mark_bolt11_settled_tx(tx, bolt11_id).await?; - mark_invoice_paid_tx(tx, invoice_id, "nwc").await + mark_invoice_paid_tx(tx, invoice_id, "nwc").await?; + Ok(()) }) .await } @@ -402,7 +403,8 @@ pub async fn settle_invoice_via_nwc( pub async fn settle_invoice_out_of_band(bolt11_id: &str, invoice_id: &str) -> Result<()> { with_tx(async |tx| { mark_bolt11_settled_tx(tx, bolt11_id).await?; - mark_invoice_paid_tx(tx, invoice_id, "oob").await + mark_invoice_paid_tx(tx, invoice_id, "oob").await?; + Ok(()) }) .await } @@ -417,7 +419,8 @@ pub async fn settle_invoice_via_stripe( with_tx(async |tx| { insert_intent_tx(tx, intent_id, invoice_id).await?; clear_tenant_stripe_error_tx(tx, tenant_pubkey).await?; - mark_invoice_paid_tx(tx, invoice_id, "stripe").await + mark_invoice_paid_tx(tx, invoice_id, "stripe").await?; + Ok(()) }) .await } @@ -592,10 +595,11 @@ async fn set_relay_status_tx( insert_activity_tx(tx, activity_type, &relay.id, snapshot).await } +/// Stamp a bolt11 as settled but don't overwrite an existing settled_at. async fn mark_bolt11_settled_tx(tx: &mut Transaction<'_, Sqlite>, bolt11_id: &str) -> Result<()> { let settled_at = chrono::Utc::now().timestamp(); - sqlx::query("UPDATE bolt11 SET settled_at = ? WHERE id = ?") + sqlx::query("UPDATE bolt11 SET settled_at = ? WHERE id = ? AND settled_at IS NULL") .bind(settled_at) .bind(bolt11_id) .execute(&mut **tx) @@ -603,6 +607,9 @@ async fn mark_bolt11_settled_tx(tx: &mut Transaction<'_, Sqlite>, bolt11_id: &st Ok(()) } +/// Mark an invoice paid, but only while it is still open — a late Lightning +/// payment never flips a voided/forgiven invoice to paid, and a Stripe-paid +/// invoice never has its provenance overwritten by a later bolt11. async fn mark_invoice_paid_tx( tx: &mut Transaction<'_, Sqlite>, invoice_id: &str, @@ -610,12 +617,15 @@ async fn mark_invoice_paid_tx( ) -> Result<()> { let paid_at = chrono::Utc::now().timestamp(); - sqlx::query("UPDATE invoice SET method = ?, paid_at = ? WHERE id = ?") - .bind(method) - .bind(paid_at) - .bind(invoice_id) - .execute(&mut **tx) - .await?; + sqlx::query( + "UPDATE invoice SET method = ?, paid_at = ? + WHERE id = ? AND paid_at IS NULL AND voided_at IS NULL", + ) + .bind(method) + .bind(paid_at) + .bind(invoice_id) + .execute(&mut **tx) + .await?; Ok(()) } diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index c61e1c7..73bb200 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -9,7 +9,6 @@ import Login from "@/views/Login" import { createRelayForActiveTenant } from "@/lib/hooks" import { account } from "@/lib/state" import FlotillaLogo from "@/assets/flotilla-logo.svg" -import ChachiLogo from "@/assets/chachi-logo.svg" import NostordLogo from "@/assets/nostord-logo.svg" export default function Home() {