diff options
| author | Adam Soltys <[email protected]> | 2026-06-18 08:06:43 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-18 08:27:11 -0700 |
| commit | 3a60e3a49a031c8efea067b3c142b086bb6f5847 (patch) | |
| tree | d015773dfaa93b8f1f0bd23e46952cb7d4ef77e3 | |
| parent | fbf0c4f45249875b0b95eeb1beedd811b176a245 (diff) | |
| download | coinos-server-3a60e3a49a031c8efea067b3c142b086bb6f5847.tar.gz coinos-server-3a60e3a49a031c8efea067b3c142b086bb6f5847.zip | |
payments: don't let check() reverse an in-flight lightning send
check() skipped pending payments younger than 10s, but xpay retries for
30s (retry_for: 30). During that window listpays can transiently show all
parts failed before the winning part lands, so check() could reverse a
payment sendLightning was still driving; the part then completed and
finalize() ran against an already-removed record.
- check(): raise the skip threshold 10s -> 60s so sendLightning finishes
finalize()/reverse() and removes the payment from `pending` before
check() ever touches it.
- finalize(): if the record is gone, raise a clear error instead of a
cryptic null-deref.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| -rw-r--r-- | lib/payments.ts | 21 |
1 files changed, 19 insertions, 2 deletions
diff --git a/lib/payments.ts b/lib/payments.ts index 489b78d3..16eb7213 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -1077,7 +1077,15 @@ export const check = async () => { continue; } const p = await getPayment(pr); - if (!p || Date.now() - p.created < 10000) continue; + // Skip payments sendLightning may still be actively driving. xpay retries + // for 30s (retry_for: 30), during which listpays can momentarily show all + // attempts "failed" before the winning part lands. The old 10s threshold + // let check() reverse/refund such a payment mid-flight; it then completed, + // and sendLightning's finalize threw on the deleted record — refunding a + // 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; const { pays } = await ln.listpays(pr); const failed = !pays.length || pays.every((p) => p.status === "failed"); @@ -1128,7 +1136,16 @@ const finalize = async (r, p) => { p.fee = Number.isFinite(computedFee) ? computedFee : maxfee; p.ref = preimage; - if (!(await g(`payment:${p.id}`)).ref) { + const current = await g(`payment:${p.id}`); + // The record is gone because a concurrent reverse() already refunded this + // payment — yet here we are finalizing it, so it actually COMPLETED on the + // network and the refund was wrong (coinos is now short the amount). Do NOT + // silently re-create the record; raise a clear, greppable error so the + // LEAKED DEBIT guard logs it for manual recovery instead of a cryptic + // null-deref. (The check()-window fix should make this path very rare.) + if (!current) fail(`finalize: payment ${p.id} reversed-then-completed (double-pay leak)`); + + if (!current.ref) { await s(`payment:${p.id}`, p); const refund = maxfee - p.fee; if (Number.isFinite(refund) && Number.isInteger(refund)) { |
