aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-18 08:06:43 -0700
committerAdam Soltys <[email protected]>2026-06-18 08:27:11 -0700
commit3a60e3a49a031c8efea067b3c142b086bb6f5847 (patch)
treed015773dfaa93b8f1f0bd23e46952cb7d4ef77e3
parentfbf0c4f45249875b0b95eeb1beedd811b176a245 (diff)
downloadcoinos-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.ts21
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)) {