aboutsummaryrefslogtreecommitdiffstats
path: root/names/server.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-27 12:10:46 -0700
committerAdam Soltys <[email protected]>2026-10-27 12:10:46 -0700
commit3f745bc5c1ded49859b5dc3dd026d468b2124300 (patch)
tree68d46b29ab30d614c14784827e9395f41e374a66 /names/server.js
parent962528572fbe9eea22248bb4f157c3125876a8e2 (diff)
downloadcoinosv3-server-3f745bc5c1ded49859b5dc3dd026d468b2124300.tar.gz
coinosv3-server-3f745bc5c1ded49859b5dc3dd026d468b2124300.zip
names: a Lightning delivery that timed out is never paid again while it's still in flight
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01QjVvE4YAcK3AGwV7kZXeN4
Diffstat (limited to 'names/server.js')
-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;
};