aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-14 12:34:04 -0700
committerAdam Soltys <[email protected]>2026-06-17 07:47:47 -0700
commite55165307096093d769893475a7481c781457f94 (patch)
tree56be538ffc8f0b3412f5e93efb4ac9f3aaae2d17
parent1dc51829c39422495e289ba11f8668352abdba41 (diff)
downloadcoinos-server-e55165307096093d769893475a7481c781457f94.tar.gz
coinos-server-e55165307096093d769893475a7481c781457f94.zip
sendLightning/sendKeysend: instrument exit paths, tighten LEAKED DEBIT guard
Port of master 51107ed4 + fe767002 + cb0f1962 (combined) to the staging stack. Track an outcome string through every exit path, handle the xpay failed_parts branch explicitly (verify with listpays then finalize/ reverse/leave-pending), and dump abnormal xpay/keysend response shapes. Leak guard re-reads the db record before firing: if ref is persisted the payment is settled regardless of which path threw (finalize may set ref then throw on the later tbRefund), so only a genuinely missing ref logs "LEAKED DEBIT". Uses warn (no warnThrottled on this stack) and keeps the existing retry_for: 20. Co-Authored-By: Claude Opus 4.8 <[email protected]>
-rw-r--r--lib/payments.ts123
1 files changed, 111 insertions, 12 deletions
diff --git a/lib/payments.ts b/lib/payments.ts
index 7445300b..bb2a055d 100644
--- a/lib/payments.ts
+++ b/lib/payments.ts
@@ -1017,25 +1017,46 @@ export const sendKeysend = async ({
type: PaymentType.lightning,
});
+ let outcome = "unknown";
try {
- return await ln.keysend({
+ const r = await ln.keysend({
destination: pubkey,
amount_msat: amount * 1000,
maxfee: fee * 1000,
retry_for: 20,
extratlvs,
});
- } catch (e) {
+ warn("keysend returned", p.id, JSON.stringify({
+ status: r?.status,
+ has_preimage: !!(r?.payment_preimage ?? r?.preimage),
+ amount_sent_msat: r?.amount_sent_msat,
+ }));
+ outcome = "keysend-returned"; // success path — caller handles preimage polling
+ l("sendKeysend outcome", p.id, "=", outcome);
+ return r;
+ } catch (e: any) {
+ err("failed keysend", hash?.slice(0, 16), "error:", e?.message);
try {
const { pays } = await ln.listpays({ payment_hash: hash });
- const failed = !pays.length || pays.every((p) => p.status === "failed");
- if (failed) await reverse(p);
+ 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";
+ } else {
+ 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) {
warn("listpays verification failed (keysend)", verifyErr?.message ?? String(verifyErr));
+ outcome = "verify-failed-after-keysend-throw";
}
+ warn("sendKeysend outcome", p.id, "=", outcome);
throw e;
}
};
@@ -1082,6 +1103,11 @@ export const sendLightning = async ({ user, pr, amount, fee = undefined, memo =
l("paying lightning invoice", pr.substr(-8), amount, fee);
+ // Instrumentation: track which exit path the payment took so we can diagnose
+ // any future stuck "optimistic" cases (confirmed=true with no ref, no
+ // pending-set membership). Used by the leak guard at the function end.
+ let outcome = "unknown";
+
try {
const r = await ln.xpay({
invstring: pr.replace(/\s/g, "").toLowerCase(),
@@ -1091,32 +1117,105 @@ export const sendLightning = async ({ user, pr, amount, fee = undefined, memo =
retry_for: 20,
});
- try {
- if (!r.failed_parts) p = await finalize(r, p);
- } catch (e) {
- warn("failed to process payment", p.id);
+ // Only log the xpay response shape when there's something interesting —
+ // failed_parts > 0 or a missing preimage. The success case is noise.
+ if (r?.failed_parts || !(r?.payment_preimage ?? r?.preimage)) {
+ warn("xpay returned (abnormal)", p.id, JSON.stringify({
+ status: r?.status,
+ failed_parts: r?.failed_parts,
+ parts: r?.parts,
+ has_preimage: !!(r?.payment_preimage ?? r?.preimage),
+ amount_sent_msat: r?.amount_sent_msat,
+ amount_msat: r?.amount_msat,
+ }));
}
- } catch (e) {
- err("failed to pay", pr.substr(-8));
+
+ if (r.failed_parts) {
+ // xpay didn't throw but reported failed parts. The old code path skipped
+ // finalize and left the debit stranded. Verify with listpays and act.
+ warn("xpay returned failed_parts without throwing", p.id, r.failed_parts);
+ try {
+ const { pays } = await ln.listpays(pr);
+ const completed = pays.find((x) => x.status === "complete");
+ const failed = !pays.length || pays.every((x) => x.status === "failed");
+ if (completed) {
+ warn("xpay failed_parts but listpays complete — finalizing", p.id);
+ try { p = await finalize(completed, p); outcome = "finalized-via-listpays-after-failed_parts"; } catch (e: any) { warn("finalize threw in failed_parts branch", p.id, e?.message); outcome = "finalize-threw-failed_parts"; }
+ } else if (failed) {
+ warn("xpay failed_parts and listpays failed — reversing", p.id);
+ await reverse(p); outcome = "reversed-via-failed_parts";
+ } else {
+ outcome = "pending-after-failed_parts"; // leave for check() loop
+ }
+ } catch (verifyErr: any) {
+ warn("listpays verification failed (failed_parts branch)", verifyErr?.message ?? String(verifyErr));
+ outcome = "verify-failed-after-failed_parts";
+ }
+ } else {
+ try {
+ p = await finalize(r, p);
+ outcome = "finalized";
+ } catch (e: any) {
+ warn("finalize threw despite no failed_parts", p.id, e?.message);
+ outcome = "finalize-threw";
+ }
+ }
+ } catch (e: any) {
+ err("failed to pay", pr.substr(-8), "xpay error:", e?.message);
try {
const { pays } = await ln.listpays(pr);
+ warn("listpays after xpay-threw", p.id, JSON.stringify(pays?.map((x) => ({ status: x.status, amount_sent_msat: x.amount_sent_msat })) ?? []));
const completed = pays.find((p) => p.status === "complete");
const failed = !pays.length || pays.every((p) => p.status === "failed");
if (completed) {
warn("payment completed despite error, finalizing", p.id);
- try { await finalize(completed, p); } catch (_) {}
+ try { p = await finalize(completed, p); outcome = "finalized-after-xpay-throw"; } catch (_) { outcome = "finalize-threw-after-xpay-throw"; }
} else if (failed) {
- await reverse(p);
+ await reverse(p); outcome = "reversed-after-xpay-throw";
+ } else {
+ outcome = "pending-after-xpay-throw";
}
// else: pending — leave the debit in place; check() loop will reconcile
// once CLN confirms outcome. Reversing while still in flight would
// refund the user even though the payment may yet complete.
} catch (verifyErr: any) {
warn("listpays verification failed", verifyErr?.message ?? String(verifyErr));
+ outcome = "verify-failed-after-xpay-throw";
}
+ warn("sendLightning outcome", p.id, "=", outcome);
throw e;
}
+ // Leak guard: re-read the db record before deciding. If the payment has ref
+ // set in db it's settled regardless of which code path threw — finalize() may
+ // have set ref via s() and then thrown on a later line (e.g. the tbRefund fee
+ // refund), but the payment itself is recorded as complete. Only treat as a
+ // real leak if ref is genuinely missing from the db record. Pending-* outcomes
+ // are fine — check() will reconcile. finalize-threw-* with no ref is the
+ // joho33-style stuck state we want zero of; grep "LEAKED DEBIT" in app logs.
+ const finalizedOutcomes = new Set([
+ "finalized",
+ "finalized-via-listpays-after-failed_parts",
+ "finalized-after-xpay-throw",
+ ]);
+ const safeNonFinalized = new Set([
+ "reversed-via-failed_parts",
+ "reversed-after-xpay-throw",
+ "pending-after-failed_parts",
+ "pending-after-xpay-throw",
+ "verify-failed-after-failed_parts",
+ "verify-failed-after-xpay-throw",
+ ]);
+ const persisted = await g(`payment:${p.id}`);
+ const refInDb = !!persisted?.ref;
+ if (refInDb || finalizedOutcomes.has(outcome)) {
+ l("sendLightning outcome", p.id, "=", outcome, refInDb && !finalizedOutcomes.has(outcome) ? "(ref set in db despite throw)" : "");
+ } else if (safeNonFinalized.has(outcome)) {
+ warn("sendLightning outcome", p.id, "=", outcome);
+ } else {
+ err("LEAKED DEBIT in sendLightning", p.id, "outcome=", outcome, "user=", user?.username, "amount=", amount, "fee=", fee);
+ }
+
return p;
};