From b524be4331cbcb6ff68c27894b6e8143b556b85f Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 31 Jul 2026 23:23:03 -0700 Subject: sendKeysend: verify a thrown keysend by label, not the caller's hash (COINOS-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendKeysend debited, ran ln.keysend, and on a throw decided whether to refund by calling listpays({payment_hash: hash}) with the CALLER's id (a Nostr ev.id on the NWC path). keysend randomizes its own payment hash — the CLN command takes no preimage arg — so that lookup could never match: it returned [], `failed` was always true, and reverse() ran on EVERY throw. A keysend that threw but actually settled was re-credited while the sats had left the node, a repeatable hot-wallet drain. Nothing corrected it either: unlike sendLightning, keysends are never enrolled in `pending`, so check() never revisits them. Recovering the real hash from the error payload doesn't work — verified on CLN v26.06.2 that keysend errors carry no payment_hash under any key (the error is {code, message, attempts}), so that approach never reverses and instead eats the user's money on every ordinary routing failure. Instead tag the payment with our own unique label and record where the sendpays index stands first (`wait sendpays created 0` returns the current index without blocking), so a throw can list only sendpays created since — no full scan — and match ours by label whatever the error looks like. CLN writes the sendpay record BEFORE sending the HTLC, so no record at all proves nothing left the node and the debit is safe to reverse. Verified both directions on regtest: an unroutable keysend is refunded, and a settled one is correctly detected and left alone. Known gap: a keysend still in flight when the RPC throws stays debited, since keysends are not enrolled in `pending` for check() to reconcile. Reported-by: Muhammed Shekho (@SYR-ROOT) Co-Authored-By: Claude Fable 5 --- lib/payments.ts | 69 +++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/lib/payments.ts b/lib/payments.ts index 7855abd2..34029233 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -731,12 +731,38 @@ export const sendKeysend = async ({ }); let outcome = "unknown"; + + // COINOS-1: keysend randomizes its payment hash (the CLN command takes no + // preimage arg) and a throw yields no result object, so the caller-supplied + // `hash` is NOT the payment's real hash — verifying with it is what made the + // old code reverse on every throw. CLN keysend errors carry no payment_hash + // either (verified on v26.06.2: the error is {code, message, attempts}). + // + // Instead, tag the payment with our own unique `label` and note where the + // sendpays index stands beforehand. `wait sendpays created 0` returns the + // current index immediately, so on a throw we can list only sendpays created + // since — no full scan — and find ours by label regardless of error shape. + // `wait` isn't in the rpc client's generated method list, so go through the + // generic call(). nextvalue 0 never blocks — it returns the current index. + let startIndex: number | undefined; + try { + const w = await ln.call("wait", { + subsystem: "sendpays", + indexname: "created", + nextvalue: 0, + }); + startIndex = w?.created; + } catch (e: any) { + warnThrottled("keysend: wait sendpays unavailable", e?.message ?? String(e)); + } + try { const r = await ln.keysend({ destination: pubkey, amount_msat: amount * 1000, maxfee: fee * 1000, retry_for: 10, + label: hash, extratlvs, }); warn("keysend returned", p.id, JSON.stringify({ @@ -750,23 +776,38 @@ export const sendKeysend = async ({ } catch (e: any) { err("failed keysend", hash?.slice(0,16), "error:", shortError(e?.message)); try { - const { pays } = await ln.listpays({ payment_hash: hash }); - warn("listpays after keysend-threw", p.id, JSON.stringify(pays?.map((x) => ({ status: x.status, amount_sent_msat: x.amount_sent_msat })) ?? [])); - const completed = pays.find((x) => x.status === "complete"); - const failed = !pays.length || pays.every((x) => x.status === "failed"); - if (completed) { - outcome = "keysend-completed-despite-throw"; - } else if (failed) { - await reverse(p); - outcome = "reversed-after-keysend-throw"; + if (startIndex === undefined) { + // Couldn't establish the index baseline, so we can't bound the search. + // Never refund a payment that may have settled — leave the debit. + warn("keysend threw with no sendpays baseline — NOT reversing (manual review)", p.id, pubkey); + outcome = "unverified-after-keysend-throw (not reversed)"; } else { - outcome = "pending-after-keysend-throw"; + const { payments = [] } = await ln.listsendpays({ + index: "created", + start: startIndex, + }); + const ours = payments.filter((x: any) => x.label === hash); + warn("sendpays after keysend-threw", p.id, JSON.stringify(ours.map((x: any) => ({ status: x.status })))); + + if (ours.some((x: any) => x.status === "complete")) { + // Settled despite the throw — reversing here is the drain. + outcome = "keysend-completed-despite-throw"; + } else if (!ours.length) { + // CLN records a sendpay BEFORE it sends the HTLC, so no record at all + // means nothing left the node (the common no-route/no-path failure). + // Safe — and necessary — to refund. + await reverse(p); + outcome = "reversed-after-keysend-throw (no htlc sent)"; + } else if (ours.every((x: any) => x.status === "failed")) { + await reverse(p); + outcome = "reversed-after-keysend-throw (confirmed failed)"; + } else { + // Still in flight: CLN may yet settle it, so leave the debit. + outcome = "pending-after-keysend-throw"; + } } - // else: pending or completed — leave the debit; check() loop will - // reconcile. Reversing on pending would refund while CLN may still - // settle the keysend. } catch (verifyErr: any) { - warnThrottled("listpays verification failed (keysend)", verifyErr?.message ?? String(verifyErr)); + warnThrottled("sendpays verification failed (keysend)", verifyErr?.message ?? String(verifyErr)); outcome = "verify-failed-after-keysend-throw"; } warn("sendKeysend outcome", p.id, "=", outcome); -- cgit v1.2.3