diff options
| author | Adam Soltys <[email protected]> | 2026-07-04 08:55:45 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-04 08:55:45 -0700 |
| commit | 11a14600da683769c74d49e3d973183aa1a2fe5e (patch) | |
| tree | 004ac05ad9a6f6e109acb2d0efdf82b19747d48a /src | |
| parent | 65ad54a060a8035d8cf1e1ca2004f7c3bc5ff296 (diff) | |
| download | coinosv3-11a14600da683769c74d49e3d973183aa1a2fe5e.tar.gz coinosv3-11a14600da683769c74d49e3d973183aa1a2fe5e.zip | |
Features: extract swaps, Ark, and SP behind a plugin seam
First step toward optional build profiles. app.js gains a small feature
registry (ctx + FEATURES + hook helpers); swaps/Ark/silent-payments UI
moves wholesale to src/features/{swaps,ark,sp}.js, each exporting a
factory returning integration hooks:
init/stop/networkChanged wallet lifecycle
receiveModes/receiveTakeover receive tab
isSendDest/matchSendText/
interceptReview/sendView/
hideSendControls/sendFormNote send flow
historyEntries/historyDetail/
txDetailSection history
balanceLines/settingsCards balance card + settings
No behavior change: ark + gift browser e2e suites pass, and a probe
confirms LN receive, all receive modes, and the three settings cards
render as before. Gifts extraction and build flags come next.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QFAcD4V284DvYZZHP7Rxxy
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 692 | ||||
| -rw-r--r-- | src/features/ark.js | 429 | ||||
| -rw-r--r-- | src/features/sp.js | 57 | ||||
| -rw-r--r-- | src/features/swaps.js | 218 |
4 files changed, 777 insertions, 619 deletions
@@ -8,10 +8,10 @@ import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxo import { qrSvg } from './qr.js'; import { scanQr } from './scan.js'; import { getSyncConfig, setSyncConfig, parseNostrPubkey, npubOf, fetchNostrProfile, decryptWithCode } from './nostr.js'; -import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS, getSpIndexerConfig, setSpIndexerConfig, spIndexerPresets, getBoltzApi, BOLTZ_PRESETS, getBoltzProviderId, setBoltzProviderId, getBoltzCustom, setBoltzCustom, arkPresets, getArkProviderId, setArkProviderId, getArkCustom, setArkCustom, getArkConfig } from './api.js'; -import { SwapManager } from './swap.js'; -import { ArkManager } from './ark/manager.js'; -import { isSilentPaymentAddress } from './silentpay.js'; +import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js'; +import { swapsFeature } from './features/swaps.js'; +import { arkFeature } from './features/ark.js'; +import { spFeature } from './features/sp.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; import { fmtBtc, @@ -24,94 +24,29 @@ 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(), -}); -// Ark (off-chain payments via an ASP, spoken natively — see src/ark). One -// manager per open wallet; null when Ark is off in Settings, watch-only, or -// still connecting. State persists under the wallet's cache key. -let ark = null; -let arkTimer = null; -let arkConnectPromise = null; -let arkInitGen = 0; // guards against a stale init() resolving after a wallet switch +// ---- optional features ------------------------------------------------ +// Swaps, Ark, and silent payments plug into fixed seams (receive modes, send +// matchers, history entries, balance lines, settings cards, lifecycle hooks). +// FEATURES/ctx are assembled near the bottom of this file, once every helper +// they capture exists; the hooks below are only ever called at runtime. +const featureHook = (name, ...args) => { + for (const f of FEATURES) { + if (!f[name]) continue; + const v = f[name](...args); + if (v) return v; + } + return null; +}; +const featureAll = (name, ...args) => FEATURES.flatMap((f) => (f[name] ? f[name](...args) : [])); +const featureMatchSend = (text) => FEATURES.some((f) => f.matchSendText && f.matchSendText(text)); -function stopArk() { - arkInitGen++; - if (arkTimer) clearInterval(arkTimer); - arkTimer = null; - if (ark) ark.stopMailboxStream(); - ark = null; - arkConnectPromise = null; -} -// Ark is available (a server is configured and we hold keys), even if we -// haven't dialed the server yet — gates the UI entry points. -function arkAvailable() { - return !!getArkConfig() && !wallet.watchOnly && !!wallet.account; -} -// Whether this wallet has used Ark before (coins, unfinished operations, or -// history). Only then does opening the wallet dial the server on its own — -// fresh wallets stay silent until the user first touches an Ark feature. -function arkWanted() { - const st = wallet.loadArkState(); - return !!(st && ((st.vtxos || []).length || (st.movements || []).length - || (st.actions || []).some((a) => !['done', 'failed'].includes(a.step)))); -} -function initArk() { - stopArk(); - ui.arkError = ''; - if (arkAvailable() && arkWanted()) connectArk().catch(() => {}); -} -// Connect on demand; idempotent (returns the live manager or the in-flight -// connection). Callers surface ui.arkError on failure. -function connectArk() { - if (ark) return Promise.resolve(ark); - if (arkConnectPromise) return arkConnectPromise; - if (!arkAvailable()) return Promise.reject(new Error(t('arkNotConnected'))); - const cfg = getArkConfig(); - const gen = arkInitGen; - const mgr = new ArkManager({ - account: wallet.account(), - storage: { load: () => wallet.loadArkState(), save: (s) => wallet.saveArkState(s) }, - arkUrl: cfg.ark, - esploraUrl: cfg.esplora, - network: getNetwork(), - onUpdate: () => render(), - }); - ui.arkError = ''; - arkConnectPromise = mgr.init().then(() => { - if (gen !== arkInitGen) throw new Error('superseded'); // wallet switched mid-connect - ark = mgr; - const tick = () => mgr.sync().catch(() => {}); - tick(); - // Receives arrive in real time over the mailbox stream; the poll is the - // fallback and what drives in-flight boards/refreshes forward. - mgr.startMailboxStream(); - arkTimer = setInterval(() => { if (ark === mgr) tick(); }, getNetwork() === 'regtest' ? 5000 : 30000); - render(); - return mgr; - }).catch((e) => { - if (gen === arkInitGen) { ui.arkError = e.message; render(); } - throw e; - }).finally(() => { arkConnectPromise = null; }); - render(); - return arkConnectPromise; -} -// The spendable Ark balance (sats), or null when Ark isn't active. -function arkBalance() { - if (!ark || !ark.state) return null; - return ark.balance(); -} -// t?ark1… bech32m — an Ark address for this or another ASP. -function isArkAddress(a) { return /^t?ark1[a-z0-9]{20,}$/i.test((a || '').trim()); } const ui = { screen: 'unlock', // 'unlock' | 'wallet' | 'claim' | 'howItWorks' @@ -857,9 +792,7 @@ 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 - initArk(); // connect to the Ark server (no-op when Ark is off in Settings) + for (const f of FEATURES) { try { f.init && f.init(); } catch {} } // per-feature wallet lifecycle 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'; @@ -1527,11 +1460,9 @@ function settingsTab() { : h('button', { onClick: () => { ui.addrScan = true; ui.addrScanPage = 0; render(); } }, t('rescanAddresses')) ), networkCard(), - boltzProviderCard(), - arkCard(), + ...featureAll('settingsCards'), consolidateCard(), explorerCard(), - spIndexerCard(), wallet.watchOnly || !wallet.mnemonic ? null : syncCard() ); } @@ -1803,146 +1734,14 @@ async function doRevoke(id) { function changeNetwork(net) { if (net === getNetwork()) return; setNetwork(net); - swaps.network = net; - ui.swapLimits = null; // limits differ per network's provider + for (const f of FEATURES) { try { f.networkChanged && f.networkChanged(net); } catch {} } const acc = accounts.find((a) => a.id === activeId); if (acc) activateAccount(acc); else render(); } -// Swap provider selector. Boltz-compatible providers (from SwapMarket) plus a -// custom option. Swaps stay non-custodial regardless of provider. -function boltzProviderCard() { - const id = getBoltzProviderId(); - const custom = getBoltzCustom(); - return h( - 'div', - { class: 'card col' }, - h('h3', {}, '⚡ Swap provider'), - h('p', { class: 'small muted', style: 'margin:0' }, 'Boltz-compatible provider for Lightning swaps. Non-custodial: a provider can fail a swap but never take your funds.'), - h('select', { onChange: (e) => { setBoltzProviderId(e.target.value); ui.swapLimits = null; render(); } }, - BOLTZ_PRESETS.map((p) => h('option', { value: p.id, selected: p.id === id }, p.label))), - id === 'custom' - ? h('div', { class: 'col', style: 'gap:8px' }, - h('label', { class: 'field' }, - h('span', { class: 'lab' }, 'API URL'), - h('input', { - type: 'text', class: 'mono-input', placeholder: 'https://api.example.com', - autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: custom.api, - onChange: (e) => { setBoltzCustom({ api: e.target.value.trim(), ws: custom.ws }); ui.swapLimits = null; render(); }, - })), - h('label', { class: 'field' }, - h('span', { class: 'lab' }, 'WebSocket URL (optional)'), - h('input', { - type: 'text', class: 'mono-input', placeholder: 'wss://api.example.com/v2/ws', - autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: custom.ws, - onChange: (e) => { setBoltzCustom({ api: custom.api, ws: e.target.value.trim() }); render(); }, - }), - h('div', { class: 'small faint' }, 'Left blank, the WS is derived from the API URL.'))) - : h('div', { class: 'small faint', style: 'word-break:break-all' }, getBoltzApi()) - ); -} - -// Ark server selector + wallet panel. Off by default; picking a server spins -// up the ArkManager for this wallet (off-chain balance, board, refresh). -function arkCard() { - const net = getNetwork(); - const id = getArkProviderId(net); - const custom = getArkCustom(net); - const presets = arkPresets(net); - const applyProvider = (v) => { - setArkProviderId(v, net); - ui.receiveType = null; - initArk(); - render(); - }; - const head = [ - h('h3', {}, '⚔ Ark'), - h('p', { class: 'small muted', style: 'margin:0' }, t('arkDesc')), - h('select', { onChange: (e) => applyProvider(e.target.value) }, - presets.map((p) => h('option', { value: p.id, selected: p.id === id }, p.label))), - id === 'custom' - ? h('div', { class: 'col', style: 'gap:8px' }, - h('label', { class: 'field' }, - h('span', { class: 'lab' }, t('arkServerUrl')), - h('input', { - type: 'text', class: 'mono-input', placeholder: 'https://ark.example.com', - autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: custom.ark, - onChange: (e) => { setArkCustom({ ark: e.target.value.trim(), esplora: custom.esplora }, net); initArk(); render(); }, - })), - h('label', { class: 'field' }, - h('span', { class: 'lab' }, t('arkEsploraUrl')), - h('input', { - type: 'text', class: 'mono-input', placeholder: 'https://mempool.example.com/api', - autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: custom.esplora, - onChange: (e) => { setArkCustom({ ark: custom.ark, esplora: e.target.value.trim() }, net); initArk(); render(); }, - }))) - : null, - ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null, - ]; - if (id === 'off' || wallet.watchOnly) { - return h('div', { class: 'card col' }, ...head, - wallet.watchOnly && id !== 'off' ? h('div', { class: 'small faint' }, t('arkWatchOnly')) : null); - } - if (!ark || !ark.state) { - return h('div', { class: 'card col' }, ...head, - arkConnectPromise - ? h('div', { class: 'row gap6', style: 'align-items:center' }, - h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkConnecting'))) - : h('button', { class: 'btn-ghost btn-block', onClick: () => connectArk().catch(() => {}) }, t('arkConnectBtn'))); - } - const spendables = ark.vtxos().filter((v) => v.state === 'spendable'); - const pendingActions = ark.pendingActions(); - const row = (k, v) => h('div', { class: 'row between' }, h('span', { class: 'small muted' }, k), h('span', { class: 'small' }, v)); - - return h( - 'div', - { class: 'card col', style: 'gap:10px' }, - ...head, - spendables.length ? row(t('arkVtxos'), String(spendables.length)) : null, - pendingActions.length - ? h('div', { class: 'small muted' }, t('arkPendingActions', { n: pendingActions.length }) + ' — ' + pendingActions.map((a) => `${a.type}:${a.step}`).join(', ')) - : null, - spendables.length >= 1 - ? h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy, onClick: doArkRefresh }, - ui.arkBusy === 'refresh' ? h('span', { class: 'spinner sm' }) : t('arkRefreshBtn', { n: spendables.length })) - : null - ); -} -// Board: startBoard() gives the funding address; hal's own on-chain wallet pays -// it (recipient pinned to vout 0 — the ASP requires the board output there), -// then the manager waits for confirmations and registers via the sync loop. -async function doArkBoard() { - const sats = parseInt((ui.arkBoardAmt || '').trim(), 10); - if (!sats) return; - ui.arkBusy = 'board'; ui.arkError = ''; render(); - try { - const { actionId, fundingAddress, feeSat } = await ark.startBoard(sats); - const feeRate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; - const draft = wallet.buildTx({ recipients: [{ address: fundingAddress, amount: sats }], feeRate, noSort: true }); - const hexTx = wallet.sign(draft.tx); - const txid = await wallet.broadcast(hexTx); - await ark.completeBoard(actionId, txid); - ui.arkBoardAmt = ''; - ui.arkBoarded = { txid, netSat: sats - feeSat }; - wallet.scan().catch(() => {}); - } catch (e) { - ui.arkError = e.message; - } - ui.arkBusy = null; render(); -} -async function doArkRefresh() { - ui.arkBusy = 'refresh'; ui.arkError = ''; render(); - try { - await ark.refresh(); - toast(t('arkRefreshStarted'), 5000); - } catch (e) { - ui.arkError = e.message; - } - ui.arkBusy = null; render(); -} // Network selector. Per-network endpoints (Electrum server / block explorer) are // configured in the Data source card below — no duplicate URL fields here. @@ -2727,8 +2526,7 @@ function balanceCard() { const firstLoad = wallet.scanning && !wallet.loaded; const locked = wallet.lockedValue; const pending = wallet.pendingIncoming; - const b = arkBalance(); - const arkBal = b && (b.spendableSat + b.pendingSat) > 0 ? b : null; + const featLines = featureAll('balanceLines'); return h( 'div', { class: 'card balance' }, @@ -2737,7 +2535,7 @@ function balanceCard() { // change, minus gift locks — so a pending spend debits immediately, while a // pending incoming receive stays out of it until it confirms. h('div', { class: 'amt', style: firstLoad ? 'opacity:.3' : '' }, fmtAmount(wallet.spendable), ' ', unitTag('unit')), - pending > 0 || locked > 0 || arkBal + pending > 0 || locked > 0 || featLines.length ? h( 'div', { class: 'split' }, @@ -2747,30 +2545,14 @@ function balanceCard() { locked > 0 ? h('div', {}, h('div', { class: 'k' }, t('lockedInGifts')), h('div', { class: 'v' }, fmtAmount(locked), ' ', unitTag())) : null, - arkBal - ? h('div', {}, h('div', { class: 'k' }, t('arkBalance')), h('div', { class: 'v' }, fmtAmount(arkBal.spendableSat + arkBal.pendingSat), ' ', unitTag())) - : null + ...featLines.map((l) => + h('div', {}, h('div', { class: 'k' }, l.label), h('div', { class: 'v' }, fmtAmount(l.sat), ' ', unitTag()))) ) : null ); } -async function doReverse() { - const amt = parseInt((ui.swapReverseAmt || '').trim(), 10); - const lim = ui.swapLimits || (ui.swapLimits = await swaps.reverseLimits()); - if (!amt || amt < lim.min || amt > lim.max) { ui.swapError = `Enter ${lim.min.toLocaleString()}–${lim.max.toLocaleString()} sats`; return render(); } - ui.swapError = ''; ui.swapBusy = true; render(); - try { const rec = await swaps.startReverse(amt); ui.lnReceiveId = rec.id; ui.swapReverseAmt = ''; } - 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 tabsBar() { const tabs = [ @@ -2841,43 +2623,9 @@ function receiveTab() { ); } - // A board just broadcast — show its success screen until dismissed. - if (ui.arkBoarded) { - const b = ui.arkBoarded; - const url = wallet.api.explorerTx(b.txid); - return h( - 'div', - { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;padding:40px 20px' }, - h('div', { class: 'check-badge' }, '✓'), - h('h2', { style: 'margin:0' }, t('arkBoardedTitle')), - h('div', { class: 'amount-pos', style: 'font-size:18px' }, '+' + fmtAmount(b.netSat) + ' ' + unitLabel()), - h('p', { class: 'small muted', style: 'margin:0' }, t('arkBoardedNote')), - h('div', { class: 'addr-box', style: 'width:100%' }, b.txid), - h('div', { class: 'row gap6' }, - copyBtn(b.txid, t('copyTxid')), - h('a', { class: 'btn btn-sm', href: url, target: '_blank', rel: 'noopener', onClick: (e) => { e.preventDefault(); openExternal(url); } }, t('viewOnMempool'))), - h('button', { class: 'btn-primary btn-block', onClick: () => { ui.arkBoarded = null; render(); } }, t('done')) - ); - } - - // An Ark payment landed — same celebration as an on-chain receive, dismissed - // with a tap (the ack persists in the ark state so it shows exactly once). - const arkUnseen = ark && ark.state ? ark.unseenReceives() : []; - if (arkUnseen.length) { - const amt = arkUnseen.reduce((n, m) => n + m.amountSat, 0); - return h( - 'div', - { - class: 'card col', - style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px', - onClick: () => { ark.ackReceives(); render(); }, - }, - h('div', { class: 'check-badge' }, '✓'), - h('h2', { style: 'margin:0' }, t('paymentReceived')), - amt ? h('div', { class: 'amount-pos', style: 'font-size:18px' }, '+' + fmtAmount(amt) + ' ' + unitLabel()) : null, - h('div', { class: 'small muted' }, t('tapToProceed')) - ); - } + // Feature takeovers: boarding success, Ark receive celebration, etc. + const takeover = featureHook('receiveTakeover'); + if (takeover) return takeover; // A payment landed on the shown address (the fresh index advanced past what // the user last saw) — celebrate, and wait for a tap before showing the next. @@ -2908,146 +2656,31 @@ function receiveTab() { ); } - // One card with a toggle: on-chain address, Lightning (a Boltz reverse swap), - // and the silent-payment address — each shown only when available. + // One card with a toggle: the on-chain address plus whatever receive modes + // the enabled features contribute (Lightning, silent payment, Ark, ...). const fresh = wallet.freshReceive(); - const spAddr = wallet.silentPaymentsAvailable() ? wallet.silentPaymentAddress() : null; - const canLn = !wallet.watchOnly; // a reverse swap needs our keys to claim on-chain + const featModes = featureAll('receiveModes'); let mode = ui.receiveType || 'address'; - if (mode === 'sp' && !spAddr) mode = 'address'; - if (mode === 'ln' && !canLn) mode = 'address'; - if (mode === 'ark' && !arkAvailable()) mode = 'address'; - // A compact dropdown (not a 3-button segmented control) so it fits on mobile and - // scales if more address types are added. - const opts = [['address', t('receiveAddressTab')]]; - if (canLn) opts.push(['ln', t('receiveLnTab')]); - if (spAddr) opts.push(['sp', t('receiveSpTab')]); - if (arkAvailable()) opts.push(['ark', t('receiveArkTab')]); + if (mode !== 'address' && !featModes.some((m) => m.id === mode)) mode = 'address'; + const opts = [['address', t('receiveAddressTab')], ...featModes.map((m) => [m.id, m.label])]; const seg = opts.length > 1 ? h('select', { style: 'width:100%', onChange: (e) => { ui.receiveType = e.target.value; render(); } }, opts.map(([id, label]) => h('option', { value: id, selected: mode === id }, label))) : null; - if (mode === 'ln') { - return h('div', { class: 'card col', style: 'align-items:center;gap:14px' }, seg, ...lnReceiveContent()); - } - if (mode === 'ark') { - if (!ark) { - connectArk().catch(() => {}); - return h( - 'div', - { class: 'card col', style: 'align-items:center;gap:14px' }, - seg, - ui.arkError - ? h('div', { class: 'notice err', style: 'width:100%' }, ui.arkError) - : h('div', { class: 'row gap6', style: 'align-items:center;padding:24px 0' }, - h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkConnecting'))) - ); - } - const arkAddr = ark.address(); - const minBoard = ark.info.minBoardAmountSat || 0; - const canBoard = wallet.spendable >= minBoard; - return h( - 'div', - { class: 'card col', style: 'align-items:center;gap:14px' }, - seg, - h('p', { class: 'small muted', style: 'margin:0;text-align:center' }, t('arkReceiveIntro')), - h('div', { html: qrSvg(arkAddr) }), - h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, arkAddr), - copyBtn(arkAddr, t('copyAddress')), - // Board: fund your Ark balance from this wallet's on-chain coins. - h('div', { class: 'col', style: 'width:100%;gap:8px;border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:14px' }, - h('div', { class: 'small muted', style: 'text-align:center' }, t('arkBoardIntro')), - h('div', { class: 'input-group' }, - h('input', { - type: 'number', inputmode: 'numeric', min: '0', - placeholder: t('arkBoardPlaceholder', { n: minBoard.toLocaleString() }), - value: ui.arkBoardAmt || '', - onInput: (e) => { ui.arkBoardAmt = e.target.value; }, - }), - h('button', { class: 'btn-sm', disabled: !!ui.arkBusy || !canBoard, onClick: doArkBoard }, - ui.arkBusy === 'board' ? h('span', { class: 'spinner sm' }) : t('arkBoardBtn'))), - canBoard - ? h('div', { class: 'small faint', style: 'text-align:center' }, t('arkBoardAvailable', { n: fmtAmount(wallet.spendable) + ' ' + unitLabel() })) - : h('div', { class: 'small faint', style: 'text-align:center' }, t('arkBoardNoFunds', { n: minBoard.toLocaleString() })), - ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null) - ); - } - const addr = mode === 'sp' ? spAddr : fresh.address; + const fm = featModes.find((m) => m.id === mode); + if (fm) return fm.render(seg); + const addr = fresh.address; return h( 'div', { class: 'card col', style: 'align-items:center;gap:14px' }, seg, h('div', { html: qrSvg(addr) }), - h('div', { class: 'addr-box' + (mode === 'sp' ? ' break' : ''), style: 'width:100%' + (mode === 'sp' ? ';font-size:12px' : '') }, addr), + h('div', { class: 'addr-box', style: 'width:100%' }, addr), copyBtn(addr, t('copyAddress')) ); } -// Lightning receive = a Boltz reverse swap: show a bolt11 the user shares; when -// it's paid, Boltz locks up on-chain and the SwapManager claims it into this -// wallet automatically. Returns the card's inner children. -function lnReceiveContent() { - if (!ui.swapLimits && !ui._swapLimitsLoading) { - ui._swapLimitsLoading = true; - swaps.reverseLimits().then((l) => { ui.swapLimits = l; ui._swapLimitsLoading = false; render(); }).catch(() => { ui._swapLimitsLoading = false; }); - } - const lim = ui.swapLimits; - let rec = ui.lnReceiveId ? swaps.list().find((s) => s.id === ui.lnReceiveId) : null; - if (rec && ['claimed', 'success'].includes(rec.status)) { - // Claimed — the coin is credited on-chain (swap.js → creditReceive), so the - // generic "Payment received!" celebration shows it like any receive. Just - // reset the Lightning flow back to its form. - ui.lnReceiveId = null; - rec = null; - } - if (rec) { - let svg = null; - try { svg = qrSvg(rec.invoice.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {} - return [ - h('p', { class: 'small muted', style: 'margin:0;text-align:center' }, t('lnReceiveAwaiting')), - svg ? h('div', { html: svg }) : null, - h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, rec.invoice), - copyBtn(rec.invoice, t('copyInvoice')), - h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('lnReceiveWatching'))), - h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.lnReceiveId = null; ui.swapReverseAmt = ''; ui.swapError = ''; render(); } }, t('back')), - ]; - } - return [ - h('p', { class: 'small muted', style: 'margin:0;text-align:center' }, t('lnReceiveIntro')), - h('label', { class: 'field', style: 'width:100%' }, - h('span', { class: 'lab' }, t('amountSatsLabel')), - h('input', { type: 'number', inputmode: 'numeric', placeholder: '', value: ui.swapReverseAmt || '', onInput: (e) => { ui.swapReverseAmt = e.target.value; } }), - lim ? h('span', { class: 'small muted' }, `Min ${lim.min.toLocaleString()} · max ${lim.max.toLocaleString()} sats`) : null), - ui.swapError ? h('div', { class: 'notice err' }, ui.swapError) : null, - h('button', { class: 'btn-primary btn-block', disabled: !!ui.swapBusy, onClick: doReverse }, ui.swapBusy ? h('span', { class: 'spinner' }) : t('lnCreateInvoice')), - ]; -} -// Silent-payment tweak indexer picker (per network) — the endpoint used to scan -// for received silent payments. Only shown for wallets that can derive SP keys. -function spIndexerCard() { - if (!wallet.silentPaymentKeys || !wallet.silentPaymentKeys()) return null; - const net = getNetwork(); - const cfg = getSpIndexerConfig(net); - return h( - 'div', - { class: 'card col' }, - h('h3', {}, t('spIndexerTitle')), - h('p', { class: 'small muted', style: 'margin:0' }, t('spIndexerDesc')), - h('select', { onChange: (e) => { setSpIndexerConfig({ server: e.target.value, url: cfg.url }, net); render(); } }, - spIndexerPresets(net).map((o) => h('option', { value: o.id, selected: o.id === cfg.server }, o.id === 'custom' ? o.label : o.url.replace(/^\w+:\/\//, '')))), - cfg.server === 'custom' - ? h('label', { class: 'field' }, - h('span', { class: 'lab' }, t('spIndexerUrl')), - h('input', { - type: 'text', class: 'mono-input', placeholder: 'https://your-indexer:8888', - autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: cfg.url, - onChange: (e) => { setSpIndexerConfig({ server: 'custom', url: e.target.value.trim() }, net); render(); }, - }), - h('div', { class: 'small faint' }, t('spIndexerHint'))) - : null - ); -} // ---------------------------------------------------------------- Send // Form to enter a recovery phrase / xprv and make a watch-only wallet spendable. @@ -3099,9 +2732,8 @@ function sendTab() { } if (ui.sendResult) return sendResultView(); if (ui.broadcastTx) return broadcastConfirmView(); - if (ui.arkSent || ui.arkSend) return arkSendReview(); - if (ui.lnSent || ui.lnSend) return lnSendReview(); - if (ui.lnSendBusy) return h('div', { class: 'card col', style: 'align-items:center;gap:14px;padding:32px 14px' }, h('span', { class: 'spinner' }), h('p', { class: 'muted', style: 'margin:0' }, t('lnQuoting'))); + const featView = featureHook('sendView'); + if (featView) return featView; if (ui.draft) return reviewView(); if (ui.giftMode) return giftView(); return sendForm(); @@ -3142,8 +2774,7 @@ function handleScanned(raw) { ui.sendError = ''; // A scanned bolt11 (optionally lightning:-prefixed) → pay it over Lightning. - const lnInv = text.replace(/^lightning:/i, ''); - if (isLnInvoice(lnInv)) { startLnSend(lnInv); return; } + if (featureMatchSend(text)) return; if (/^bitcoin:/i.test(text)) { const { address, amount } = parseBip21(text); @@ -3361,7 +2992,7 @@ function addrVerifyNodes(a) { // auto-advances to its own confirmation, so it never needs them. function destReady(a) { a = (a || '').trim(); - return !!a && (wallet.isOnchainAddress(a) || isSilentPaymentAddress(a) || (isArkAddress(a) && arkAvailable())); + return !!a && (wallet.isOnchainAddress(a) || FEATURES.some((f) => f.isSendDest && f.isSendDest(a))); } // One recipient: address + amount. Max is only offered for a single recipient. @@ -3380,20 +3011,16 @@ function recipientRow(s, r, i) { check.style.display = a ? '' : 'none'; }; - // A pasted/typed/scanned bolt11 → jump straight to the Lightning confirmation - // (a submarine swap), so its baked-in amount is used and the amount field skipped. - const tryLn = (v) => { - const inv = (v || '').trim().replace(/^lightning:/i, ''); - if (s.recipients.length === 1 && !ui.lnSendBusy && !ui.lnSend && isLnInvoice(inv)) { startLnSend(inv); return true; } - return false; - }; + // A pasted/typed/scanned payload a feature recognizes (e.g. a bolt11) jumps + // straight to that feature's own confirmation flow. + const tryFeature = (v) => featureMatchSend(v); const addrInput = h('input', { type: 'text', class: 'mono-input grow', placeholder: i === 0 ? t('destPlaceholder') : 'bc1q…', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: r.address, onInput: (e) => { const v = e.target.value; r.address = v; syncCheck(); - if (tryLn(v)) return; // a bolt11 advances to its own confirmation + if (tryFeature(v)) return; // a bolt11 advances to its own confirmation if (destReady(v) !== r._ready) render(); // reveal/hide amount + controls as validity flips }, }); @@ -3402,7 +3029,7 @@ function recipientRow(s, r, i) { { class: 'col gap6' }, h('div', { class: 'input-group' }, addrInput, - pasteBtn((text) => { addrInput.value = text; r.address = text; syncCheck(); if (!tryLn(text) && destReady(text) !== r._ready) render(); }), + pasteBtn((text) => { addrInput.value = text; r.address = text; syncCheck(); if (!tryFeature(text) && destReady(text) !== r._ready) render(); }), i === 0 && canScan() && h('button', { type: 'button', class: 'btn-sm', title: t('scanQr'), onClick: scanIntoSend, html: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3"/></svg>', @@ -3432,7 +3059,7 @@ function recipientRow(s, r, i) { }, }), h('button', { type: 'button', title: t('switchUnit'), onClick: toggleUnit }, unitLabel()), - single && !isArkAddress(r.address) && h('button', { type: 'button', class: s.max ? 'btn-primary' : '', onClick: () => { s.max = !s.max; render(); } }, t('max')) + single && !featureHook('hideSendControls', r.address) && h('button', { type: 'button', class: s.max ? 'btn-primary' : '', onClick: () => { s.max = !s.max; render(); } }, t('max')) ) : null ); syncCheck(); @@ -3444,7 +3071,8 @@ function sendForm() { // Progressive disclosure: until the destination is a valid on-chain/SP address, // show only the destination input (a Lightning invoice auto-advances instead). const ready = destReady((s.recipients[0] || {}).address); - const arkDest = s.recipients.length === 1 && isArkAddress((s.recipients[0] || {}).address); + const plainAddr = (s.recipients[0] || {}).address; + const arkDest = s.recipients.length === 1 && !!featureHook('hideSendControls', plainAddr); const feeOpts = [ ['economyFee', t('feeEconomy')], ['halfHourFee', t('feeNormal')], @@ -3467,7 +3095,7 @@ function sendForm() { onClick: () => { s.recipients.push({ address: '', amount: '' }); s.max = false; render(); }, }, t('addRecipient')) ), - ready && arkDest && h('div', { class: 'small faint' }, t('arkSendHint')), + ready && arkDest && (featureHook('sendFormNote', plainAddr) || null), ready && !arkDest && h( 'div', { class: 'field' }, @@ -3580,17 +3208,10 @@ function reviewSend() { ui.sendError = ''; try { const s = ui.send; - // A bolt11 invoice in the recipient field → pay it over Lightning via a Boltz - // submarine swap, shown with an itemized cost. To the user it's just a payment. - const lnTarget = s.recipients.length === 1 ? (s.recipients[0].address || '').trim().replace(/^lightning:/i, '') : ''; - if (isLnInvoice(lnTarget)) { startLnSend(lnTarget); return; } - // An Ark address → instant off-chain send, no mining fee; its own confirmation. - if (s.recipients.length === 1 && isArkAddress(s.recipients[0].address)) { - if (!arkAvailable()) throw new Error(t('arkNotConnected')); - const sats = parseAmount(s.recipients[0].amount, unit); - if (!sats || sats <= 0) throw new Error(t('enterValidAmtForN', { n: 1 })); - ui.arkSend = { address: s.recipients[0].address.trim(), amountSat: sats }; - return render(); + // Feature destinations (bolt11 → Lightning swap, ark1 → Ark send, ...) + // divert to their own confirmation flows. + for (const f of FEATURES) { + if (f.interceptReview && f.interceptReview(s)) return; } const feeRate = currentFeeRate(); let coinIds = null; @@ -3621,103 +3242,11 @@ function reviewSend() { render(); } -// Ark send confirmation: amount + destination, zero fees, instant. After the -// send, a simple success view (there is no on-chain txid to link). -function arkSendReview() { - if (ui.arkSent) { - return h( - 'div', - { - class: 'card col', - style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px', - onClick: () => { ui.arkSent = null; ui.send = blankSend(); render(); }, - }, - h('div', { class: 'check-badge' }, '✓'), - h('h2', { style: 'margin:0' }, t('arkSentTitle')), - h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkSent.amountSat) + ' ' + unitLabel()), - h('div', { class: 'small muted' }, t('tapToProceed')) - ); - } - const a = ui.arkSend; - const row = (k, v) => h('div', { class: 'row between' }, h('span', { class: 'small muted' }, k), h('span', { class: 'small' }, v)); - return h( - 'div', - { class: 'card col', style: 'gap:12px' }, - h('h3', {}, t('arkSendTitle')), - row(t('lnPayAmount'), fmtAmount(a.amountSat) + ' ' + unitLabel()), - row(t('arkPayTo'), shortAddr(a.address, 14)), - row(t('networkFee'), t('arkNoFee')), - ui.sendError ? h('div', { class: 'notice err' }, ui.sendError) : null, - h('div', { class: 'row gap6' }, - h('button', { class: 'btn-ghost', onClick: () => { ui.arkSend = null; ui.sendError = ''; render(); } }, t('back')), - ui.busy - ? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' })) - : h('button', { class: 'btn-primary grow', onClick: doArkSend }, t('arkSendBtn')) - ) - ); -} -async function doArkSend() { - const a = ui.arkSend; - ui.busy = true; ui.sendError = ''; render(); - try { - const mgr = await connectArk(); - await mgr.send(a.address, a.amountSat); - ui.arkSent = { amountSat: a.amountSat }; - ui.arkSend = null; - } catch (e) { - ui.sendError = e.message; - } - ui.busy = false; render(); -} -// A bolt11 Lightning invoice (any network prefix); long ln-prefixed bech32 string. -function isLnInvoice(t) { t = (t || '').trim(); return /^ln[a-z0-9]+$/i.test(t) && t.length > 50; } -// Quote a submarine swap for a pasted invoice, then show the itemized cost. -async function startLnSend(invoice) { - ui.sendError = ''; ui.lnSend = null; ui.lnSent = null; ui.lnSendBusy = true; render(); - try { ui.lnSend = await swaps.quoteSubmarine(invoice); } - catch (e) { ui.sendError = e.message; } - ui.lnSendBusy = false; render(); -} -async function doLnPay() { - if (!ui.lnSend) return; - ui.sendError = ''; ui.lnSendBusy = true; render(); - try { const rec = await swaps.fundQuotedSubmarine(ui.lnSend); ui.lnSent = { amount: ui.lnSend.invoiceAmount, id: rec.id }; } - catch (e) { ui.sendError = e.message; } - ui.lnSendBusy = false; render(); -} -// Lightning send review: itemized cost (amount + Boltz fee + network fee → total), -// then a success card once funded. The provider pays the invoice after it confirms. -function lnSendReview() { - const u = ' ' + unitLabel(); - if (ui.lnSent) { - return h('div', { class: 'col', style: 'gap:16px' }, - h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:8px' }, - h('div', { class: 'check-badge' }, '⚡'), - h('h2', { style: 'margin:0' }, t('lnPaySentTitle')), - ui.lnSent.amount ? h('div', { class: 'amount-pos', style: 'font-size:18px' }, fmtAmount(ui.lnSent.amount) + u) : null, - h('p', { class: 'muted', style: 'margin:0' }, t('lnPaySentBody'))), - h('button', { class: 'btn-primary btn-block', onClick: () => { ui.lnSent = null; ui.lnSend = null; ui.send = blankSend(); ui.tab = 'history'; render(); } }, t('done'))); - } - const q = ui.lnSend; - const row = (label, sats, bold) => h('div', { class: 'row', style: 'justify-content:space-between' + (bold ? ';font-weight:600' : '') }, - h('span', { class: bold ? '' : 'small muted' }, label), h('span', {}, fmtAmount(sats) + u)); - return h('div', { class: 'col', style: 'gap:12px' }, - h('div', { class: 'card col', style: 'gap:10px' }, - h('h3', { style: 'margin:0' }, t('lnPayTitle')), - q.invoiceAmount != null ? row(t('lnPayAmount'), q.invoiceAmount) : null, - q.boltzFee != null ? row(t('lnPayBoltzFee'), q.boltzFee) : null, - row(t('lnPayNetworkFee'), q.networkFee), - h('div', { style: 'border-top:1px solid var(--line, #ddd);margin:2px 0' }), - row(t('lnPayTotal'), q.total, true)), - ui.sendError ? h('div', { class: 'notice err' }, ui.sendError) : null, - h('button', { class: 'btn-primary btn-block', disabled: !!ui.lnSendBusy, onClick: doLnPay }, ui.lnSendBusy ? h('span', { class: 'spinner' }) : t('lnPayConfirm')), - h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.lnSend = null; ui.sendError = ''; render(); } }, t('back'))); -} function reviewView() { const d = ui.draft; @@ -3900,59 +3429,7 @@ function giftDetailView(g) { ); } -// Detail view for an Ark movement. Off-chain payments have no on-chain txid; -// we show what exists: amount, time, status, destination (sends), the vtxo id, -// and — for boards — the funding txid with an explorer link. -function arkMoveDetailView(m) { - const incoming = m.type !== 'send'; - const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') : t('sent'); - const row = (k, v) => h('div', { class: 'row between', style: 'gap:12px' }, - h('span', { class: 'small muted', style: 'flex-shrink:0' }, k), h('span', { class: 'small', style: 'text-align:right;word-break:break-all' }, v)); - const url = m.txid ? wallet.api.explorerTx(m.txid) : null; - return h( - 'div', - { class: 'card col', style: 'gap:10px' }, - h('div', { class: 'row gap6', style: 'align-items:center' }, - h('h3', { style: 'margin:0' }, label), - h('span', { class: 'tag' }, 'Ark'), - m.status !== 'complete' ? h('span', { class: 'tag pending' }, m.status) : null), - h('div', { class: incoming ? 'amount-pos' : 'amount-neg', style: 'font-size:20px' }, - (incoming ? '+' : '-') + fmtAmount(m.amountSat) + ' ' + unitLabel()), - row(t('dateLabel'), new Date(m.ts).toLocaleString()), - m.to ? row(t('arkPayTo'), shortAddr(m.to, 16, 12)) : null, - m.vtxoId ? row(t('arkVtxoId'), shortTxid(m.vtxoId)) : null, - m.detail ? row(t('detailsLabel'), m.detail) : null, - m.txid - ? h('div', { class: 'col', style: 'gap:6px' }, - h('div', { class: 'small muted' }, t('transactionId')), - h('div', { class: 'addr-box', style: 'width:100%' }, m.txid), - h('div', { class: 'row gap6' }, - copyBtn(m.txid, t('copyTxid')), - h('a', { class: 'btn btn-sm', href: url, target: '_blank', rel: 'noopener', onClick: (e) => { e.preventDefault(); openExternal(url); } }, t('viewOnMempool')))) - : null, - m.to ? copyBtn(m.to, t('copyAddress')) : null, - h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.arkMoveDetail = null; render(); } }, t('back')) - ); -} -// An Ark movement (receive / send / board) as a history row. -function arkHistoryItem(m) { - const incoming = m.type !== 'send'; - const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') : t('sent'); - return h( - 'div', - { class: 'item', style: 'cursor:pointer', onClick: () => { ui.arkMoveDetail = m.id; render(); } }, - h('div', { class: `ico ${incoming ? 'in' : 'out'}` }, incoming ? '↓' : '↑'), - h('div', { class: 'grow' }, - h('div', { class: 'row gap6' }, - label, - h('span', { class: 'tag' }, 'Ark'), - m.status !== 'complete' ? h('span', { class: 'tag pending' }, m.status) : null), - h('div', { class: 'small faint' }, timeAgo(m.ts / 1000))), - h('div', { style: 'text-align:right' }, - h('div', { class: incoming ? 'amount-pos' : 'amount-neg' }, (incoming ? '+' : '-') + fmtAmount(m.amountSat))) - ); -} function txHistoryItem(tx) { const incoming = tx.net >= 0; @@ -3995,11 +3472,8 @@ function pager(page, total, onPage) { function historyTab() { if (ui.bump) return bumpView(); const txs = wallet.history; // BIP84 txs + silent-payment receipts, newest first - if (ui.arkMoveDetail) { - const m = ark && ark.state ? ark.movements().find((x) => x.id === ui.arkMoveDetail) : null; - if (m) return arkMoveDetailView(m); - ui.arkMoveDetail = null; - } + const featDetail = featureHook('historyDetail'); + if (featDetail) return featDetail; if (ui.giftDetail) { const g = wallet.loaded ? [...wallet.outstandingGifts(), ...wallet.claimedGifts().map((c) => ({ ...c, claimed: true }))] @@ -4041,12 +3515,10 @@ function historyTab() { const gifts = wallet.loaded ? [...wallet.outstandingGifts(), ...claimed.filter((c) => !(c.claimTxid && txids.has(c.claimTxid))).map((c) => ({ ...c, claimed: true }))] : []; - // Ark receives/sends/boards interleave with on-chain txs by time. (Refreshes - // are internal churn — they stay in the Settings activity list only.) - const arkMoves = ark && ark.state - ? ark.movements().filter((m) => ['receive', 'send', 'board'].includes(m.type) && m.status === 'complete') - : []; - if (!txs.length && !gifts.length && !arkMoves.length) + // Feature movements (Ark receives/sends/boards, ...) interleave with + // on-chain txs by time. + const featEntries = featureAll('historyEntries', txs); + if (!txs.length && !gifts.length && !featEntries.length) return h('div', { class: 'card' }, h('p', { class: 'muted center', style: 'margin:0' }, t('noTxYet'))); // Gifts, transactions, and Ark movements merge into one timeline (claimed @@ -4055,7 +3527,7 @@ function historyTab() { const giftTime = (g) => (recs[g.id] && recs[g.id].created) || g.created || Date.now(); const entries = [ ...txs.map((tx) => ({ time: tx.confirmed ? (tx.blockTime || 0) * 1000 : Date.now(), render: () => txHistoryItem(tx) })), - ...arkMoves.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m) })), + ...featEntries, ...gifts.map((g) => ({ time: giftTime(g), render: () => giftHistoryItem(g) })), ].sort((a, b) => b.time - a.time); const txPages = Math.ceil(entries.length / PAGE_SIZE); @@ -4103,37 +3575,9 @@ async function openTx(txid) { } catch {} } -// Lightning-swap context shown inside a tx's detail screen (the on-chain tx is the -// swap's lockup funding, claim, or refund). Replaces the old standalone Swap list. -function swapTxDetailSection(swap, tx) { - const u = ' ' + unitLabel(); - const line = (k, v) => h('div', { class: 'line' }, h('span', { class: 'k' }, k), h('span', { class: 'v' }, v)); - const head = (txt) => h('div', { style: 'font-weight:600;margin:12px 0 2px' }, txt); - if (swap.refundTxid === tx.txid) - return h('div', { class: 'summary col', style: 'gap:0' }, head('↩ Lightning swap refund'), line(t('status'), 'Refunded ✓')); - if (swap.kind === 'submarine') { - const fee = (swap.expectedAmount || 0) - (swap.invoiceAmount || 0); - const st = swap.status === 'success' ? 'Invoice paid ✓' - : swap.status === 'refundable' ? 'Payment failed — refundable' - : swap.status === 'refunded' ? 'Refunded' : 'Paying…'; - return h('div', { class: 'summary col', style: 'gap:0' }, - head('⚡ Paid over Lightning'), - swap.invoiceAmount != null ? line('Invoice', fmtAmount(swap.invoiceAmount) + u) : null, - fee > 0 ? line('Swap fee', fmtAmount(fee) + u) : null, - line(t('status'), st), - swap.status === 'refundable' - ? h('button', { class: 'btn-sm btn-block', style: 'margin-top:8px', disabled: !!ui.swapBusy, onClick: () => doRefund(swap.id) }, 'Refund on-chain') - : null); - } - // reverse swap claim - return h('div', { class: 'summary col', style: 'gap:0' }, - head('⚡ Received over Lightning'), - line(t('status'), swap.status === 'claimed' ? 'Settled ✓' : swap.status)); -} function txDetailView(tx) { const incoming = tx.net >= 0; - const swap = swaps.findByTxid(tx.txid); // this tx's swap (Lightning), if any const gift = wallet.loaded ? wallet.claimedGifts().find((c) => c.claimTxid === tx.txid) : null; const line = (k, v) => h('div', { class: 'line' }, h('span', { class: 'k' }, k), h('span', { class: 'v' }, v)); return h( @@ -4153,7 +3597,7 @@ function txDetailView(tx) { tx.confirmed && tx.blockTime ? line(t('date'), new Date(tx.blockTime * 1000).toLocaleString()) : null, tx.fee ? line(t('networkFee'), fmtAmount(tx.fee) + ' ' + unitLabel()) : null ), - swap ? swapTxDetailSection(swap, tx) : null, + featureHook('txDetailSection', tx), gift ? h('div', { class: 'summary col', style: 'gap:0' }, h('div', { style: 'font-weight:600;margin:12px 0 2px' }, '🎁 ' + t('giftClaimedContext')), @@ -4223,6 +3667,16 @@ async function importSnapshotFile(e) { // ================================================================ start // Load the active language's strings (English is inline; others are fetched), +// ---- feature registry ------------------------------------------------- +// Optional features receive the app context and plug into fixed seams. This +// sits just before boot so every helper it captures is defined; the hooks are +// only invoked at runtime (first render happens after loadLocale below). +const ctx = { + h, ui, render, wallet, toast, copyBtn, pasteBtn, blankSend, goBack, openExternal, + fmtAmount, unitLabel, unitTag, parseAmount, getUnit: () => unit, +}; +const FEATURES = [swapsFeature, arkFeature, spFeature].map((f) => f(ctx)); + // apply text direction, then restore a wallet left open in this tab — otherwise // show the unlock screen. applyDir(); diff --git a/src/features/ark.js b/src/features/ark.js new file mode 100644 index 0000000..24acfea --- /dev/null +++ b/src/features/ark.js @@ -0,0 +1,429 @@ +// Ark feature — off-chain payments via an ASP (Second's bark/captaind +// protocol, spoken natively by ../ark). Receive address + boarding, instant +// sends, history entries, refresh/consolidation, server settings. + +import { ArkManager } from '../ark/manager.js'; +import { getNetwork, arkPresets, getArkProviderId, setArkProviderId, getArkCustom, setArkCustom, getArkConfig } from '../api.js'; +import { t } from '../i18n.js'; +import { qrSvg } from '../qr.js'; +import { shortAddr, shortTxid, timeAgo } from '../format.js'; + +// t?ark1… bech32m — an Ark address for this or another ASP. +export function isArkAddress(a) { return /^t?ark1[a-z0-9]{20,}$/i.test((a || '').trim()); } + +export function arkFeature(ctx) { + const { h, ui, render, wallet, blankSend, fmtAmount, unitLabel, unitTag, copyBtn, toast, openExternal } = ctx; + + // One manager per open wallet; null when Ark is off in Settings, watch-only, + // or not yet dialed (lazy: fresh wallets connect on first use). + let ark = null; + let arkTimer = null; + let arkConnectPromise = null; + let arkInitGen = 0; // guards against a stale init() resolving after a wallet switch + + function stopArk() { + arkInitGen++; + if (arkTimer) clearInterval(arkTimer); + arkTimer = null; + if (ark) ark.stopMailboxStream(); + ark = null; + arkConnectPromise = null; + } + + function arkAvailable() { + return !!getArkConfig() && !wallet.watchOnly && !!wallet.account; + } + + function arkWanted() { + const st = wallet.loadArkState(); + return !!(st && ((st.vtxos || []).length || (st.movements || []).length + || (st.actions || []).some((a) => !['done', 'failed'].includes(a.step)))); + } + + function initArk() { + stopArk(); + ui.arkError = ''; + if (arkAvailable() && arkWanted()) connectArk().catch(() => {}); + } + + function connectArk() { + if (ark) return Promise.resolve(ark); + if (arkConnectPromise) return arkConnectPromise; + if (!arkAvailable()) return Promise.reject(new Error(t('arkNotConnected'))); + const cfg = getArkConfig(); + const gen = arkInitGen; + const mgr = new ArkManager({ + account: wallet.account(), + storage: { load: () => wallet.loadArkState(), save: (s) => wallet.saveArkState(s) }, + arkUrl: cfg.ark, + esploraUrl: cfg.esplora, + network: getNetwork(), + onUpdate: () => render(), + }); + ui.arkError = ''; + arkConnectPromise = mgr.init().then(() => { + if (gen !== arkInitGen) throw new Error('superseded'); // wallet switched mid-connect + ark = mgr; + const tick = () => mgr.sync().catch(() => {}); + tick(); + // Receives arrive in real time over the mailbox stream; the poll is the + // fallback and what drives in-flight boards/refreshes forward. + mgr.startMailboxStream(); + arkTimer = setInterval(() => { if (ark === mgr) tick(); }, getNetwork() === 'regtest' ? 5000 : 30000); + render(); + return mgr; + }).catch((e) => { + if (gen === arkInitGen) { ui.arkError = e.message; render(); } + throw e; + }).finally(() => { arkConnectPromise = null; }); + render(); + return arkConnectPromise; + } + + function arkBalance() { + if (!ark || !ark.state) return null; + return ark.balance(); + } + + function arkSendReview() { + if (ui.arkSent) { + return h( + 'div', + { + class: 'card col', + style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px', + onClick: () => { ui.arkSent = null; ui.send = blankSend(); render(); }, + }, + h('div', { class: 'check-badge' }, '✓'), + h('h2', { style: 'margin:0' }, t('arkSentTitle')), + h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkSent.amountSat) + ' ' + unitLabel()), + h('div', { class: 'small muted' }, t('tapToProceed')) + ); + } + const a = ui.arkSend; + const row = (k, v) => h('div', { class: 'row between' }, h('span', { class: 'small muted' }, k), h('span', { class: 'small' }, v)); + return h( + 'div', + { class: 'card col', style: 'gap:12px' }, + h('h3', {}, t('arkSendTitle')), + row(t('lnPayAmount'), fmtAmount(a.amountSat) + ' ' + unitLabel()), + row(t('arkPayTo'), shortAddr(a.address, 14)), + row(t('networkFee'), t('arkNoFee')), + ui.sendError ? h('div', { class: 'notice err' }, ui.sendError) : null, + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost', onClick: () => { ui.arkSend = null; ui.sendError = ''; render(); } }, t('back')), + ui.busy + ? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' })) + : h('button', { class: 'btn-primary grow', onClick: doArkSend }, t('arkSendBtn')) + ) + ); + } + + async function doArkSend() { + const a = ui.arkSend; + ui.busy = true; ui.sendError = ''; render(); + try { + const mgr = await connectArk(); + await mgr.send(a.address, a.amountSat); + ui.arkSent = { amountSat: a.amountSat }; + ui.arkSend = null; + } catch (e) { + ui.sendError = e.message; + } + ui.busy = false; render(); + } + + function arkHistoryItem(m) { + const incoming = m.type !== 'send'; + const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') : t('sent'); + return h( + 'div', + { class: 'item', style: 'cursor:pointer', onClick: () => { ui.arkMoveDetail = m.id; render(); } }, + h('div', { class: `ico ${incoming ? 'in' : 'out'}` }, incoming ? '↓' : '↑'), + h('div', { class: 'grow' }, + h('div', { class: 'row gap6' }, + label, + h('span', { class: 'tag' }, 'Ark'), + m.status !== 'complete' ? h('span', { class: 'tag pending' }, m.status) : null), + h('div', { class: 'small faint' }, timeAgo(m.ts / 1000))), + h('div', { style: 'text-align:right' }, + h('div', { class: incoming ? 'amount-pos' : 'amount-neg' }, (incoming ? '+' : '-') + fmtAmount(m.amountSat))) + ); + } + + function arkMoveDetailView(m) { + const incoming = m.type !== 'send'; + const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') : t('sent'); + const row = (k, v) => h('div', { class: 'row between', style: 'gap:12px' }, + h('span', { class: 'small muted', style: 'flex-shrink:0' }, k), h('span', { class: 'small', style: 'text-align:right;word-break:break-all' }, v)); + const url = m.txid ? wallet.api.explorerTx(m.txid) : null; + return h( + 'div', + { class: 'card col', style: 'gap:10px' }, + h('div', { class: 'row gap6', style: 'align-items:center' }, + h('h3', { style: 'margin:0' }, label), + h('span', { class: 'tag' }, 'Ark'), + m.status !== 'complete' ? h('span', { class: 'tag pending' }, m.status) : null), + h('div', { class: incoming ? 'amount-pos' : 'amount-neg', style: 'font-size:20px' }, + (incoming ? '+' : '-') + fmtAmount(m.amountSat) + ' ' + unitLabel()), + row(t('dateLabel'), new Date(m.ts).toLocaleString()), + m.to ? row(t('arkPayTo'), shortAddr(m.to, 16, 12)) : null, + m.vtxoId ? row(t('arkVtxoId'), shortTxid(m.vtxoId)) : null, + m.detail ? row(t('detailsLabel'), m.detail) : null, + m.txid + ? h('div', { class: 'col', style: 'gap:6px' }, + h('div', { class: 'small muted' }, t('transactionId')), + h('div', { class: 'addr-box', style: 'width:100%' }, m.txid), + h('div', { class: 'row gap6' }, + copyBtn(m.txid, t('copyTxid')), + h('a', { class: 'btn btn-sm', href: url, target: '_blank', rel: 'noopener', onClick: (e) => { e.preventDefault(); openExternal(url); } }, t('viewOnMempool')))) + : null, + m.to ? copyBtn(m.to, t('copyAddress')) : null, + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.arkMoveDetail = null; render(); } }, t('back')) + ); + } + + function arkCard() { + const net = getNetwork(); + const id = getArkProviderId(net); + const custom = getArkCustom(net); + const presets = arkPresets(net); + const applyProvider = (v) => { + setArkProviderId(v, net); + ui.receiveType = null; + initArk(); + render(); + }; + const head = [ + h('h3', {}, '⚔ Ark'), + h('p', { class: 'small muted', style: 'margin:0' }, t('arkDesc')), + h('select', { onChange: (e) => applyProvider(e.target.value) }, + presets.map((p) => h('option', { value: p.id, selected: p.id === id }, p.label))), + id === 'custom' + ? h('div', { class: 'col', style: 'gap:8px' }, + h('label', { class: 'field' }, + h('span', { class: 'lab' }, t('arkServerUrl')), + h('input', { + type: 'text', class: 'mono-input', placeholder: 'https://ark.example.com', + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: custom.ark, + onChange: (e) => { setArkCustom({ ark: e.target.value.trim(), esplora: custom.esplora }, net); initArk(); render(); }, + })), + h('label', { class: 'field' }, + h('span', { class: 'lab' }, t('arkEsploraUrl')), + h('input', { + type: 'text', class: 'mono-input', placeholder: 'https://mempool.example.com/api', + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: custom.esplora, + onChange: (e) => { setArkCustom({ ark: custom.ark, esplora: e.target.value.trim() }, net); initArk(); render(); }, + }))) + : null, + ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null, + ]; + if (id === 'off' || wallet.watchOnly) { + return h('div', { class: 'card col' }, ...head, + wallet.watchOnly && id !== 'off' ? h('div', { class: 'small faint' }, t('arkWatchOnly')) : null); + } + if (!ark || !ark.state) { + return h('div', { class: 'card col' }, ...head, + arkConnectPromise + ? h('div', { class: 'row gap6', style: 'align-items:center' }, + h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkConnecting'))) + : h('button', { class: 'btn-ghost btn-block', onClick: () => connectArk().catch(() => {}) }, t('arkConnectBtn'))); + } + + const spendables = ark.vtxos().filter((v) => v.state === 'spendable'); + const pendingActions = ark.pendingActions(); + const row = (k, v) => h('div', { class: 'row between' }, h('span', { class: 'small muted' }, k), h('span', { class: 'small' }, v)); + + return h( + 'div', + { class: 'card col', style: 'gap:10px' }, + ...head, + spendables.length ? row(t('arkVtxos'), String(spendables.length)) : null, + pendingActions.length + ? h('div', { class: 'small muted' }, t('arkPendingActions', { n: pendingActions.length }) + ' — ' + pendingActions.map((a) => `${a.type}:${a.step}`).join(', ')) + : null, + spendables.length >= 1 + ? h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy, onClick: doArkRefresh }, + ui.arkBusy === 'refresh' ? h('span', { class: 'spinner sm' }) : t('arkRefreshBtn', { n: spendables.length })) + : null + ); + } + + async function doArkBoard() { + const sats = parseInt((ui.arkBoardAmt || '').trim(), 10); + if (!sats) return; + ui.arkBusy = 'board'; ui.arkError = ''; render(); + try { + const { actionId, fundingAddress, feeSat } = await ark.startBoard(sats); + const feeRate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; + const draft = wallet.buildTx({ recipients: [{ address: fundingAddress, amount: sats }], feeRate, noSort: true }); + const hexTx = wallet.sign(draft.tx); + const txid = await wallet.broadcast(hexTx); + await ark.completeBoard(actionId, txid); + ui.arkBoardAmt = ''; + ui.arkBoarded = { txid, netSat: sats - feeSat }; + wallet.scan().catch(() => {}); + } catch (e) { + ui.arkError = e.message; + } + ui.arkBusy = null; render(); + } + + async function doArkRefresh() { + ui.arkBusy = 'refresh'; ui.arkError = ''; render(); + try { + await ark.refresh(); + toast(t('arkRefreshStarted'), 5000); + } catch (e) { + ui.arkError = e.message; + } + ui.arkBusy = null; render(); + } + + // The Ark receive pane: connect-on-demand, then address + board form. + function arkReceivePane(seg) { + if (true) { + if (!ark) { + connectArk().catch(() => {}); + return h( + 'div', + { class: 'card col', style: 'align-items:center;gap:14px' }, + seg, + ui.arkError + ? h('div', { class: 'notice err', style: 'width:100%' }, ui.arkError) + : h('div', { class: 'row gap6', style: 'align-items:center;padding:24px 0' }, + h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkConnecting'))) + ); + } + const arkAddr = ark.address(); + const minBoard = ark.info.minBoardAmountSat || 0; + const canBoard = wallet.spendable >= minBoard; + return h( + 'div', + { class: 'card col', style: 'align-items:center;gap:14px' }, + seg, + h('p', { class: 'small muted', style: 'margin:0;text-align:center' }, t('arkReceiveIntro')), + h('div', { html: qrSvg(arkAddr) }), + h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, arkAddr), + copyBtn(arkAddr, t('copyAddress')), + // Board: fund your Ark balance from this wallet's on-chain coins. + h('div', { class: 'col', style: 'width:100%;gap:8px;border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:14px' }, + h('div', { class: 'small muted', style: 'text-align:center' }, t('arkBoardIntro')), + h('div', { class: 'input-group' }, + h('input', { + type: 'number', inputmode: 'numeric', min: '0', + placeholder: t('arkBoardPlaceholder', { n: minBoard.toLocaleString() }), + value: ui.arkBoardAmt || '', + onInput: (e) => { ui.arkBoardAmt = e.target.value; }, + }), + h('button', { class: 'btn-sm', disabled: !!ui.arkBusy || !canBoard, onClick: doArkBoard }, + ui.arkBusy === 'board' ? h('span', { class: 'spinner sm' }) : t('arkBoardBtn'))), + canBoard + ? h('div', { class: 'small faint', style: 'text-align:center' }, t('arkBoardAvailable', { n: fmtAmount(wallet.spendable) + ' ' + unitLabel() })) + : h('div', { class: 'small faint', style: 'text-align:center' }, t('arkBoardNoFunds', { n: minBoard.toLocaleString() })), + ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null) + ); + } + throw new Error('unreachable'); + } + + // "Payment received!" takeover for unseen Ark receives. + function arkCelebration() { + // An Ark payment landed — same celebration as an on-chain receive, dismissed + // with a tap (the ack persists in the ark state so it shows exactly once). + const arkUnseen = ark && ark.state ? ark.unseenReceives() : []; + if (arkUnseen.length) { + const amt = arkUnseen.reduce((n, m) => n + m.amountSat, 0); + return h( + 'div', + { + class: 'card col', + style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px', + onClick: () => { ark.ackReceives(); render(); }, + }, + h('div', { class: 'check-badge' }, '✓'), + h('h2', { style: 'margin:0' }, t('paymentReceived')), + amt ? h('div', { class: 'amount-pos', style: 'font-size:18px' }, '+' + fmtAmount(amt) + ' ' + unitLabel()) : null, + h('div', { class: 'small muted' }, t('tapToProceed')) + ); + } + return null; + } + + // Boarding success screen (txid + explorer link) until dismissed. + function arkBoardedScreen() { + // A board just broadcast — show its success screen until dismissed. + if (ui.arkBoarded) { + const b = ui.arkBoarded; + const url = wallet.api.explorerTx(b.txid); + return h( + 'div', + { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;padding:40px 20px' }, + h('div', { class: 'check-badge' }, '✓'), + h('h2', { style: 'margin:0' }, t('arkBoardedTitle')), + h('div', { class: 'amount-pos', style: 'font-size:18px' }, '+' + fmtAmount(b.netSat) + ' ' + unitLabel()), + h('p', { class: 'small muted', style: 'margin:0' }, t('arkBoardedNote')), + h('div', { class: 'addr-box', style: 'width:100%' }, b.txid), + h('div', { class: 'row gap6' }, + copyBtn(b.txid, t('copyTxid')), + h('a', { class: 'btn btn-sm', href: url, target: '_blank', rel: 'noopener', onClick: (e) => { e.preventDefault(); openExternal(url); } }, t('viewOnMempool'))), + h('button', { class: 'btn-primary btn-block', onClick: () => { ui.arkBoarded = null; render(); } }, t('done')) + ); + } + return null; + } + + return { + id: 'ark', + init() { initArk(); }, + stop() { stopArk(); }, + receiveModes() { + if (!arkAvailable()) return []; + return [{ id: 'ark', label: t('receiveArkTab'), render: (seg) => arkReceivePane(seg) }]; + }, + receiveTakeover() { + const boarded = arkBoardedScreen(); + if (boarded) return boarded; + return arkCelebration(); + }, + isSendDest(a) { return isArkAddress(a) && arkAvailable(); }, + hideSendControls(a) { return isArkAddress(a); }, + sendFormNote(a) { + return isArkAddress(a) ? h('div', { class: 'small faint' }, t('arkSendHint')) : null; + }, + interceptReview(s) { + if (s.recipients.length === 1 && isArkAddress(s.recipients[0].address)) { + if (!arkAvailable()) throw new Error(t('arkNotConnected')); + const sats = ctx.parseAmount(s.recipients[0].amount, ctx.getUnit()); + if (!sats || sats <= 0) throw new Error(t('enterValidAmtForN', { n: 1 })); + ui.arkSend = { address: s.recipients[0].address.trim(), amountSat: sats }; + render(); + return true; + } + return false; + }, + sendView() { + if (ui.arkSent || ui.arkSend) return arkSendReview(); + return null; + }, + historyEntries() { + if (!ark || !ark.state) return []; + return ark.movements() + .filter((m) => ['receive', 'send', 'board'].includes(m.type) && m.status === 'complete') + .map((m) => ({ time: m.ts, render: () => arkHistoryItem(m) })); + }, + historyDetail() { + if (!ui.arkMoveDetail) return null; + const m = ark && ark.state ? ark.movements().find((x) => x.id === ui.arkMoveDetail) : null; + if (m) return arkMoveDetailView(m); + ui.arkMoveDetail = null; + return null; + }, + balanceLines() { + const b = arkBalance(); + if (!b || (b.spendableSat + b.pendingSat) <= 0) return []; + return [{ label: t('arkBalance'), sat: b.spendableSat + b.pendingSat }]; + }, + settingsCards() { return [arkCard()]; }, + }; +} diff --git a/src/features/sp.js b/src/features/sp.js new file mode 100644 index 0000000..0960cdf --- /dev/null +++ b/src/features/sp.js @@ -0,0 +1,57 @@ +// Silent payments (BIP-352) feature — the reusable sp1… receive address and +// the tweak-indexer settings. (The SP scanning/spending machinery itself lives +// in wallet.js/silentpay.js and ships with the core wallet for now.) + +import { isSilentPaymentAddress } from '../silentpay.js'; +import { getNetwork, getSpIndexerConfig, setSpIndexerConfig, spIndexerPresets } from '../api.js'; +import { t } from '../i18n.js'; +import { qrSvg } from '../qr.js'; + +export function spFeature(ctx) { + const { h, ui, render, wallet, copyBtn } = ctx; + + function spIndexerCard() { + if (!wallet.silentPaymentKeys || !wallet.silentPaymentKeys()) return null; + const net = getNetwork(); + const cfg = getSpIndexerConfig(net); + return h( + 'div', + { class: 'card col' }, + h('h3', {}, t('spIndexerTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('spIndexerDesc')), + h('select', { onChange: (e) => { setSpIndexerConfig({ server: e.target.value, url: cfg.url }, net); render(); } }, + spIndexerPresets(net).map((o) => h('option', { value: o.id, selected: o.id === cfg.server }, o.id === 'custom' ? o.label : o.url.replace(/^\w+:\/\//, '')))), + cfg.server === 'custom' + ? h('label', { class: 'field' }, + h('span', { class: 'lab' }, t('spIndexerUrl')), + h('input', { + type: 'text', class: 'mono-input', placeholder: 'https://your-indexer:8888', + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: cfg.url, + onChange: (e) => { setSpIndexerConfig({ server: 'custom', url: e.target.value.trim() }, net); render(); }, + }), + h('div', { class: 'small faint' }, t('spIndexerHint'))) + : null + ); + } + + return { + id: 'sp', + receiveModes() { + const spAddr = wallet.silentPaymentsAvailable && wallet.silentPaymentsAvailable() ? wallet.silentPaymentAddress() : null; + if (!spAddr) return []; + return [{ + id: 'sp', label: t('receiveSpTab'), + render: (seg) => h( + 'div', + { class: 'card col', style: 'align-items:center;gap:14px' }, + seg, + h('div', { html: qrSvg(spAddr) }), + h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, spAddr), + copyBtn(spAddr, t('copyAddress')) + ), + }]; + }, + isSendDest(a) { return isSilentPaymentAddress(a); }, + settingsCards() { return [spIndexerCard()]; }, + }; +} diff --git a/src/features/swaps.js b/src/features/swaps.js new file mode 100644 index 0000000..636a9f8 --- /dev/null +++ b/src/features/swaps.js @@ -0,0 +1,218 @@ +// Lightning swaps feature (Boltz reverse/submarine) — receive over LN, +// pay bolt11 invoices, provider settings. Extracted from app.js; the +// protocol layer lives in ../swap.js. + +import { SwapManager } from '../swap.js'; +import { getNetwork, getBoltzApi, BOLTZ_PRESETS, getBoltzProviderId, setBoltzProviderId, getBoltzCustom, setBoltzCustom } from '../api.js'; +import { t } from '../i18n.js'; +import { qrSvg } from '../qr.js'; +import { fmtBtc } from '../format.js'; + +// A bolt11 Lightning invoice (any network prefix); long ln-prefixed bech32 string. +function isLnInvoice(v) { v = (v || '').trim(); return /^ln[a-z0-9]+$/i.test(v) && v.length > 50; } + +export function swapsFeature(ctx) { + const { h, ui, render, wallet, blankSend, fmtAmount, unitLabel, copyBtn } = ctx; + + // Boltz swap orchestrator (reverse = receive over LN, submarine = spend over LN). + const swaps = new SwapManager({ + wallet, network: getNetwork(), getApi: getBoltzApi, + feeRate: 2, onUpdate: () => render(), + }); + + function lnReceiveContent() { + if (!ui.swapLimits && !ui._swapLimitsLoading) { + ui._swapLimitsLoading = true; + swaps.reverseLimits().then((l) => { ui.swapLimits = l; ui._swapLimitsLoading = false; render(); }).catch(() => { ui._swapLimitsLoading = false; }); + } + const lim = ui.swapLimits; + let rec = ui.lnReceiveId ? swaps.list().find((s) => s.id === ui.lnReceiveId) : null; + if (rec && ['claimed', 'success'].includes(rec.status)) { + // Claimed — the coin is credited on-chain (swap.js → creditReceive), so the + // generic "Payment received!" celebration shows it like any receive. Just + // reset the Lightning flow back to its form. + ui.lnReceiveId = null; + rec = null; + } + if (rec) { + let svg = null; + try { svg = qrSvg(rec.invoice.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {} + return [ + h('p', { class: 'small muted', style: 'margin:0;text-align:center' }, t('lnReceiveAwaiting')), + svg ? h('div', { html: svg }) : null, + h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, rec.invoice), + copyBtn(rec.invoice, t('copyInvoice')), + h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('lnReceiveWatching'))), + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.lnReceiveId = null; ui.swapReverseAmt = ''; ui.swapError = ''; render(); } }, t('back')), + ]; + } + return [ + h('p', { class: 'small muted', style: 'margin:0;text-align:center' }, t('lnReceiveIntro')), + h('label', { class: 'field', style: 'width:100%' }, + h('span', { class: 'lab' }, t('amountSatsLabel')), + h('input', { type: 'number', inputmode: 'numeric', placeholder: '', value: ui.swapReverseAmt || '', onInput: (e) => { ui.swapReverseAmt = e.target.value; } }), + lim ? h('span', { class: 'small muted' }, `Min ${lim.min.toLocaleString()} · max ${lim.max.toLocaleString()} sats`) : null), + ui.swapError ? h('div', { class: 'notice err' }, ui.swapError) : null, + h('button', { class: 'btn-primary btn-block', disabled: !!ui.swapBusy, onClick: doReverse }, ui.swapBusy ? h('span', { class: 'spinner' }) : t('lnCreateInvoice')), + ]; + } + + async function doReverse() { + const amt = parseInt((ui.swapReverseAmt || '').trim(), 10); + const lim = ui.swapLimits || (ui.swapLimits = await swaps.reverseLimits()); + if (!amt || amt < lim.min || amt > lim.max) { ui.swapError = `Enter ${lim.min.toLocaleString()}–${lim.max.toLocaleString()} sats`; return render(); } + ui.swapError = ''; ui.swapBusy = true; render(); + try { const rec = await swaps.startReverse(amt); ui.lnReceiveId = rec.id; ui.swapReverseAmt = ''; } + 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 boltzProviderCard() { + const id = getBoltzProviderId(); + const custom = getBoltzCustom(); + return h( + 'div', + { class: 'card col' }, + h('h3', {}, '⚡ Swap provider'), + h('p', { class: 'small muted', style: 'margin:0' }, 'Boltz-compatible provider for Lightning swaps. Non-custodial: a provider can fail a swap but never take your funds.'), + h('select', { onChange: (e) => { setBoltzProviderId(e.target.value); ui.swapLimits = null; render(); } }, + BOLTZ_PRESETS.map((p) => h('option', { value: p.id, selected: p.id === id }, p.label))), + id === 'custom' + ? h('div', { class: 'col', style: 'gap:8px' }, + h('label', { class: 'field' }, + h('span', { class: 'lab' }, 'API URL'), + h('input', { + type: 'text', class: 'mono-input', placeholder: 'https://api.example.com', + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: custom.api, + onChange: (e) => { setBoltzCustom({ api: e.target.value.trim(), ws: custom.ws }); ui.swapLimits = null; render(); }, + })), + h('label', { class: 'field' }, + h('span', { class: 'lab' }, 'WebSocket URL (optional)'), + h('input', { + type: 'text', class: 'mono-input', placeholder: 'wss://api.example.com/v2/ws', + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: custom.ws, + onChange: (e) => { setBoltzCustom({ api: custom.api, ws: e.target.value.trim() }); render(); }, + }), + h('div', { class: 'small faint' }, 'Left blank, the WS is derived from the API URL.'))) + : h('div', { class: 'small faint', style: 'word-break:break-all' }, getBoltzApi()) + ); + } + + function swapTxDetailSection(swap, tx) { + const u = ' ' + unitLabel(); + const line = (k, v) => h('div', { class: 'line' }, h('span', { class: 'k' }, k), h('span', { class: 'v' }, v)); + const head = (txt) => h('div', { style: 'font-weight:600;margin:12px 0 2px' }, txt); + if (swap.refundTxid === tx.txid) + return h('div', { class: 'summary col', style: 'gap:0' }, head('↩ Lightning swap refund'), line(t('status'), 'Refunded ✓')); + if (swap.kind === 'submarine') { + const fee = (swap.expectedAmount || 0) - (swap.invoiceAmount || 0); + const st = swap.status === 'success' ? 'Invoice paid ✓' + : swap.status === 'refundable' ? 'Payment failed — refundable' + : swap.status === 'refunded' ? 'Refunded' : 'Paying…'; + return h('div', { class: 'summary col', style: 'gap:0' }, + head('⚡ Paid over Lightning'), + swap.invoiceAmount != null ? line('Invoice', fmtAmount(swap.invoiceAmount) + u) : null, + fee > 0 ? line('Swap fee', fmtAmount(fee) + u) : null, + line(t('status'), st), + swap.status === 'refundable' + ? h('button', { class: 'btn-sm btn-block', style: 'margin-top:8px', disabled: !!ui.swapBusy, onClick: () => doRefund(swap.id) }, 'Refund on-chain') + : null); + } + // reverse swap claim + return h('div', { class: 'summary col', style: 'gap:0' }, + head('⚡ Received over Lightning'), + line(t('status'), swap.status === 'claimed' ? 'Settled ✓' : swap.status)); + } + + async function startLnSend(invoice) { + ui.sendError = ''; ui.lnSend = null; ui.lnSent = null; ui.lnSendBusy = true; render(); + try { ui.lnSend = await swaps.quoteSubmarine(invoice); } + catch (e) { ui.sendError = e.message; } + ui.lnSendBusy = false; render(); + } + + async function doLnPay() { + if (!ui.lnSend) return; + ui.sendError = ''; ui.lnSendBusy = true; render(); + try { const rec = await swaps.fundQuotedSubmarine(ui.lnSend); ui.lnSent = { amount: ui.lnSend.invoiceAmount, id: rec.id }; } + catch (e) { ui.sendError = e.message; } + ui.lnSendBusy = false; render(); + } + + function lnSendReview() { + const u = ' ' + unitLabel(); + if (ui.lnSent) { + return h('div', { class: 'col', style: 'gap:16px' }, + h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:8px' }, + h('div', { class: 'check-badge' }, '⚡'), + h('h2', { style: 'margin:0' }, t('lnPaySentTitle')), + ui.lnSent.amount ? h('div', { class: 'amount-pos', style: 'font-size:18px' }, fmtAmount(ui.lnSent.amount) + u) : null, + h('p', { class: 'muted', style: 'margin:0' }, t('lnPaySentBody'))), + h('button', { class: 'btn-primary btn-block', onClick: () => { ui.lnSent = null; ui.lnSend = null; ui.send = blankSend(); ui.tab = 'history'; render(); } }, t('done'))); + } + const q = ui.lnSend; + const row = (label, sats, bold) => h('div', { class: 'row', style: 'justify-content:space-between' + (bold ? ';font-weight:600' : '') }, + h('span', { class: bold ? '' : 'small muted' }, label), h('span', {}, fmtAmount(sats) + u)); + return h('div', { class: 'col', style: 'gap:12px' }, + h('div', { class: 'card col', style: 'gap:10px' }, + h('h3', { style: 'margin:0' }, t('lnPayTitle')), + q.invoiceAmount != null ? row(t('lnPayAmount'), q.invoiceAmount) : null, + q.boltzFee != null ? row(t('lnPayBoltzFee'), q.boltzFee) : null, + row(t('lnPayNetworkFee'), q.networkFee), + h('div', { style: 'border-top:1px solid var(--line, #ddd);margin:2px 0' }), + row(t('lnPayTotal'), q.total, true)), + ui.sendError ? h('div', { class: 'notice err' }, ui.sendError) : null, + h('button', { class: 'btn-primary btn-block', disabled: !!ui.lnSendBusy, onClick: doLnPay }, ui.lnSendBusy ? h('span', { class: 'spinner' }) : t('lnPayConfirm')), + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.lnSend = null; ui.sendError = ''; render(); } }, t('back'))); + } + + return { + id: 'swaps', + // wallet activated: re-attach in-flight swap watchers + init() { + swaps.network = getNetwork(); + if (!wallet.watchOnly) { try { swaps.resumeAll(); } catch {} } + }, + networkChanged(net) { swaps.network = net; ui.swapLimits = null; }, + receiveModes() { + // a reverse swap needs our keys to claim on-chain + if (wallet.watchOnly) return []; + return [{ + id: 'ln', label: t('receiveLnTab'), + render: (seg) => h('div', { class: 'card col', style: 'align-items:center;gap:14px' }, seg, ...lnReceiveContent()), + }]; + }, + // a pasted/typed/scanned bolt11 auto-advances to its own confirmation + matchSendText(text) { + const inv = (text || '').trim().replace(/^lightning:/i, ''); + if (ui.send.recipients.length === 1 && !ui.lnSendBusy && !ui.lnSend && isLnInvoice(inv)) { + startLnSend(inv); + return true; + } + return false; + }, + interceptReview(s) { + const target = s.recipients.length === 1 ? (s.recipients[0].address || '').trim().replace(/^lightning:/i, '') : ''; + if (isLnInvoice(target)) { startLnSend(target); return true; } + return false; + }, + sendView() { + if (ui.lnSent || ui.lnSend) return lnSendReview(); + if (ui.lnSendBusy) return h('div', { class: 'card col', style: 'align-items:center;gap:14px;padding:32px 14px' }, h('span', { class: 'spinner' }), h('p', { class: 'muted', style: 'margin:0' }, t('lnQuoting'))); + return null; + }, + txDetailSection(tx) { + const swap = swaps.findByTxid(tx.txid); + return swap ? swapTxDetailSection(swap, tx) : null; + }, + findByTxid(txid) { return swaps.findByTxid(txid); }, + settingsCards() { return [boltzProviderCard()]; }, + }; +} |
