From 85c7256c86e6640f5424c8a1412d0323e4396aed Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 21 Sep 2026 11:51:16 -0700 Subject: Ark: an invoice the server hands back for a hash it already holds is not ours. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StartLightningReceive on a payment hash with an unpaid invoice returns that invoice — an earlier sale's amount and memo under a fresh QR (a 0.60 CAD bill showed an 8,467-sat invoice on 2026-09-21). The returned invoice must now carry the amount asked for and a comfortable expiry, or the index is treated as spoken for and skipped. The counter is also floored above every index an ln-recv action of ours already names, so a restored or merged state can't rewind it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Tmfg8k5f3TbNJS34qiqHAp --- src/ark/manager.js | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) (limited to 'src/ark') diff --git a/src/ark/manager.js b/src/ark/manager.js index 18c69b7..0440263 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -1343,7 +1343,12 @@ export class ArkManager { // count, but only once the state has travelled), and the server then // refuses the hash as already paid. Step past any such index rather // than fail the sale — the counter only ever moves up. - let idx, paymentHash, invoice; + // A restored or merged state can also carry a counter BELOW indices this + // wallet already used: never start under one an action of ours names. + for (const a of this.state.actions || []) { + if (a.type === 'ln-recv' && Number.isInteger(a.preimageIndex) && a.preimageIndex >= (this.state.nextLnRecvIndex || 0)) this.state.nextLnRecvIndex = a.preimageIndex + 1; + } + let idx, paymentHash, invoice, decInv; for (let tries = 0; ; tries++) { idx = this.state.nextLnRecvIndex || 0; this.state.nextLnRecvIndex = idx + 1; @@ -1353,7 +1358,6 @@ export class ArkManager { paymentHash, amountSat, minCltvDelta, mailboxPubkey: this._mailboxKey().pubkey, description, }); - break; } catch (e) { // "already been paid" for a settled one; "UNIQUE constraint failed: // invoices.payment_hash" for one merely issued before — both mean @@ -1361,13 +1365,23 @@ export class ArkManager { const used = /already been paid|already exists|already in use|duplicate|UNIQUE constraint|payment_hash/i.test(e?.message || ''); if (!used || tries >= 200) throw e; this._save(); // remember the skipped index even if the next try fails + continue; + } + decInv = decodeBolt11(invoice); + if (decInv.paymentHash !== hex.encode(paymentHash)) { + throw new Error('server invoice payment hash mismatch'); } + // The server answers a hash it already holds an unpaid invoice for + // with THAT invoice — an earlier sale's amount and memo under a new + // QR (seen live 2026-09-21: a 0.60 CAD bill showed an 8,467-sat + // invoice). Anything but a fresh invoice for this amount, with a + // comfortable expiry, means the index is spoken for. + const fresh = decInv.amountSat === amountSat && (!decInv.expiresAt || decInv.expiresAt > Date.now() + 10 * 60_000); + if (fresh) break; + if (tries >= 200) throw new Error('could not get a fresh invoice from the server'); + this._save(); } const keyIndex = this.state.nextKeyIndex++; - const decInv = decodeBolt11(invoice); - if (decInv.paymentHash !== hex.encode(paymentHash)) { - throw new Error('server invoice payment hash mismatch'); - } const action = { id: `lnrecv-${Date.now()}`, type: 'ln-recv', step: 'awaiting', paymentHash: hex.encode(paymentHash), preimageIndex: idx, keyIndex, -- cgit v1.2.3