From 14948460559788db024ff99406dd18adaed206ba Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 8 Aug 2026 22:36:52 -0700 Subject: payments: prevent same-invoice retry from orphaning an unreconciled debit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pending reconciliation set is keyed by bolt11, shared across attempts at the same invoice. A retry made after a prior attempt's CLN parts resolved (but before check() reconciled its debit) would pass the listpays-only guard, and the retry's own reverse()/finalize() then removed the shared entry — permanently hiding the first attempt's debit from check(), so a failed send was never auto-refunded. - sendLightning: refuse to pay while the invoice's bolt11 is still in the pending set; the caller is told to retry shortly. - check(): an entry whose payment record no longer exists in db or archive is dropped from the set instead of skipped, so a dead entry can't block its invoice forever under the new guard. Co-Authored-By: Claude Fable 5 --- lib/payments.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/payments.ts b/lib/payments.ts index 960ff705..5df028b3 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -910,6 +910,17 @@ export const sendLightning = async ({ if (pays.find((p) => p.status === "pending")) fail("Payment is already underway"); + // A prior attempt at this same invoice may still be awaiting reconciliation + // by check() (its CLN parts resolved but the debit hasn't been reversed or + // finalized yet). The pending set is keyed by bolt11 and shared across + // attempts, so letting a retry proceed here means the retry's own + // reverse()/finalize() clears the shared entry and ORPHANS the first + // attempt's debit — check() never sees it again and the user is never + // refunded. Make the retry wait until reconciliation clears the entry + // (seconds to ~a minute). + if (await db.sIsMember("pending", pr)) + fail("Previous attempt is still settling, please retry in a minute"); + p = await debit({ hash: pr, amount: amount_msat ? Math.round(amount_msat / 1000) : amount, @@ -1271,7 +1282,16 @@ export const check = async () => { // payment that actually settled (the LEAKED DEBIT losses). Wait well past // the retry window so sendLightning has finished finalize()/reverse() and // removed it from `pending` before check() ever touches it. - if (!p || Date.now() - p.created < 60000) continue; + // No record anywhere (main db or archive) means there's nothing left to + // reconcile — the debit was already reversed or the entry is a stale + // stray. Drop it: sendLightning now refuses to re-pay an invoice while + // its bolt11 sits in this set, so a dead entry would otherwise block + // that invoice forever. + if (!p) { + await db.sRem("pending", pr); + continue; + } + if (Date.now() - p.created < 60000) continue; const { pays } = await ln.listpays(pr); const failed = !pays.length || pays.every((p) => p.status === "failed"); -- cgit v1.2.3