From 348a57004f94dd0f58c4e70769e65ca7ea3f7619 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 1 Aug 2026 19:38:30 -0700 Subject: Simplify: one Lightning path, one balance, fewer features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropped: silent payments (feature, wallet chains, scan worker, indexer config), Boltz submarine/reverse swaps (feature + swap.js + tools), the third-party ark swap bridge (config, client flows, settings card — the ASP's own Lightning send is the one path now), and the Consolidate coins settings action. The NWC pouch is gone with them: background answering now spends the WHOLE ark balance. The IndexedDB record mirrors the real manager state plus the chain-3 keys for its live coins and a change-index window; the worker pays through the ASP exactly like the open app, and the app folds the worker's spends, change, and key allocations back in on connect (additive merge + reconcile against the server). A leftover chain-6 pouch from the short-lived pouch era is swept home automatically using the keys stored in its own record. The stated tradeoff: same-origin script access now reaches the ark balance, not a carve-out — the ark balance is walking-around money, the on-chain wallet's keys are never mirrored, and per-connection NWC budgets remain the enforced ceiling. Bundle: 726KB -> 602KB. Co-Authored-By: Claude Fable 5 --- src/swap.js | 459 ------------------------------------------------------------ 1 file changed, 459 deletions(-) delete mode 100644 src/swap.js (limited to 'src/swap.js') diff --git a/src/swap.js b/src/swap.js deleted file mode 100644 index 7aec8cd..0000000 --- a/src/swap.js +++ /dev/null @@ -1,459 +0,0 @@ -// 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, signet: 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 OP_EQUALVERIFY 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 OP_EQUALVERIFY 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): OP_CHECKSIGVERIFY 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. -// Wallet storage/key helpers for this feature, installed onto the core -// wallet instance so a build without the feature ships none of it. -export function installSwapWallet(wallet) { - if (wallet.loadSwaps) return; // already installed - Object.assign(wallet, { - // ---- Boltz swap support ---------------------------------------------- - // Persisted swap records (public metadata only — keys/preimage are re-derived - // deterministically from the seed via swapNode, so nothing secret is stored). - _swapKey() { return this._cacheKey() + ':swaps'; }, - loadSwaps() { - try { return JSON.parse(localStorage.getItem(this._swapKey()) || '[]'); } catch { return []; } - }, - 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() { - 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 - // privateKey + publicKey, 33-byte compressed). - swapNode(index) { - return this.account().deriveChild(2).deriveChild(index); - }, - }); -} - -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 destAddress = w.freshReceive().address; - const hex = buildSwapSpend({ pay: d.pay, leaf: d.claimLeaf, halPriv: node.privateKey, preimage, lockupTxid: u.txid, lockupVout: u.vout, lockupValue: u.value, destAddress, 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); - // Credit the claimed coin (single output → destAddress) now, so the balance - // + generic "payment received" fire instantly instead of waiting for a scan. - if (w.creditReceive) w.creditReceive({ txid, vout: 0, value: rec.received, address: destAddress }); - } 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); - } - } -} -- cgit v1.2.3