From abf0226dd0b2e7d50c378d266ae608e651c486de Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 4 Sep 2026 19:04:08 -0700 Subject: payments: finalize settled keysends in sendKeysend keysend blocks until it settles, so a successful return already carries the preimage. Previously only the caller was expected to confirm the record, and the NWC pay_keysend path never did, leaving successful keysends stuck pending forever (check() can't rescue them since the label isn't a bolt11). Add finalizeKeysend(): flip pending->confirmed, derive the real fee from amount_sent_msat, refund the unused fee reserve, and refuse to re-create a record a concurrent reverse() already removed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014uxZLvYS4gH9UqUX2Ey8TV --- lib/payments.ts | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/lib/payments.ts b/lib/payments.ts index 6329bfcf..eba60eb0 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -807,7 +807,26 @@ export const sendKeysend = async ({ has_preimage: !!(r?.payment_preimage ?? r?.preimage), amount_sent_msat: r?.amount_sent_msat, })); - outcome = "keysend-returned"; // success path — caller handles preimage polling + // keysend blocks on retry_for until it settles or fails, so a successful + // return carries the preimage. Finalize HERE — flip the record + // pending->confirmed and refund the unused fee reserve — so every caller is + // covered. The NWC pay_keysend caller only polls listpays for the preimage + // to hand back to the client and never confirmed the record, which left + // every successful zap stuck "pending" forever (and check() can't rescue a + // keysend: its label isn't a bolt11, so the reconciler evicts it). Guard on + // complete+preimage; anything else stays pending for the caller/retry path. + const settledPreimage = r?.payment_preimage ?? r?.preimage; + if (r?.status === "complete" && settledPreimage) { + try { + await finalizeKeysend(r, p, settledPreimage); + outcome = "finalized-keysend"; + } catch (e: any) { + warn("finalizeKeysend threw", p.id, e?.message); + outcome = "keysend-returned (finalize threw)"; + } + } else { + outcome = "keysend-returned"; // not settled yet — caller polls for preimage + } l("sendKeysend outcome", p.id, "=", outcome); return r; } catch (e: any) { @@ -1381,6 +1400,40 @@ const finalize = async (r, p) => { return p; }; +// Keysend variant of finalize(). Keysend has no bolt11, so finalize()'s +// `ln.decode(p.hash)` can't run — the real fee is instead the msat actually sent +// minus the record's own amount. Otherwise identical: flip pending->confirmed, +// set the preimage, refund the unused fee reserve, and refuse to re-create a +// record a concurrent reverse() already removed (a settled-but-refunded payment +// is a double-pay leak, surfaced for manual recovery rather than papered over). +const finalizeKeysend = async (r, p, preimage) => { + await db.sRem("pending", p.hash); // keysend rarely enters the set; defensive + + const maxfee = p.fee; + const sentMsat = Number(r.amount_sent_msat); + const actualFee = Math.round((sentMsat - Math.abs(p.amount) * 1000) / 1000); + p.fee = Number.isFinite(actualFee) && actualFee >= 0 ? actualFee : maxfee; + p.ref = preimage; + p.confirmed = true; + + const current = await g(`payment:${p.id}`); + if (!current) fail(`finalizeKeysend: 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)) { + await db.incrBy(`balance:${p.uid}`, refund); + } else { + warn("finalizeKeysend: skipping fee refund (non-integer delta)", p.id, + "maxfee=", maxfee, "p.fee=", p.fee, "refund=", refund); + } + } + + nwcNotify(p); + return p; +}; + const reverse = async (p) => { await sleep(Math.floor(Math.random() * (1500 - 500 + 1)) + 500); -- cgit v1.2.3