From fe767002dd59f000d54fd09c9d617d69b9a5c894 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Wed, 20 May 2026 22:29:35 -0700 Subject: tighten LEAKED DEBIT guard: don't false-alarm on successful-via-fallback paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first deployment surfaced two LEAKED DEBIT lines that were actually clean finalizes via the new failed_parts listpays-fallback path — exactly the path that recovers payments that would have been stuck under the old code (joho33's case). The leak guard's `outcome !== "finalized"` check was too narrow: any path that finalizes the payment, regardless of how it got there, is a success. Bucket outcomes explicitly: - finalizedOutcomes — clean successes (finalize ran, ref set) - safeNonFinalized — reverse ran, OR debit left in pending for check() to reconcile (these aren't leaks) - anything else — actually stranded (finalize-threw-*, unknown) → err Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/payments.ts | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) (limited to 'lib') diff --git a/lib/payments.ts b/lib/payments.ts index af45ebc3..5e073715 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -779,14 +779,30 @@ export const sendLightning = async ({ throw e; } - // Leak guard: if we got here without a clean finalize, the payment is - // confirmed=true (from debit's default) but has no ref/preimage — the - // exact stuck state we want to avoid. Log loudly so the next occurrence - // shows up in app logs immediately. - if (outcome !== "finalized") { - err("LEAKED DEBIT in sendLightning", p.id, "outcome=", outcome, "user=", user?.username, "amount=", amount, "fee=", fee); - } else { + // Leak guard: outcomes that leave a confirmed=true debit with no ref AND + // no entry in the pending set are the joho33-style stuck state. Pending-* + // outcomes are fine — check() will reconcile. Finalize-via-* outcomes + // are clean successes via a fallback path. The truly stranded cases are + // the finalize-threw-* family. + const finalizedOutcomes = new Set([ + "finalized", + "finalized-via-listpays-after-failed_parts", + "finalized-after-xpay-throw", + ]); + const safeNonFinalized = new Set([ + "reversed-via-failed_parts", + "reversed-after-xpay-throw", + "pending-after-failed_parts", + "pending-after-xpay-throw", + "verify-failed-after-failed_parts", + "verify-failed-after-xpay-throw", + ]); + if (finalizedOutcomes.has(outcome)) { l("sendLightning outcome", p.id, "=", outcome); + } else if (safeNonFinalized.has(outcome)) { + warn("sendLightning outcome", p.id, "=", outcome); + } else { + err("LEAKED DEBIT in sendLightning", p.id, "outcome=", outcome, "user=", user?.username, "amount=", amount, "fee=", fee); } return p; -- cgit v1.2.3