aboutsummaryrefslogtreecommitdiffstats
path: root/names/server.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-27 12:24:17 -0700
committerAdam Soltys <[email protected]>2026-10-27 12:24:17 -0700
commitc2f8c3281f02f29c19025553addfbd7638256b74 (patch)
treee4dc0f4103f1dc781bdd31af347a8025119b2aad /names/server.js
parentf8d35dbd39d31f0ab7e2885ac7a7799596dd29fe (diff)
downloadcoinosv3-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
Diffstat (limited to 'names/server.js')
-rw-r--r--names/server.js45
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();
}