diff options
Diffstat (limited to 'names/server.js')
| -rw-r--r-- | names/server.js | 87 |
1 files changed, 79 insertions, 8 deletions
diff --git a/names/server.js b/names/server.js index 77d758c..104aa5e 100644 --- a/names/server.js +++ b/names/server.js @@ -364,15 +364,30 @@ 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. -async function forward(name, sat, hash) { - const rec = state.names[name]; +async function forward(name, sat, hash, pubkey) { + let rec = state.names[name]; + // 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 + // whatever name the same key holds now. + if (!rec && pubkey) { + const keyed = Object.keys(state.names).filter((n) => state.names[n].pubkey === pubkey); + const now = keyed.find((n) => n.endsWith('@' + name.split('@')[1])) || keyed[0]; + if (now) { log(`forward: ${name} is gone — ${sat} sat goes to ${now}, the same key's name now`); name = now; rec = state.names[now]; } + } const dest = rec && arkParamOf(rec.uri); const clink = clinkDest(rec); - if (!dest && !clink) { log(`forward: nowhere to send ${sat} sat for ${name}`); return; } + // stays queued (and loud): a returned "success" here once dropped a payment + if (!dest && !clink) throw new Error(`nowhere to send ${sat} sat for ${name}`); const viaFloat = async () => { if (!dest || !fwd || fwd.balance().spendableSat < sat) return false; - await fwd.send(dest, sat); + try { await fwd.send(dest, sat); } + catch (e) { + // enough sats, too many coins: merge them, and the queue's next try goes + if (/too many coins/.test(e.message)) consolidateFloat().catch((x) => log('float consolidate failed: ' + x.message)); + throw e; + } recordLnReceipt(name, sat, hash); persist(); log(`forwarded ${sat} sat to ${name}`); @@ -583,11 +598,11 @@ async function settleLoop() { persist(); } try { - await forward(name, sat, inv.payment_hash); + await forward(name, sat, inv.payment_hash, state.names[name]?.pubkey); } catch (e) { log(`forward failed for ${name} (${sat} sat): ${e.message} — queued`); state.pending = state.pending || []; - state.pending.push({ name, sat, hash: inv.payment_hash, ts: Date.now() }); + state.pending.push({ name, sat, hash: inv.payment_hash, pubkey: state.names[name]?.pubkey, ts: Date.now() }); persist(); } } @@ -673,8 +688,11 @@ async function floatUpkeep() { // never start a round while another action is still in flight if (!spendables.length || (fwd.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return; const tip = await fwd.chain.tipHeight(); + // not one already past it: the server's sweeper has taken that coin, and a + // refresh of it never settles — it sat 'submitted' from 2026-09-16 (a large + // coin, swept two blocks after expiry) and blocked every round after it const expiring = spendables.filter((v) => - v.expiryHeight && v.expiryHeight - tip < REFRESH_MARGIN_BLOCKS && v.amountSat >= 330); + v.expiryHeight && v.expiryHeight > tip && v.expiryHeight - tip < REFRESH_MARGIN_BLOCKS && v.amountSat >= 330); if (!expiring.length) return; floatRefreshAt = Date.now(); const sat = expiring.reduce((n, v) => n + v.amountSat, 0); @@ -683,6 +701,36 @@ async function floatUpkeep() { log(`float upkeep: refresh round complete — float now ${fwd.balance().spendableSat} sat`); } +// A float fed by many small receives can hold enough sats yet too many coins +// for one send (the 24-input cap in _selectInputs). Merge the smallest into +// one fresh round output; floatUpkeep also does it before a send has to fail. +const CONSOLIDATE_AT = 64, CONSOLIDATE_MAX = 64, CONSOLIDATE_SMALL = 5000; +let consolidating = false; +// the coins worth merging: live (an expired one is the sweeper's) and small — +// never the float's big coins, which would leave it at 0 until the round +// confirms (it did, once, on 2026-10-14) +async function smallCoins() { + const tip = await fwd.chain.tipHeight(); + return (fwd.state?.vtxos || []) + .filter((v) => v.state === 'spendable' && !(v.expiryHeight && v.expiryHeight <= tip) && v.amountSat < CONSOLIDATE_SMALL) + .sort((a, b) => a.amountSat - b.amountSat); +} +async function consolidateFloat() { + if (!fwd || consolidating) return; + if ((fwd.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return; + const small = (await smallCoins()).slice(0, CONSOLIDATE_MAX); + if (small.length < 2) return; + consolidating = true; + try { + const sat = small.reduce((n, v) => n + v.amountSat, 0); + log(`float consolidate: merging ${small.length} coins (${sat} sat)`); + await fwd.refresh(small.map((v) => v.id)); + log(`float consolidate: submitted — it settles once the round confirms`); + for (const p of state.pending || []) p.next = 0; + persist(); + } finally { consolidating = false; } +} + // retry queued forwards (e.g. after the float is topped up). Each failure // backs off exponentially (1m → 1h cap): a stuck forward must not fire an // offer request — and push-wake the recipient's devices — every minute @@ -694,12 +742,15 @@ setInterval(async () => { await fwd.sync().catch(() => {}); await autoTopupFloat().catch((e) => log('float self-top-up failed: ' + e.message)); await floatUpkeep().catch((e) => log('float upkeep failed: ' + e.message)); + if ((await smallCoins().catch(() => [])).length > CONSOLIDATE_AT) { + await consolidateFloat().catch((e) => log('float consolidate failed: ' + e.message)); + } const q = state.pending || []; if (!q.length) return; const still = []; for (const p of q) { if (p.next && Date.now() < p.next) { still.push(p); continue; } - try { await forward(p.name, p.sat, p.hash); } + try { await forward(p.name, p.sat, p.hash, p.pubkey); } catch (e) { log(`forward retry failed for ${p.name} (${p.sat} sat, try ${(p.tries || 0) + 1}): ${e.message}`); p.tries = (p.tries || 0) + 1; @@ -1574,6 +1625,26 @@ Bun.serve({ return json({ ok: true }); } + // Operator redelivery of a settled payment that never reached its owner. + // Keyed by the original payment hash: one hash pays out once, ever. + if (url.pathname === '/admin/forward' && req.method === 'POST') { + if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) return json({ error: 'unauthorized' }, 401); + let b; try { b = await req.json(); } catch { return json({ error: 'bad body' }, 400); } + const sat = Number(b.sat), hash = String(b.hash || ''); + if (!state.names[b.name] || !(sat > 0) || !/^[0-9a-f]{64}$/.test(hash)) return json({ error: 'need a current name, sat and the payment hash' }, 400); + const done = state.adminForwards ||= {}; + if (done[hash]) return json({ error: 'already redelivered', ...done[hash] }, 409); + if ((state.pending || []).some((p) => p.hash === hash)) return json({ error: 'still queued — it will go by itself' }, 409); + done[hash] = { name: b.name, sat, ts: Date.now() }; + persist(); + try { await forward(b.name, sat, hash, state.names[b.name].pubkey); } + // a send can fail after the coins moved: keep the mark, an operator + // checks the float before clearing it by hand + catch (e) { done[hash].failed = e.message; persist(); return json({ error: e.message }, 502); } + log(`admin redelivery: ${sat} sat to ${b.name} for ${hash.slice(0, 12)}`); + return json({ ok: true, ...done[hash] }); + } + if (url.pathname === '/admin/topup' && req.method === 'POST') { if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) { return json({ error: 'unauthorized' }, 401); |
