aboutsummaryrefslogtreecommitdiffstats
path: root/names/server.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-16 12:54:42 -0700
committerAdam Soltys <[email protected]>2026-10-16 12:54:42 -0700
commit04b395feba38e50506174c03fa537911d3f15459 (patch)
tree595bba344b10b7ba3f1b7f2ab4189510197e2820 /names/server.js
parent63430deca21d50621a7c1e2985a044a63210ace0 (diff)
downloadcoinosv3-server-04b395feba38e50506174c03fa537911d3f15459.tar.gz
coinosv3-server-04b395feba38e50506174c03fa537911d3f15459.zip
names: bring in the registrar as it ran on cs
The deployed registrar carried changes made directly on the server between Oct 10 and Oct 14 that never reached the repo: - forward() delivers to the same key's current name when the paid name was released in the meantime (a v2 migration swaps its throwaway npub name for the real one seconds after a payment can settle) - "nowhere to send" throws, so the payout stays queued instead of dropping - the forwarder float merges small coins (consolidateFloat) before a send hits the input cap - floatUpkeep skips coins already past expiry, which the server's sweeper has taken and whose refresh never settles Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'names/server.js')
-rw-r--r--names/server.js87
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);