aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-29 15:31:59 -0700
committerAdam Soltys <[email protected]>2026-06-29 15:31:59 -0700
commit052e9c714092d8f4a5a9bb29bb85277630c1926d (patch)
treefdf7cc771f458df5834e34048a0c9870a9e55121 /src
parent87fcb80de91f539891cc2d635b0013d07826dd60 (diff)
downloadcoinosv3-052e9c714092d8f4a5a9bb29bb85277630c1926d.tar.gz
coinosv3-052e9c714092d8f4a5a9bb29bb85277630c1926d.zip
Swaps: don't reuse a burned swap index (deterministic-preimage collision)
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) <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/swap.js26
-rw-r--r--src/wallet.js13
2 files changed, 31 insertions, 8 deletions
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');
diff --git a/src/wallet.js b/src/wallet.js
index b3e3d40..c16bd66 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -1537,8 +1537,19 @@ export class Wallet {
saveSwaps(list) {
try { localStorage.setItem(this._swapKey(), JSON.stringify(list)); } catch {}
}
+ _swapIdxKey() { return this._cacheKey() + ':swapidx'; }
+ // Reserve the next swap index: monotonic + persisted on every call. A reverse/
+ // submarine attempt that fails AFTER Boltz created the swap (a later validation
+ // throw, a dropped response) still burns its index, so a retry can't reuse the
+ // same deterministic preimage (Boltz rejects "swap with this preimage exists").
+ // Seeded from saved swaps so existing wallets don't regress.
nextSwapIndex() {
- return this.loadSwaps().reduce((m, s) => Math.max(m, (s.swapIndex ?? -1) + 1), 0);
+ const fromSaved = this.loadSwaps().reduce((m, s) => Math.max(m, (s.swapIndex ?? -1) + 1), 0);
+ let stored = 0;
+ try { stored = parseInt(localStorage.getItem(this._swapIdxKey()) || '0', 10) || 0; } catch {}
+ const idx = Math.max(fromSaved, stored);
+ try { localStorage.setItem(this._swapIdxKey(), String(idx + 1)); } catch {}
+ return idx;
}
// Deterministic swap keypair on a dedicated chain (2) so swap keys never
// collide with receive (0) / change (1). Returns the HDKey node (has