From 10f9ebc0d047f1599f4665fda0b073c449bb8f64 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 27 Jun 2026 08:41:08 -0700 Subject: Add Boltz submarine + reverse swaps (Lightning <-> on-chain) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-custodial swaps via Boltz, fully client-side: hal holds the swap key + preimage and claims (reverse) / refunds (submarine) on-chain via the taproot script path, so Boltz can never strand funds. - src/swap.js: tapleaf/tree construction (byte-exact vs boltz-core vectors), swap P2TR address derivation (MuSig2 internal key + taptweak), script-path claim/refund tx builder, bolt11 decode, Boltz api-v2 client, and a polling SwapManager (reverse auto-claim; submarine fund + status + refund). - wallet.js: deterministic swap-key derivation (dedicated chain) + persistence. - api.js: configurable Boltz REST/WS endpoints. - app.js: ⚡ Swap tab — Receive over LN (reverse) + Pay an invoice (submarine). - tools/swap-*.test.js: vectors, live-address, and end-to-end regtest tests. Verified end-to-end on regtest against a local Boltz wired to coinos's bc + a dedicated CLN/hold node. Co-Authored-By: Claude Opus 4.8 --- src/api.js | 15 +++ src/app.js | 90 ++++++++++++++- src/swap.js | 359 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/wallet.js | 20 ++++ 4 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 src/swap.js (limited to 'src') diff --git a/src/api.js b/src/api.js index d135218..d6fa2ee 100644 --- a/src/api.js +++ b/src/api.js @@ -57,6 +57,21 @@ export function setRegtestEsplora(url) { try { localStorage.setItem(REGTEST_ESPLORA_KEY, (url || '').trim()); } catch {} } +// Boltz swap backend endpoints (regtest defaults; the api-v2 REST + the +// sidecar swap-status WebSocket). Overridable in Settings for staging/mainnet. +const BOLTZ_API_KEY = 'btc-wallet-boltz-api'; +const BOLTZ_WS_KEY = 'btc-wallet-boltz-ws'; +export function getBoltzApi() { + try { return (localStorage.getItem(BOLTZ_API_KEY) || '').trim() || 'http://localhost:9001'; } + catch { return 'http://localhost:9001'; } +} +export function getBoltzWs() { + try { return (localStorage.getItem(BOLTZ_WS_KEY) || '').trim() || 'ws://localhost:9004/v2/ws'; } + catch { return 'ws://localhost:9004/v2/ws'; } +} +export function setBoltzApi(url) { try { localStorage.setItem(BOLTZ_API_KEY, (url || '').trim()); } catch {} } +export function setBoltzWs(url) { try { localStorage.setItem(BOLTZ_WS_KEY, (url || '').trim()); } catch {} } + // Resolve the configured explorer to the host list the Api tries in order. function resolveHosts(net) { if (net === 'regtest') { diff --git a/src/app.js b/src/app.js index 1dbd478..04d87cc 100644 --- a/src/app.js +++ b/src/app.js @@ -8,7 +8,8 @@ import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxo import { qrSvg } from './qr.js'; import { scanQr } from './scan.js'; import { getSyncConfig, setSyncConfig } from './nostr.js'; -import { getExplorerConfig, setExplorerConfig, EXPLORER_PRESETS, getDataMode, setDataMode, ELECTRUM_PRESETS, getElectrumServerConfig, setElectrumServerConfig, getNetwork, setNetwork, getRegtestEsplora, setRegtestEsplora } from './api.js'; +import { getExplorerConfig, setExplorerConfig, EXPLORER_PRESETS, getDataMode, setDataMode, ELECTRUM_PRESETS, getElectrumServerConfig, setElectrumServerConfig, getNetwork, setNetwork, getRegtestEsplora, setRegtestEsplora, getBoltzApi, setBoltzApi } from './api.js'; +import { SwapManager } from './swap.js'; import { isSilentPaymentAddress } from './silentpay.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; import { @@ -22,6 +23,11 @@ import { } from './format.js'; const wallet = new Wallet(); +// Boltz swap orchestrator (reverse = receive over LN, submarine = spend over LN). +const swaps = new SwapManager({ + wallet, network: getNetwork(), getApi: getBoltzApi, + feeRate: 2, onUpdate: () => render(), +}); const ui = { screen: 'unlock', // 'unlock' | 'wallet' | 'claim' | 'howItWorks' @@ -638,6 +644,8 @@ async function activateAccount(acc, opts = {}) { // balance/history and re-enter the seed to spend again. if (acc.type !== 'watch' && !acc.provisional) acc.xpub = wallet.accountXpub(); persistAccounts(); + swaps.network = getNetwork(); + if (!wallet.watchOnly) { try { swaps.resumeAll(); } catch {} } // re-attach in-flight swap watchers const hadCache = wallet.restoreCache(); // show last-known balance/history instantly // An opened gift link starts on a claim/back-up screen instead of the wallet. ui.screen = opts.gift ? 'claim' : 'wallet'; @@ -1558,6 +1566,7 @@ function autoLockCard() { function changeNetwork(net) { if (net === getNetwork()) return; setNetwork(net); + swaps.network = net; const acc = accounts.find((a) => a.id === activeId); if (acc) activateAccount(acc); else render(); } @@ -2134,11 +2143,89 @@ function balanceCard() { ); } +async function doReverse() { + const amt = parseInt((ui.swapReverseAmt || '').trim(), 10); + if (!amt || amt < 50000) { ui.swapError = 'Enter at least 50,000 sats'; return render(); } + ui.swapError = ''; ui.swapBusy = true; render(); + try { await swaps.startReverse(amt); ui.swapReverseAmt = ''; } + catch (e) { ui.swapError = e.message; } + ui.swapBusy = false; render(); +} + +async function doSubmarine() { + const inv = (ui.swapInvoice || '').trim().toLowerCase().replace(/^lightning:/, ''); + if (!inv.startsWith('ln')) { ui.swapError = 'Paste a bolt11 invoice'; return render(); } + ui.swapError = ''; ui.swapBusy = true; render(); + try { await swaps.startSubmarine(inv); ui.swapInvoice = ''; } + catch (e) { ui.swapError = e.message; } + ui.swapBusy = false; render(); +} + +async function doRefund(id) { + ui.swapError = ''; ui.swapBusy = true; render(); + try { await swaps.refundSubmarine(id); } + catch (e) { ui.swapError = e.message; } + ui.swapBusy = false; render(); +} + +function swapListView() { + const list = swaps.list().slice().reverse(); + if (!list.length) return null; + return h('div', { class: 'card col' }, + h('h3', {}, 'Swaps'), + ...list.map((s) => h('div', { class: 'col', style: 'gap:6px;border-top:1px solid #2a2a2a;padding-top:8px' }, + h('div', { class: 'row', style: 'justify-content:space-between' }, + h('span', {}, s.kind === 'reverse' ? 'Receive ⚡→⛓' : 'Pay ⛓→⚡'), + h('span', { class: 'small muted' }, s.status + (s.boltzStatus ? ` (${s.boltzStatus})` : '')) + ), + (s.kind === 'reverse' && s.status === 'awaiting-payment') + ? h('div', { class: 'col', style: 'gap:6px' }, + h('div', { class: 'small faint' }, `Pay this ${s.onchainAmount ? '' : ''}invoice from any Lightning wallet:`), + h('div', { html: qrSvg(s.invoice.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }) }), + h('div', { class: 'addr-box break', style: 'font-size:11px' }, s.invoice)) + : null, + (s.kind === 'reverse' && s.status === 'claimed') + ? h('div', { class: 'small', style: 'color:#36d36b' }, `Received ${s.received} sats on-chain ✓`) : null, + (s.kind === 'submarine' && s.status === 'success') + ? h('div', { class: 'small', style: 'color:#36d36b' }, 'Invoice paid ✓') : null, + (s.kind === 'submarine' && ['funding', 'paying'].includes(s.status)) + ? h('div', { class: 'small faint' }, 'Funding lockup / waiting for Boltz to pay…') : null, + (s.kind === 'submarine' && s.status === 'refundable') + ? h('div', { class: 'col', style: 'gap:4px' }, + h('div', { class: 'small', style: 'color:#e0a13a' }, 'Payment failed — refundable after timeout block ' + s.timeoutBlockHeight), + h('button', { disabled: !!ui.swapBusy, onClick: () => doRefund(s.id) }, 'Refund on-chain')) + : null, + (s.status === 'refunded') ? h('div', { class: 'small', style: 'color:#36d36b' }, 'Refunded ✓') : null, + s.lastError ? h('div', { class: 'small', style: 'color:#e06a6a' }, s.lastError) : null + )) + ); +} + +function swapTab() { + return h('div', { class: 'col', style: 'gap:12px' }, + h('div', { class: 'card col' }, + h('h3', {}, '⚡ Receive over Lightning'), + h('p', { class: 'small muted', style: 'margin:0' }, 'Get a Lightning invoice; when paid, the sats are swapped on-chain into this wallet — non-custodial (you hold the preimage and claim on-chain).'), + h('label', { class: 'field' }, + h('span', { class: 'lab' }, 'Amount (sats)'), + h('input', { type: 'number', inputmode: 'numeric', placeholder: '100000', value: ui.swapReverseAmt || '', onInput: (e) => { ui.swapReverseAmt = e.target.value; } })), + h('button', { disabled: !!ui.swapBusy, onClick: doReverse }, ui.swapBusy ? '…' : 'Create invoice')), + wallet.watchOnly ? null : h('div', { class: 'card col' }, + h('h3', {}, '⚡ Pay a Lightning invoice'), + h('p', { class: 'small muted', style: 'margin:0' }, 'Pay a bolt11 from your on-chain balance: funds a swap that Boltz pays over Lightning.'), + h('textarea', { rows: 3, class: 'mono-input', placeholder: 'lnbcrt1…', value: ui.swapInvoice || '', onInput: (e) => { ui.swapInvoice = e.target.value; } }), + h('button', { disabled: !!ui.swapBusy, onClick: doSubmarine }, ui.swapBusy ? '…' : 'Pay invoice')), + ui.swapError ? h('div', { class: 'notice err' }, ui.swapError) : null, + swapListView() + ); +} + function tabsBar() { const tabs = [ ['receive', t('tabReceive')], // Watch-only wallets show Send too — it prompts to load the seed to spend. ['send', t('tabSend')], + ['swap', '⚡ Swap'], ['history', t('tabHistory')], ['settings', t('tabSettings')], ]; @@ -2164,6 +2251,7 @@ function tabContent() { switch (ui.tab) { case 'receive': return receiveTab(); case 'send': return sendTab(); + case 'swap': return swapTab(); case 'history': return historyTab(); case 'settings': return settingsTab(); } diff --git a/src/swap.js b/src/swap.js new file mode 100644 index 0000000..3d4cf54 --- /dev/null +++ b/src/swap.js @@ -0,0 +1,359 @@ +// 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' }; +const NETS = { mainnet: btc.NETWORK, testnet: 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. +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(); } + _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 + + // 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; + 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)), + }); + 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) { + 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 rec = { id: res.id, kind: 'submarine', swapIndex: idx, status: 'funding', network: net, paymentHash: bytesToHex(dec.paymentHash), lockupAddress: res.address, expectedAmount: res.expectedAmount, claimPublicKey: res.claimPublicKey, timeoutBlockHeight: res.timeoutBlockHeight, createdAt: Date.now() }; + this._save(rec); + const draft = w.buildTx({ recipients: [{ address: res.address, amount: res.expectedAmount }], feeRate: this.feeRate }); + const fundTxid = await w.broadcast(w.sign(draft.tx)); + if (w.applySentTx) w.applySentTx(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; + const done = new Set(['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); + } + } +} diff --git a/src/wallet.js b/src/wallet.js index a721c00..94132a6 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -1088,6 +1088,26 @@ export class Wallet { _saveReclaimed() { try { localStorage.setItem(this._reclaimedKey(), JSON.stringify([...this.reclaimedSet()])); } catch {} } + + // ---- 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 {} + } + nextSwapIndex() { + return this.loadSwaps().reduce((m, s) => Math.max(m, (s.swapIndex ?? -1) + 1), 0); + } + // 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); + } // Passive reclaim: free the coin for spending but keep tracking the live link // so it can still be revoked later (until the coin is spent). unreserve(id) { -- cgit v1.2.3