From 052e9c714092d8f4a5a9bb29bb85277630c1926d Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 29 Jun 2026 15:31:59 -0700 Subject: Swaps: don't reuse a burned swap index (deterministic-preimage collision) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reverse swap's preimage is sha256(swapNode(idx).privateKey) — deterministic per index. nextSwapIndex was derived only from *saved* swaps, so an attempt that created the swap at Boltz but threw before saving (e.g. the lockup-address-mismatch bug) never advanced the index; the next try reused the same preimage and Boltz rejected it ('a swap with this preimage hash exists already'). Make nextSwapIndex a persisted, monotonic counter (reserves the index on every call, seeded from saved swaps), and have startReverse retry with the next index when Boltz reports the preimage already exists — which also recovers wallets already stuck on a burned index. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/swap.js | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) (limited to 'src/swap.js') diff --git a/src/swap.js b/src/swap.js index 2627068..129712d 100644 --- a/src/swap.js +++ b/src/swap.js @@ -276,13 +276,25 @@ export class SwapManager { // (bolt11) the user has someone pay; hal then claims the lockup on-chain. async startReverse(amountSat) { const w = this.wallet, net = this.network; - const idx = w.nextSwapIndex(); - const node = w.swapNode(idx); - const preimage = sha256(node.privateKey); - const res = await boltzReq(this.getApi(), '/v2/swap/reverse', { - invoiceAmount: amountSat, from: 'BTC', to: 'BTC', - claimPublicKey: bytesToHex(node.publicKey), preimageHash: bytesToHex(sha256(preimage)), - }); + let idx, node, preimage, res; + // Our preimage is deterministic per swap index. If a prior attempt burned this + // index at Boltz ("swap with this preimage exists"), nextSwapIndex has advanced, + // so just retry with the next one. + for (let attempt = 0; ; attempt++) { + idx = w.nextSwapIndex(); + node = w.swapNode(idx); + preimage = sha256(node.privateKey); + try { + res = await boltzReq(this.getApi(), '/v2/swap/reverse', { + invoiceAmount: amountSat, from: 'BTC', to: 'BTC', + claimPublicKey: bytesToHex(node.publicKey), preimageHash: bytesToHex(sha256(preimage)), + }); + break; + } catch (e) { + if (attempt < 5 && /preimage|already exist/i.test(String(e?.message || e))) continue; + throw e; + } + } const d = swapP2TR({ kind: 'reverse', preimageHash160: hash160(preimage), boltzPub33: hexToBytes(res.refundPublicKey), halPub33: node.publicKey, lockTime: res.timeoutBlockHeight, network: net }); if (d.address !== res.lockupAddress) throw new Error('lockup address mismatch — not paying'); if (bytesToHex(decodeBolt11(res.invoice).paymentHash) !== bytesToHex(sha256(preimage))) throw new Error('invoice hash mismatch'); -- cgit v1.2.3