aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-08 22:36:52 -0700
committerAdam Soltys <[email protected]>2026-08-08 22:36:52 -0700
commit14948460559788db024ff99406dd18adaed206ba (patch)
tree44604c20bd8145dc5c423e6c3a51420367c5d737
parentdea17487cf4fe63d029b760749e8675183042d3c (diff)
downloadcoinos-server-14948460559788db024ff99406dd18adaed206ba.tar.gz
coinos-server-14948460559788db024ff99406dd18adaed206ba.zip
payments: prevent same-invoice retry from orphaning an unreconciled debit
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 <[email protected]>
-rw-r--r--lib/payments.ts22
1 files changed, 21 insertions, 1 deletions
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");