From 3f745bc5c1ded49859b5dc3dd026d468b2124300 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 27 Oct 2026 12:10:46 -0700 Subject: names: a Lightning delivery that timed out is never paid again while it's still in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit forward() records the payment hash of every Lightning delivery before it pays (state.inflight, keyed by the settled payment). A later try checks it first: complete = delivered, pending = wait, failed or never sent = go again. Before, waitsendpay's 240 s timeout queued the payment while its HTLC was still out, and the retry paid it a second time once the wallet claimed the first. A scripted wallet (usera7fc7e2c, "crit1 … double payout") held its claims to do exactly that on 2026-10-27; its key's forwards are parked for review (HOLD_KEYS). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QjVvE4YAcK3AGwV7kZXeN4 --- names/server.js | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) (limited to 'names') diff --git a/names/server.js b/names/server.js index 591222b..92c9138 100644 --- a/names/server.js +++ b/names/server.js @@ -376,8 +376,16 @@ function recordLnReceipt(name, sat, hash) { // 2. ask their wallet for an invoice and pay it over Lightning — needs no // float at all, but only works while something of theirs is listening // Failing both, it queues and retries; nothing is ever dropped silently. +// Keys whose forwards are set aside for a person to look at instead of paid: +// the scripted wallet that held its Lightning claims to be paid twice +// (2026-10-27, see `inflight` below). Parked, not dropped — state.parked. +const HOLD_KEYS = new Set(['a7fc7e2cbc16949c411a982fe6cecec0c1674fb4dabbdce5702757de90386d67']); + async function forward(name, sat, hash, pubkey) { let rec = state.names[name]; + if (HOLD_KEYS.has(pubkey) || HOLD_KEYS.has(rec?.pubkey)) { + throw Object.assign(new Error(`${name} is held for review (double-payout claims, 2026-10-27)`), { permanent: true }); + } // Released since the payment settled — a v2 migration swaps its throwaway // npub name for the real one seconds later, and payments stranded that // way on 2026-10-10. The money is the key's, not the name's: deliver to @@ -402,6 +410,36 @@ async function forward(name, sat, hash, pubkey) { const wrongNetwork = !!destArk && (dest.startsWith('tark') !== ((CFG.forwarder?.network || 'mainnet') !== 'mainnet')); try { ownServer = !!destArk && !!fwd?.serverPub && destArk.arkId === Buffer.from(arkIdFromServerPubkey(fwd.serverPub)).toString('hex'); } catch {} + // A Lightning delivery already sent for this payment decides what happens + // now. Waiting on it can time out while its HTLC is still out (the wallet + // claims when it likes); a retry that paid again then paid twice — a + // scripted wallet did exactly that, holding its claims past our 240 s, on + // 2026-10-27 ("crit1 … double payout", ~5M). So: done is done, still out + // is wait, and only a payment that really failed is tried again. + const sent = hash && state.inflight?.[hash]; + if (sent) { + const pays = (await ln.call('listpays', { payment_hash: sent.paymentHash })).pays || []; + if (pays.some((x) => x.status === 'complete')) { + delete state.inflight[hash]; + persist(); + log(`delivered ${sat} sat to ${name}: the earlier lightning payment ${sent.paymentHash.slice(0, 12)} completed`); + return; + } + // (no record at all: the send never left — fine to go again) + if (pays.some((x) => x.status === 'pending')) { + throw new Error(`lightning delivery ${sent.paymentHash.slice(0, 12)} still in flight — not paying again`); + } + log(`forward: the earlier lightning payment for ${name} ${pays.length ? 'failed' : 'never left'} — trying again`); + delete state.inflight[hash]; + persist(); + } + const markSent = (paymentHash) => { + if (!hash) return; + (state.inflight ||= {})[hash] = { paymentHash, name, sat, at: Date.now() }; + persist(); + }; + const settled = () => { if (hash && state.inflight?.[hash]) { delete state.inflight[hash]; persist(); } }; + const viaFloat = async () => { if (!ownServer || fwd.balance().spendableSat < sat) return false; try { await fwd.send(dest, sat); } @@ -432,13 +470,17 @@ async function forward(name, sat, hash, pubkey) { if (dec.payee === (await ln.call('getinfo')).id) { const pick = await circularPick(); if (!pick || pick.cap < sat) return false; + markSent(dec.payment_hash); const { paymentHash } = await payCircular(pr, pick); const r = await ln.call('waitsendpay', { payment_hash: paymentHash, timeout: 240 }); if (r.status !== 'complete') throw new Error(`circular delivery ${r.status}`); + settled(); log(`delivered ${sat} sat to ${name} over lightning via a circular route (no float needed)`); return true; } + markSent(dec.payment_hash); await ln.pay(pr, { maxfeeSat: Math.max(2, Math.ceil(sat * 0.005)) }); + settled(); log(`delivered ${sat} sat to ${name} over lightning (no float needed)`); return true; }; -- cgit v1.2.3