diff options
| author | Adam Soltys <[email protected]> | 2026-10-27 12:38:48 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-27 12:38:48 -0700 |
| commit | 5f758f0583ba03adeab4daedc066714b358218d1 (patch) | |
| tree | c329d3c63014df58aef781286a9a8ad5800c29cc | |
| parent | a788054f0dcbdd17e2df7614d3156ad053afaa82 (diff) | |
| download | coinosv3-server-5f758f0583ba03adeab4daedc066714b358218d1.tar.gz coinosv3-server-5f758f0583ba03adeab4daedc066714b358218d1.zip | |
names: nothing goes out that didn't come in, and no key gets more than 1.5M an hour without a person
- 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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QjVvE4YAcK3AGwV7kZXeN4
| -rw-r--r-- | names/server.js | 44 |
1 files changed, 33 insertions, 11 deletions
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) { |
