diff options
| author | Adam Soltys <[email protected]> | 2026-10-27 12:24:17 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-27 12:24:17 -0700 |
| commit | c2f8c3281f02f29c19025553addfbd7638256b74 (patch) | |
| tree | e4dc0f4103f1dc781bdd31af347a8025119b2aad | |
| parent | f8d35dbd39d31f0ab7e2885ac7a7799596dd29fe (diff) | |
| download | coinosv3-server-c2f8c3281f02f29c19025553addfbd7638256b74.tar.gz coinosv3-server-c2f8c3281f02f29c19025553addfbd7638256b74.zip | |
names: one payment, one delivery — an idempotency record and a per-payment lock around forward(), the first try queued before it runs
- state.delivered[saleHash] is saved once a delivery succeeds; any later
caller (retry tick, settle loop, /admin/forward) returns without paying
- a sale already being delivered refuses a second concurrent caller (stays
queued) instead of racing it
- the settle loop queues a payment BEFORE its first try, so a restart
mid-delivery no longer loses it (the retry finds it; the records keep it
from paying twice)
- the retry tick no longer overwrites the queue with its snapshot (it
dropped payments queued while it ran)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QjVvE4YAcK3AGwV7kZXeN4
| -rw-r--r-- | names/server.js | 45 |
1 files changed, 37 insertions, 8 deletions
diff --git a/names/server.js b/names/server.js index 2e2634c..f8fa378 100644 --- a/names/server.js +++ b/names/server.js @@ -382,7 +382,29 @@ function recordLnReceipt(name, sat, hash) { const HOLD_KEYS = new Set(['a7fc7e2cbc16949c411a982fe6cecec0c1674fb4dabbdce5702757de90386d67', '42a0887a748f535f0796a55601bb3d2a3bfa537b13869e9185fba26222a0a005']); +// One payment, one delivery, whoever asks: the settle loop, the retry tick +// and the operator's /admin/forward all come through here. A sale being +// delivered right now refuses a second caller (it stays queued), and a sale +// delivered once is never delivered again — the record is saved before +// anything else happens. +const delivering = new Set(); 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); + 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(); + } + } finally { if (hash) delivering.delete(hash); } +} + +async function forwardOnce(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 }); @@ -699,16 +721,21 @@ async function settleLoop() { } // a released name's custom offer still knows its owner's key const pubkey = state.names[name]?.pubkey || (offerId && state.offers?.[offerId]?.pubkey) || undefined; + // on the queue BEFORE the first try: a restart mid-delivery used to lose + // the payment (the settle index had moved on, nothing was queued); the + // queue's retry now finds it, and forward()'s records keep that from + // paying twice + const p = { name, sat, hash: inv.payment_hash, pubkey, ts: Date.now() }; + (state.pending ||= []).push(p); + persist(); try { await forward(name, sat, inv.payment_hash, pubkey); + state.pending = (state.pending || []).filter((x) => x !== p); } catch (e) { - const p = { name, sat, hash: inv.payment_hash, pubkey, ts: Date.now() }; - if (queueFailedForward(p, e)) { - log(`forward failed for ${name} (${sat} sat): ${e.message} — queued`); - (state.pending ||= []).push(p); - } - persist(); + if (queueFailedForward(p, e)) log(`forward failed for ${name} (${sat} sat): ${e.message} — queued`); + else state.pending = (state.pending || []).filter((x) => x !== p); } + persist(); } } // -- float self-top-up ------------------------------------------------------- @@ -906,7 +933,7 @@ async function retryTick() { if ((await smallCoins().catch(() => [])).length > CONSOLIDATE_AT) { await consolidateFloat().catch((e) => log('float consolidate failed: ' + e.message)); } - const q = state.pending || []; + const q = [...(state.pending || [])]; if (!q.length) return; const still = []; for (const p of q) { @@ -923,7 +950,9 @@ async function retryTick() { if (queueFailedForward(p, e)) still.push(p); } } - state.pending = still; + // (only the ones this tick looked at: the settle loop may have queued more + // while it ran, and a plain `= still` dropped those) + state.pending = (state.pending || []).filter((p) => !q.includes(p) || still.includes(p)); persist(); } |
