diff options
| author | Adam Soltys <[email protected]> | 2026-06-27 08:41:08 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-27 08:41:08 -0700 |
| commit | 10f9ebc0d047f1599f4665fda0b073c449bb8f64 (patch) | |
| tree | b5e68e8044cabd9d878b8ed8e3715d46e5cc9d0c /src/app.js | |
| parent | a9a903e7a1f80ed4a6f4163419b25d4e6de5db01 (diff) | |
| download | coinosv3-10f9ebc0d047f1599f4665fda0b073c449bb8f64.tar.gz coinosv3-10f9ebc0d047f1599f4665fda0b073c449bb8f64.zip | |
Add Boltz submarine + reverse swaps (Lightning <-> on-chain)
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 <[email protected]>
Diffstat (limited to 'src/app.js')
| -rw-r--r-- | src/app.js | 90 |
1 files changed, 89 insertions, 1 deletions
@@ -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(); } |
