From 5f758f0583ba03adeab4daedc066714b358218d1 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 27 Oct 2026 12:38:48 -0700 Subject: names: nothing goes out that didn't come in, and no key gets more than 1.5M an hour without a person MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - forward() refuses (parks) any delivery not backed by this node's own invoice for that payment hash, paid, for at least the amount — whatever path asked (settle loop, retry tick, /admin/forward) - a key past 1.5M sat delivered in the trailing hour has its next deliveries wait in the queue (ALERT line in the log; the desk monitor pages on a queue older than 30 min) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QjVvE4YAcK3AGwV7kZXeN4 --- names/server.js | 44 +++++++++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 11 deletions(-) (limited to 'names') diff --git a/names/server.js b/names/server.js index f2a9f41..a9ade90 100644 --- a/names/server.js +++ b/names/server.js @@ -388,20 +388,42 @@ const HOLD_KEYS = new Set(['a7fc7e2cbc16949c411a982fe6cecec0c1674fb4dabbdce57027 // delivered once is never delivered again — the record is saved before // anything else happens. const delivering = new Set(); +// Past this many sats delivered to one key within an hour, the next ones +// wait in the queue for a person (the monitor pages on a queue older than +// 30 min): a bug we haven't found yet costs at most this much per key. +const VELOCITY = { windowMs: 3600_000, maxSat: 1_500_000 }; async function forward(name, sat, hash, pubkey) { - if (hash && state.delivered?.[hash]) { log(`forward: ${hash.slice(0, 12)} was already delivered — not again`); return; } - if (hash && delivering.has(hash)) throw new Error('already being delivered'); - if (hash) delivering.add(hash); + if (!hash) throw Object.assign(new Error('no payment hash — refusing to deliver'), { permanent: true }); + if (state.delivered?.[hash]) { log(`forward: ${hash.slice(0, 12)} was already delivered — not again`); return; } + if (delivering.has(hash)) throw new Error('already being delivered'); + delivering.add(hash); try { - await forwardOnce(name, sat, hash, pubkey); - if (hash) { - const all = state.delivered ||= {}; - all[hash] = Date.now(); - const cutoff = Date.now() - 60 * 86400_000; - for (const [h, t] of Object.entries(all)) if (t < cutoff) delete all[h]; - persist(); + // Every delivery is backed by a payment that really arrived: our node's + // own invoice for this hash, paid, for at least this much. Whatever + // path got here (settle loop, retry, operator), nothing goes out that + // didn't come in. + const inv = ln && (await ln.call('listinvoices', { payment_hash: hash })).invoices?.[0]; + const got = Math.floor(Number(inv?.amount_received_msat?.msat ?? inv?.amount_received_msat ?? 0) / 1000); + if (!inv || inv.status !== 'paid' || got < sat) { + throw Object.assign(new Error(`no paid invoice of ${sat} sat behind ${hash.slice(0, 12)} (${inv ? inv.status + ' ' + got : 'none'}) — refusing`), { permanent: true }); + } + const key = pubkey || state.names[name]?.pubkey || name; + const recent = Object.values(state.deliveredTo?.[key] || {}).filter((x) => x.at > Date.now() - VELOCITY.windowMs); + const lastHour = recent.reduce((n, x) => n + x.sat, 0); + if (lastHour + sat > VELOCITY.maxSat) { + log(`ALERT forward velocity: ${key.slice(0, 12)} (${name}) got ${lastHour} sat in the last hour; ${sat} more waits for a person`); + throw new Error(`velocity hold: ${lastHour} sat delivered to this key in the last hour`); } - } finally { if (hash) delivering.delete(hash); } + await forwardOnce(name, sat, hash, pubkey); + const all = state.delivered ||= {}; + all[hash] = Date.now(); + const cutoff = Date.now() - 60 * 86400_000; + for (const [h, t] of Object.entries(all)) if (t < cutoff) delete all[h]; + const mine = (state.deliveredTo ||= {})[key] ||= {}; + mine[hash] = { sat, at: Date.now() }; + for (const [h, x] of Object.entries(mine)) if (x.at < Date.now() - VELOCITY.windowMs) delete mine[h]; + persist(); + } finally { delivering.delete(hash); } } async function forwardOnce(name, sat, hash, pubkey) { -- cgit v1.2.3