aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--names/server.js42
1 files changed, 42 insertions, 0 deletions
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;
};