aboutsummaryrefslogtreecommitdiffstats
path: root/src/swap.js
blob: 129712d655f743918036bd8ca369603ccd2ff304 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
// Boltz submarine + reverse swap support for Hal — fully client-side and
// non-custodial. Hal holds the swap key + preimage and can always claim
// (reverse) or refund (submarine) on-chain via the Taproot SCRIPT PATH, so an
// uncooperative/offline Boltz can never strand funds. The cooperative MuSig2
// key-path spend (smaller witness) is a future optimization; correctness here
// relies only on the script path.
//
// Tapscript byte layouts and the two-leaf tree are replicated exactly from
// boltz-core (src/bitcoin/scripts/{swap_tree,reverse_tree,tree}.rs) and unit-
// tested against its published test vectors (see tools/swap-vectors.test.js).

import * as btc from '@scure/btc-signer';
import { p2tr, taprootListToTree, tapLeafHash as scureTapLeafHash } from '@scure/btc-signer/payment';
import { keyAggregate, keyAggExport, sortKeys } from '@scure/btc-signer/musig2';
import { schnorr } from '@noble/curves/secp256k1';
import { sha256 } from '@noble/hashes/sha2';
import { ripemd160 } from '@noble/hashes/legacy';
import { hexToBytes, bytesToHex, concatBytes } from '@noble/hashes/utils';
import { bech32 } from '@scure/base';

export { hexToBytes, bytesToHex };

// Regtest network object (same version bytes as testnet, `bcrt` HRP).
export const REGTEST = { ...btc.TEST_NETWORK, bech32: 'bcrt' };
// mutinynet is a signet — same address params as testnet (tb1…), matching Boltz's
// bitcoinSignet. Without this entry netFor() falls back to mainnet and the swap
// P2TR address is computed wrong → "lockup address mismatch".
const NETS = { mainnet: btc.NETWORK, testnet: btc.TEST_NETWORK, mutinynet: btc.TEST_NETWORK, regtest: REGTEST };
export const netFor = (name) => NETS[name] || btc.NETWORK;

const TAP_LEAF_VERSION = 0xc0;

export const hash160 = (b) => ripemd160(sha256(b));
const sha256d = (b) => sha256(b);

// BIP340/341 tagged hash: sha256(sha256(tag) || sha256(tag) || msg).
function taggedHash(tag, ...msgs) {
  const tagHash = sha256(new TextEncoder().encode(tag));
  return sha256(concatBytes(tagHash, tagHash, ...msgs));
}

// Minimal CScriptNum push of a non-negative locktime, as bitcoin's
// script::Builder::push_lock_time / push_int produces (used in the refund leaf).
function pushScriptNum(n) {
  if (n === 0) return Uint8Array.of(0x00); // OP_0
  if (n >= 1 && n <= 16) return Uint8Array.of(0x50 + n); // OP_1..OP_16
  const out = [];
  let v = n;
  while (v > 0) { out.push(v & 0xff); v >>= 8; }
  if (out[out.length - 1] & 0x80) out.push(0x00); // sign byte for positives
  return concatBytes(Uint8Array.of(out.length), Uint8Array.from(out));
}

// data push of a byte slice with its OP_PUSHBYTES_N length prefix (slices here
// are always < 76 bytes: a 20-byte hash160 or a 32-byte x-only key).
const pushData = (b) => concatBytes(Uint8Array.of(b.length), b);

const OP = { HASH160: 0xa9, EQUALVERIFY: 0x88, CHECKSIG: 0xac, CHECKSIGVERIFY: 0xad, CLTV: 0xb1, SIZE: 0x82 };

// Submarine claim leaf: OP_HASH160 <h160> OP_EQUALVERIFY <claimX> OP_CHECKSIG
export function submarineClaimLeaf(preimageHash160, claimXonly) {
  return concatBytes(
    Uint8Array.of(OP.HASH160), pushData(preimageHash160), Uint8Array.of(OP.EQUALVERIFY),
    pushData(claimXonly), Uint8Array.of(OP.CHECKSIG),
  );
}

// Reverse claim leaf: OP_SIZE <32> OP_EQUALVERIFY OP_HASH160 <h160> OP_EQUALVERIFY <claimX> OP_CHECKSIG
export function reverseClaimLeaf(preimageHash160, claimXonly) {
  return concatBytes(
    Uint8Array.of(OP.SIZE), pushData(Uint8Array.of(32)), Uint8Array.of(OP.EQUALVERIFY),
    Uint8Array.of(OP.HASH160), pushData(preimageHash160), Uint8Array.of(OP.EQUALVERIFY),
    pushData(claimXonly), Uint8Array.of(OP.CHECKSIG),
  );
}

// Refund leaf (both kinds): <refundX> OP_CHECKSIGVERIFY <locktime> OP_CLTV
export function refundLeaf(refundXonly, lockTime) {
  return concatBytes(
    pushData(refundXonly), Uint8Array.of(OP.CHECKSIGVERIFY),
    pushScriptNum(lockTime), Uint8Array.of(OP.CLTV),
  );
}

// Use @scure's BIP341 leaf hash (handles the compact-size encoding + tag).
export function tapLeafHash(script) {
  return scureTapLeafHash(script, TAP_LEAF_VERSION);
}

// Two-leaf Taproot merkle root: TapBranch of the lexicographically-sorted
// (claim, refund) leaf hashes (BIP341).
export function swapMerkleRoot(claimLeafScript, refundLeafScript) {
  const a = tapLeafHash(claimLeafScript);
  const b = tapLeafHash(refundLeafScript);
  const [lo, hi] = compareBytes(a, b) <= 0 ? [a, b] : [b, a];
  return taggedHash('TapBranch', lo, hi);
}

function compareBytes(a, b) {
  for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return a[i] - b[i]; }
  return 0;
}

// Build the full swap tree for a given kind from Hal's own inputs. Returns the
// leaf scripts + merkle root so the caller can verify Boltz's lockup address
// and later construct a script-path spend.
export function buildSwapTree(kind, preimageHash160, claimXonly, refundXonly, lockTime) {
  const claim = kind === 'reverse'
    ? reverseClaimLeaf(preimageHash160, claimXonly)
    : submarineClaimLeaf(preimageHash160, claimXonly);
  const refund = refundLeaf(refundXonly, lockTime);
  return { claimLeaf: claim, refundLeaf: refund, merkleRoot: swapMerkleRoot(claim, refund) };
}

// Taproot internal key = MuSig2 aggregate of the two 33-byte pubkeys, x-only,
// in the FIXED order [boltzKey, halKey] (no sort). Boltz builds the musig as
// Musig.create(ourPriv, [ourPub, theirPub]) without sorting, and "ours" is
// always Boltz, so the address is agg([boltzPub, halPub]). Verified live
// against Boltz's lockupAddress (6/6; sorted fails when key order differs).
export function swapInternalKey(boltzPub33, halPub33) {
  return keyAggExport(keyAggregate([boltzPub33, halPub33]));
}

// Full swap P2TR for either flow. Roles by kind:
//   reverse   — hal CLAIMS (preimage), Boltz REFUNDS  → claim leaf = halKey,   refund leaf = boltzKey
//   submarine — Boltz CLAIMS (preimage), hal REFUNDS  → claim leaf = boltzKey, refund leaf = halKey
// Internal key is always agg([boltzKey, halKey]). Pubkeys are 33-byte compressed.
// Returns the address + @scure p2tr payment (for control blocks/spending) + leaves.
export function swapP2TR({ kind, preimageHash160, boltzPub33, halPub33, lockTime, network = 'regtest' }) {
  const claimPub = kind === 'reverse' ? halPub33 : boltzPub33;   // who can claim
  const refundPub = kind === 'reverse' ? boltzPub33 : halPub33;  // who can refund
  const claim = kind === 'reverse'
    ? reverseClaimLeaf(preimageHash160, claimPub.slice(1))
    : submarineClaimLeaf(preimageHash160, claimPub.slice(1));
  const refund = refundLeaf(refundPub.slice(1), lockTime);
  const internalKey = swapInternalKey(boltzPub33, halPub33);
  const tree = taprootListToTree([{ script: claim }, { script: refund }]);
  const pay = p2tr(internalKey, tree, netFor(network), true);
  return { address: pay.address, script: pay.script, pay, internalKey, claimLeaf: claim, refundLeaf: refund };
}

const eqBytes = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);

// Build a signed taproot SCRIPT-PATH spend of a swap lockup output. This is the
// non-custodial path that needs no Boltz cooperation:
//   reverse claim  — witness [sig, preimage, claimLeaf, controlBlock]
//   submarine refund — witness [sig, refundLeaf, controlBlock], nLockTime=lockTime
// `leaf` is the leaf script being spent; `preimage` is required for a claim and
// omitted for a refund. Returns the raw signed tx hex ready to broadcast.
export function buildSwapSpend({
  pay, leaf, halPriv, preimage = null,
  lockupTxid, lockupVout, lockupValue, destAddress,
  fee, lockTime = 0, network = 'regtest',
}) {
  const net = netFor(network);
  const isRefund = preimage === null;
  // Find the @scure leaf entry (control block + script||version) for this leaf.
  const idx = pay.leaves.findIndex((l) => eqBytes(l.script, leaf));
  if (idx < 0) throw new Error('leaf not found in swap tree');
  const controlBlock = pay.leaves[idx].controlBlock;

  const tx = new btc.Transaction({ allowUnknownOutputs: true, version: 2 });
  tx.addInput({
    txid: lockupTxid, index: lockupVout,
    witnessUtxo: { script: pay.script, amount: BigInt(lockupValue) },
    tapLeafScript: [pay.tapLeafScript[idx]],
    tapInternalKey: pay.tapInternalKey,
    sequence: isRefund ? 0xfffffffd : 0xffffffff, // refund: non-final for CLTV
  });
  if (isRefund && lockTime) tx.lockTime = lockTime;
  tx.addOutputAddress(destAddress, BigInt(lockupValue - fee), net);

  // Sighash for the leaf (BIP341 script path, SIGHASH_DEFAULT) + schnorr sig.
  const sighash = tx.preimageWitnessV1(
    0, [pay.script], btc.SigHash.DEFAULT, [BigInt(lockupValue)],
    undefined, leaf, TAP_LEAF_VERSION,
  );
  const sig = schnorr.sign(sighash, halPriv);

  // Assemble the witness manually (preimage inserted for a claim).
  const witness = isRefund
    ? [sig, leaf, controlBlock]
    : [sig, preimage, leaf, controlBlock];
  tx.updateInput(0, { finalScriptWitness: witness });
  return tx.hex;
}

// ---------------------------------------------------------------------------
// Boltz api-v2 client + bolt11 decode + SwapManager (polling-based).
// ---------------------------------------------------------------------------

async function boltzReq(apiBase, path, body) {
  const opt = body
    ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }
    : {};
  const r = await fetch(apiBase.replace(/\/+$/, '') + path, opt);
  const j = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(j?.error || `boltz ${path}: ${r.status}`);
  return j;
}

function convertBits(data, from, to) {
  let acc = 0, bits = 0; const out = []; const maxv = (1 << to) - 1;
  for (const v of data) { acc = (acc << from) | v; bits += from; while (bits >= to) { bits -= to; out.push((acc >> bits) & maxv); } }
  return Uint8Array.from(out);
}

// Decode the payment hash (+ amount in msat, best-effort) from a bolt11 invoice.
export function decodeBolt11(invoice) {
  const dec = bech32.decode(invoice.toLowerCase(), 4000);
  const words = dec.words;
  const m = dec.prefix.match(/^ln[a-z]+?(\d+)([munp])?$/);
  let amountMsat = null;
  if (m && m[1]) {
    const n = BigInt(m[1]);
    if (m[2] === 'p') amountMsat = n / 10n;
    else amountMsat = n * ({ m: 100000000n, u: 100000n, n: 100n }[m[2]] ?? 100000000000n);
  }
  let payHash = null;
  const end = words.length - 104; // last 104 words = signature
  for (let i = 7; i + 3 <= end;) {
    const type = words[i];
    const len = (words[i + 1] << 5) | words[i + 2];
    if (type === 1 && len === 52) payHash = convertBits(words.slice(i + 3, i + 3 + len), 5, 8).slice(0, 32);
    i += 3 + len;
  }
  if (!payHash) throw new Error('invoice missing payment hash');
  return { paymentHash: payHash, amountMsat };
}

// Orchestrates non-custodial swaps: creates them via Boltz, verifies the lockup
// address locally before any funds move, then polls the chain (via the wallet's
// esplora api) to claim (reverse) or detect success/refund (submarine). DOM-free.
export class SwapManager {
  constructor({ wallet, network = 'regtest', getApi, feeRate = 2, onUpdate }) {
    this.wallet = wallet;
    this.network = network;
    this.getApi = getApi;                 // () => boltz REST base url
    this.feeRate = feeRate;               // sat/vB for funding + claim/refund
    this.onUpdate = onUpdate || (() => {});
    this.timers = new Map();
  }

  list() { return this.wallet.loadSwaps(); }
  // The swap (if any) an on-chain tx belongs to — its lockup funding (submarine),
  // claim (reverse), or refund. Lets the tx detail screen show the swap context.
  findByTxid(txid) { return this.list().find((s) => s.fundTxid === txid || s.claimTxid === txid || s.refundTxid === txid) || null; }
  _get(id) { return this.wallet.loadSwaps().find((s) => s.id === id); }
  _save(rec) {
    const list = this.wallet.loadSwaps();
    const i = list.findIndex((s) => s.id === rec.id);
    if (i >= 0) list[i] = rec; else list.push(rec);
    this.wallet.saveSwaps(list);
    this.onUpdate(rec);
  }
  _stop(id) { const t = this.timers.get(id); if (t) clearInterval(t); this.timers.delete(id); }
  _spendFee() { return Math.max(250, Math.ceil(170 * this.feeRate)); } // ~1-input taproot spend

  // The selected provider's reverse-swap limits (sat), fetched + cached per API.
  // Lets the UI bound the amount to what the provider actually accepts instead of
  // a hardcoded guess (providers + networks differ). Falls back conservatively.
  async reverseLimits() {
    const api = this.getApi();
    if (this._revLim && this._revLimApi === api) return this._revLim;
    let lim = { min: 25000, max: 25000000 };
    try {
      const res = await boltzReq(api, '/v2/swap/reverse');
      const b = res && res.BTC && res.BTC.BTC && res.BTC.BTC.limits;
      if (b && b.minimal) lim = { min: b.minimal, max: b.maximal };
    } catch {}
    this._revLim = lim; this._revLimApi = api;
    return lim;
  }

  // REVERSE: hal receives over Lightning. Returns a record whose `.invoice`
  // (bolt11) the user has someone pay; hal then claims the lockup on-chain.
  async startReverse(amountSat) {
    const w = this.wallet, net = this.network;
    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');
    const rec = { id: res.id, kind: 'reverse', swapIndex: idx, status: 'awaiting-payment', network: net, invoice: res.invoice, lockupAddress: res.lockupAddress, onchainAmount: res.onchainAmount, refundPublicKey: res.refundPublicKey, timeoutBlockHeight: res.timeoutBlockHeight, createdAt: Date.now() };
    this._save(rec);
    this._watchReverse(rec.id);
    return rec;
  }

  _watchReverse(id) {
    if (this.timers.has(id)) return;
    const w = this.wallet, net = this.network;
    const tick = async () => {
      const rec = this._get(id);
      if (!rec || ['claimed', 'failed'].includes(rec.status)) return this._stop(id);
      try {
        const utxos = await w.api.addressUtxos(rec.lockupAddress);
        if (!utxos || !utxos.length) return;
        const u = utxos[0];
        const node = w.swapNode(rec.swapIndex);
        const preimage = sha256(node.privateKey);
        const d = swapP2TR({ kind: 'reverse', preimageHash160: hash160(preimage), boltzPub33: hexToBytes(rec.refundPublicKey), halPub33: node.publicKey, lockTime: rec.timeoutBlockHeight, network: net });
        const fee = this._spendFee();
        const hex = buildSwapSpend({ pay: d.pay, leaf: d.claimLeaf, halPriv: node.privateKey, preimage, lockupTxid: u.txid, lockupVout: u.vout, lockupValue: u.value, destAddress: w.freshReceive().address, fee, network: net });
        const txid = await w.api.broadcast(hex);
        rec.status = 'claimed'; rec.claimTxid = txid; rec.received = u.value - fee;
        this._save(rec); this._stop(id);
      } catch (e) { rec.lastError = String(e?.message || e); this.onUpdate(rec); }
    };
    this.timers.set(id, setInterval(tick, 3000)); tick();
  }

  // SUBMARINE: hal spends over Lightning. Funds the lockup from the wallet;
  // Boltz pays the invoice + claims. On failure, refund after the timeout.
  async startSubmarine(invoice) {
    return this.fundQuotedSubmarine(await this.quoteSubmarine(invoice));
  }

  // Quote a submarine swap WITHOUT committing funds: ask Boltz for the lockup
  // amount + build (but don't broadcast) the funding tx, so the UI can show an
  // itemized cost (amount + Boltz fee + on-chain network fee) before the user
  // pays. Returns everything fundQuotedSubmarine() needs.
  async quoteSubmarine(invoice) {
    const w = this.wallet, net = this.network;
    const dec = decodeBolt11(invoice);
    const idx = w.nextSwapIndex();
    const node = w.swapNode(idx);
    const res = await boltzReq(this.getApi(), '/v2/swap/submarine', { invoice, from: 'BTC', to: 'BTC', refundPublicKey: bytesToHex(node.publicKey) });
    const d = swapP2TR({ kind: 'submarine', preimageHash160: ripemd160(dec.paymentHash), boltzPub33: hexToBytes(res.claimPublicKey), halPub33: node.publicKey, lockTime: res.timeoutBlockHeight, network: net });
    if (d.address !== res.address) throw new Error('lockup address mismatch — not funding');
    const draft = w.buildTx({ recipients: [{ address: res.address, amount: res.expectedAmount }], feeRate: this.feeRate });
    const invoiceAmount = dec.amountMsat ? Number(dec.amountMsat / 1000n) : null;
    return {
      invoice, id: res.id, swapIndex: idx, lockupAddress: res.address, expectedAmount: res.expectedAmount,
      claimPublicKey: res.claimPublicKey, timeoutBlockHeight: res.timeoutBlockHeight, paymentHash: bytesToHex(dec.paymentHash),
      invoiceAmount, networkFee: draft.fee, boltzFee: invoiceAmount != null ? res.expectedAmount - invoiceAmount : null,
      total: res.expectedAmount + draft.fee, _draft: draft,
    };
  }

  // Broadcast the funding tx for a previously-quoted submarine swap + watch it.
  async fundQuotedSubmarine(q) {
    const w = this.wallet, net = this.network;
    const rec = { id: q.id, kind: 'submarine', swapIndex: q.swapIndex, status: 'funding', network: net, paymentHash: q.paymentHash, lockupAddress: q.lockupAddress, expectedAmount: q.expectedAmount, claimPublicKey: q.claimPublicKey, timeoutBlockHeight: q.timeoutBlockHeight, invoiceAmount: q.invoiceAmount, createdAt: Date.now() };
    this._save(rec);
    const fundTxid = await w.broadcast(w.sign(q._draft.tx));
    if (w.applySentTx) w.applySentTx(q._draft.tx);
    rec.fundTxid = fundTxid; rec.status = 'paying'; this._save(rec);
    this._watchSubmarine(rec.id);
    return rec;
  }

  _watchSubmarine(id) {
    if (this.timers.has(id)) return;
    // For a submarine swap the success moment is invoice.paid — Boltz has paid the
    // Lightning invoice (the whole point). It then claims its on-chain lockup
    // (transaction.claim.pending → claimed), often deferred to a later batch, but
    // our funds are already gone to the LN destination, so we're done either way.
    const done = new Set(['invoice.paid', 'transaction.claim.pending', 'transaction.claimed', 'invoice.settled']);
    const bad = new Set(['invoice.failedToPay', 'swap.expired', 'transaction.lockupFailed', 'transaction.refunded']);
    const tick = async () => {
      const rec = this._get(id);
      if (!rec || ['success', 'refunded', 'failed'].includes(rec.status)) return this._stop(id);
      try {
        const s = await boltzReq(this.getApi(), `/v2/swap/${rec.id}`);
        rec.boltzStatus = s.status;
        if (done.has(s.status)) { rec.status = 'success'; this._save(rec); this._stop(id); return; }
        if (bad.has(s.status)) { rec.status = 'refundable'; this._save(rec); this._stop(id); return; }
        this._save(rec);
      } catch (e) { rec.lastError = String(e?.message || e); this.onUpdate(rec); }
    };
    this.timers.set(id, setInterval(tick, 3000)); tick();
  }

  // Refund a failed submarine swap on-chain via the timeout script path. Only
  // succeeds once chain height >= timeoutBlockHeight (CLTV).
  async refundSubmarine(id) {
    const w = this.wallet, net = this.network;
    const rec = this._get(id);
    if (!rec || rec.kind !== 'submarine') throw new Error('not a submarine swap');
    const utxos = await w.api.addressUtxos(rec.lockupAddress);
    if (!utxos || !utxos.length) throw new Error('no lockup utxo to refund');
    const u = utxos[0];
    const node = w.swapNode(rec.swapIndex);
    const d = swapP2TR({ kind: 'submarine', preimageHash160: ripemd160(hexToBytes(rec.paymentHash)), boltzPub33: hexToBytes(rec.claimPublicKey), halPub33: node.publicKey, lockTime: rec.timeoutBlockHeight, network: net });
    const fee = this._spendFee();
    const hex = buildSwapSpend({ pay: d.pay, leaf: d.refundLeaf, halPriv: node.privateKey, preimage: null, lockupTxid: u.txid, lockupVout: u.vout, lockupValue: u.value, destAddress: w.freshReceive().address, fee, lockTime: rec.timeoutBlockHeight, network: net });
    const txid = await w.api.broadcast(hex);
    rec.status = 'refunded'; rec.refundTxid = txid; this._save(rec);
    return txid;
  }

  // Re-attach watchers to non-final swaps after a page reload.
  resumeAll() {
    for (const rec of this.list()) {
      if (rec.kind === 'reverse' && !['claimed', 'failed'].includes(rec.status)) this._watchReverse(rec.id);
      else if (rec.kind === 'submarine' && ['funding', 'paying'].includes(rec.status)) this._watchSubmarine(rec.id);
    }
  }
}